[Effective Modern C++] Chapter 4. 똑똑한 포인터 (Smart Pointer) [항목 18~22]

Chapter 4. 똑똑한 포인터 (Smart Pointer)

항목 18. 소유권 독점 자원의 관리에는 std::unique_ptr를 사용하라.

보통 smart pointer를 처음 접한 사람들은 std::shared_ptr만을 남용(?)하는 경향이 있다. 그러나 std::shared_ptr이 매우 강력한 존재이긴 하지만 크게 2가지 측면에서 단점이 있다. 첫 번째는 overhead이다. 참조 계수를 관리하기 위해 어쩔 수 없이 overhead가 존재한다. 두 번째는 돌이킬 수 없다는 점이다. 한번 std::shared_ptr에 pointer를 물리고 나면 다시는 일반 pointer로 복귀할 수 없다. (단순히 .get()을 쓰면 raw pointer를 받을 수 있을 뿐이다. 여전히 프로그램 어딘가에서 포인터를 참조할 수 있을 수 있다.)
이러한 단점들 때문에 나는 기본적으로 std::unique_ptr을 사용한다. std::unique_ptr은 덜 강력하지만 위 2가지 단점이 없다. 추가적인 메모리를 요구하는 커스텀 삭제자를 지정하지 않는다면 performance는 raw pointer와 사실상 동일하고, std::unique_ptr를 사용하다가 적합하지 않은 상황이 오면 언제든지 정책을 raw pointer나 std::shared_ptr등으로 바꿀 수 있다.
참고) raw pointer, std::unique_ptr, std::shared_ptr의 '생성 및 삭제' 성능 비교 : http://www.modernescpp.com/index.php/memory-and-performance-overhead-of-smart-pointer
특히 어떻게 쓰일 지 모르는 포인터를 반환해야 하는 경우는 std::unique_ptr를 쓰는 것이 좋다. std::unique_ptr은 어떤 형태로든지 변환이 가능하기 때문이다. 특별한 목적이 있는 경우가 아니라면, 팩토리 함수등에서 std::shared_ptr 을 반환하는 것은 좋지 않은 습관이다. 하지만 std::shared_ptr로 쓰일 것이 확실한 경우라면 std::shared_ptr을 반환하는 것이 좋다. 왜냐하면 std::unique_ptr로 반환된 뒤 이것을 std::shared_ptr로 변환할 경우에는 actual object와 control block이 따로 관리되기 때문이다. 반면에 바로 std::shared_ptr로 반환할 경우에는 애초에 std::make_shared를 사용할 수 있기 때문에 더 효율적이다. (이에 관해서는 내가 2015년에 썼던 http://blog.naver.com/likeme96/220564843267 을 참고)
#include <memory>
#include <iostream>

class Example {};

void deletor_func(Example *ptr) { delete ptr; }

struct DeletorClass {
    void operator() (Example *ptr) const { delete ptr; }
};

struct DeletorClass2 {
    DeletorClass2(std::int32_t num)
    {
        m_arr[0] = m_arr[1] = num;
    }
    void operator() (Example *ptr) const { delete ptr; }
    std::int32_t m_arr[2];
};

int main()
{
    auto a = std::make_unique<Example>();

    auto deletor = [](Example *ptr) { delete ptr; };
    auto b = std::unique_ptr<Example, decltype(deletor)>(new Example, deletor);
    /* In C++17,
        auto b = std::unique_ptr(new Example, [](Example *ptr) { delete ptr; });
    */

    int num = 13;
    auto deletor2 = [num](Example *ptr) { delete ptr; };
    auto c = std::unique_ptr<Example, decltype(deletor2)>(new Example, deletor2);

    auto d = std::unique_ptr<Example, void(*)(Example *)>(new Example, [](Example *ptr) { delete ptr; });
    auto e = std::unique_ptr<Example, DeletorClass>(new Example, DeletorClass());
    auto f = std::unique_ptr<Example, DeletorClass2>(new Example, DeletorClass2(1));

    // https://en.wikibooks.org/wiki/More_C%2B%2B_Idioms/Empty_Base_Optimization
    // Result in x86 : 4 4 8 8 4 12
    std::cout << sizeof(a) << " " << sizeof(b) << " " << sizeof(c) << " " << 
        sizeof(d) << " " << sizeof(e) << " " << sizeof(f) << std::endl;
}

위는 custom deletor를 사용하는 경우에 객체의 size가 얼마나 늘어나는 지에 대한 코드이다. 주목할 점은 std::unique_ptr은 custom deletor가 type의 일부가 된다는 것이다. 이로 인해 유연함은 떨어지지만, 매우 효율적이다.
위 결과 중 b,c,e의 경우 어떻게 custom deletor의 역할을 할 수 있는 fuctor object를 넘겼는데도 불구하고, size가 4일 수 있는지 궁금할 것이다. 이 것은 내가 실험한 visual studio 2015에서 std::unique_ptr 내부적으로 empty base optimization을 활용하기 때문이다. 이 것이 std::unique_ptr 표준에 명시된 내용인지는 모르겠지만, 대부분의 표준 라이브러리 구현의 경우 EBO를 활용해서 이런 식으로 최적화를 할 것이다. (참고 : https://en.wikibooks.org/wiki/More_C%2B%2B_Idioms/Empty_Base_Optimization)

[기억해 둘 사항들]
- std::unique_ptr는 독점 소유권 의미론을 가진 자원의 관리를 위한, 작고 빠른 이동 전용 똑똑한 포인터이다.
- 기본적으로 자원 파괴는 delete를 통해 일어나나, 커스텀 삭제자를 지정할 수 도 있다. 상태 있는 삭제자나 함수 포인터를 사용하면 std::unique_ptr 객체의 크기가 커진다.
- std::unique_ptr를 std::shared_ptr로 손쉽게 변환할 수 있다.

항목 19. 소유권 공유 자원의 관리에는 std::shared_ptr를 사용하라.

std::shared_ptr은 std::unique_ptr과는 다르게 커스텀 삭제자가 타입의 일부가 아니다. 좀 더 유연하게 사용이 가능하지만, 서로 다른 타입의 커스텀 삭제자를 포함하기 위해 포인터 한 개를 더 가져야 하는 memory overhead와 최적화 방해 요소가 생기게 된다. std::shared_ptr은 참조 횟수 관리가 필요한 순간에 overhead가 있다. (생성, 소멸, 복사 등..) 그러면 dereference 일 때는 어떨까? 이 경우는 단순하게 생각하면 overhead가 없어 보인다. 그러나 실제로 std::shared_ptr은 raw pointer에 비해 2배의 메모리를 사용하므로, cache 측면에서 불리하다. 즉 std::shared_ptr의 배열과 raw pointer의 배열이 있을 때 dereferencing performance를 비교하면 std::shared_ptr이 더 느리다. 즉, 상황에 따라 다르긴 하지만 std::shared_ptr은 dereference에 있어서도 overhead가 있다는 것에 유의해야 한다.

[참조 횟수 관리가 성능에 끼치는 영향]
- std::shared_ptr의 크기가 생 포인터의 두 배이다.
- 참조 횟수를 담을 메모리를 반드시 동적으로 할당해야 한다.
- 참조 횟수의 증가와 감소가 반드시 원자적 연산이어야 한다.

[기억해 둘 사항들]
- std:;shared_ptr는 임의의 공유 자원의 수명을 편리하게(쓰레기 수거에 맡길 때 만큼이나) 관리할 수 있는 수단을 제공한다.
- 대체로 std::shared_ptr 객체는 그 크기가 std::unique_ptr 객체의 두 배이며, 제어 블록에 관련된 추가 부담을 유발하며, 원자적 참조 횟수 조작을 요구한다.
- 자원은 기본적으로 delete를 통해 파괴되나, 커스텀 삭제자도 지원된다. 삭제자의 형식은 std::shared_ptr의 형식에 아무런 영향도 미치지 않는다.
- 생 포인터 형식의 변수로부터 std::shared_ptr를 생성하는 일은 피해야 한다.

항목 20. std::shared_ptr처럼 작동하되 대상을 잃을 수도 있는 포인터가 필요하면 std::weak_ptr를 사용하라.

올 초에 Microsoft/CNTK 의 dangling pointer 문제를 std::weak_ptr을 이용해서 해결해 PR을 날리고 merge된 경험이 있다. std::weak_ptr이 쓰이는 실제 사례가 알고 싶은 분은 다음 링크를 참고해보면 좋을 것 같다.

[기억해 둘 사항들]
- std::shared_ptr처럼 작동하되 대상을 잃을 수도 있는 포인터가 필요하면 std::weak_ptr를 사용하라.
- std::weak_ptr의 잠재적인 용도로는 캐싱, 관찰자 목록, 그리고 std::shared_ptr 순환 고리 방지가 있다.

항목 21. new를 직접 사용하는 것보다 std::make_unique와 std::make_shared를 선호하라.

#include <iostream>
#include <memory>

class Example
{
public:
    Example(int, int)
    { std::cout << "Example(int, int) called!" << std::endl;}

    /* http://stackoverflow.com/questions/17803475/why-is-stdinitializer-list-often-passed-by-value */
    Example(std::initializer_list<int>)
    { std::cout << "Example(std::initializer_list<int>) called!" << std::endl;}
};

int main()
{
    auto a = std::make_shared<Example>(1, 2); /* Example(int, int) called! */
    auto b = std::make_shared<Example>(std::initializer_list<int>{1, 2}); /* Example(std::initializer_list<int>) called! */
    auto tmp = { 1, 2 };
    auto c = std::make_shared<Example>(tmp); /* Example(std::initializer_list<int>) called! */

    /* https://akrzemi1.wordpress.com/2016/07/07/the-cost-of-stdinitializer_list/ */
    int arr[4] = { 1,3,5,7 };
    auto qq = { arr[0],2,arr[1],4,arr[2],6,arr[3],8 };
    std::cout << sizeof(qq) << std::endl; // 8 in x86, 16 in x64
}

중괄호 초기치를 perfect forwarding 할 수 없는 한계점 때문에 make_* 의 사용이 불가능하다면 위 코드에 나와있는 방법으로 해결할 수 있다.

[기억해 둘 사항들]
- new의 직접 사용에 비해, make 함수를 사용하면 소스 코드 중복의 여지가 없어지고, 예외 안전성이 향상되고, std::make_shared와 std::allocate_shared의 경우 더 작고 빠른 코드가 산출된다.
- make 함수의 사용이 불가능 또는 부적합한 경우로는 커스텀 삭제자를 지정해야 하는 경우와 중괄호 초기치를 전달해야 하는 경우가 있다.
- std::shared_ptr에 대해서는 make 함수가 부적합한 경우가 더 있는데, 두 가지 예를 들자면 (1) 커스텀 메모리 관리 기능을 가진 클래스를 다루는 경우와 (2) 메모리가 넉넉하지 않은 시스템에서 큰 객체를 자주 다루어야 하고 std::weak_ptr들이 해당 std::shared_ptr들보다 더 오래 살아남는 경우이다.

항목 22. Pimpl 관용구를 사용할 때에는 특수 멤버 함수들을 구현 파일에서 정의하라.

std::unique_ptr 형식의 pImpl 포인터를 사용할 때 '거지같은 문제'들이 발생하는 이유는 바로 std::default_delete 때문이다. std::unique_ptr은 deletor type이 template 인자로서 type에 포함된다. 그리고 그 template 인자의 기본 값이 std::default_delete이다. 그리고 std::default_delete는 compile-time에 definition이 알려지고, 이 definition안에는 delete 구문이 있다. 이 delete 구문은 객체의 소멸자를 호출할 것이다. 따라서 incomplete type은 std::unique_ptr과 함께 쓸 수 없다. 따라서 이를 해결하기 위해 스콧 마이어스가 effective modern C++를 통해 제안한 것이 특수 멤버 함수들을 클래스 헤더에 선언하고, 구현파일에서 구현하는 방법이다. 미안하지만 참 별로다.. 문제의 근원은 바로 std::default_delete이다. 따라서 이것을 사용하지 않으면 된다. 결론은 std::unique_ptr<Impl, void (*)(Impl *)> 와 같이 function pointer를 deletor type으로 지정해주고, 생성자에서만 deletor를 넣어주는 것이다. 물론 이렇게 할 경우, function pointer를 담아야 하므로 std::unique_ptr의 크기가 2배 커지는 단점이 있다. 이를 해결하기 위해서는 function pointer가 아닌 Functor를 활용하면 된다. struct Deletor { void operator() (Example *ptr) const { delete ptr; } }; 를 만든 뒤 std::unique_ptr<Impl, Deletor> 하는 식으로 하면 std::unique_ptr의 크기는 그대로 유지하면서 스콧마이어스가 제안한 방법보다 더 깔끔해진다.
관련 내용에 더 관심이 있는 사람들은 아래 링크들을 보길 바란다.
https://howardhinnant.github.io/incomplete.html
http://oliora.github.io/2015/12/29/pimpl-and-rule-of-zero.html (강추)

[기억해 둘 사항들]
- Pimpl 관용구는 클래스 구현과 클래스 클라이언트 사이으이 컴파일 의존성을 줄임으로써 빌드 시간을 감소한다.
- std::unique_ptr 형식의 pImpl 포인터를 사용할 때에는 특수 멤버 함수들을 클래스 헤더에 선언하고 구현 파일에서 구현해야 한다. 컴파일러가 기본으로 작성하는 함수 구현들이 사용하기에 적합한 경우에도 그렇게 해야 한다.
- 위의 조언은 std::unique_ptr에 적용될 뿐, std:;shared_ptr에는 적용되지 않는다.



후... 최근에 너무 바빴어서 포스팅을 못했다... 이제 시험기간이니까 여유가 좀 나서 포스팅을 좀 자주 할 거 같다. (시험공부는 버린다!)
포스팅 하는 거 너무 힘든 거 같다... 책에 있는 내용 반복하는 건 내 스타일 아니고.. 책 이상의 감동(?)을 전하려다보니 귀찮고 힘들다. (네이버 블로그라서 어차피 아무도 안볼텐데...)
어쨌든 이 책은 포스팅 시작했으니 끝을 보긴 할테지만... GoF의 디자인패턴이랑 Optimized C++을 포스팅을 할 지 말지 고민이다.. 흠... 일단 이 두 책은 다 읽고 생각하자...ㅠㅜㅜ

댓글