스토리텔링 개발자

[Effective Modern C++] 19. std::shared_ptr 본문

Effective C++/Effective Modern C++

[Effective Modern C++] 19. std::shared_ptr

김디트 2025. 3. 4. 12:31
728x90

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

 

 

C++11에서의 std::shared_ptr
  • 가비지 컬랙팅의 편리함과 수동 수명 관리의 소멸 시점 예측성을 모두 갖춘 자원 관리 시스템
  • 공유 소유권(shared ownership) 의미론으로 관리한다.
  • 참조 카운트(reference count)를 사용하여 자원을 관리한다.

 

 

 

참조 카운트 관리와 성능
  • std::shared_ptr의 크기는 raw 포인터의 두배이다.
    • 참조 카운트 포인터도 저장해야 하기 때문이다.
  • 참조 카운트를 담을 메모리를 반드시 동적으로 할당해야 한다.
    • 공유 포인터가 가리키는 객체와는 별개로 할당되어야 하기 때문이다.
    • make_shared를 사용하면 동적 할당 비용을 피할 수 있다.
  • 참조 횟수의 증가와 감소가 반드시 아토믹(atomic)해야 한다.
    • 여러 쓰레드가 참조 카운트를 동시에 읽고 쓰려고 할 수 있기 때문이다.
    • 아토믹 연산은 일반적인 연산보다 느리다.

 

 

 

레퍼런트 카운트를 올리지 않는 경우
  • 이동 생성을 할 때는 카운트를 올리고 낮추는 것이 쓸데없는 자원의 소모일 뿐이다.

 

 

 

std::unique_ptr과의 차이
  • 커스텀 삭제자가 타입에 반영되지 않는다.
auto loggingDel = [](Widget* pw)
                  {
                      makeLogEntry(pw);
                  };

std::unique_ptr<Widget, decltype(loggingDel)> upw(new Widget, loggingDel);
std::shared_ptr<Widget> spw(new Widget, loggingDel);

// 덕분에 공유 포인터가 더 유연하게 사용할 수 있다.

// 서로 다른 커스텀 삭제자들
auto customDeleter1 = [](Widget* pw) { ... };
auto customDeleter2 = [](Widget* pw) { ... };

// 아래 두 공유 포인터는 동일한 타입이므로
std::shared_ptr<Widget> pw1(new Widget, customDeleter1);
std::shared_ptr<Widget> pw2(new Widget, customDeleter2);

// 같은 컨테이너에 넣을 수 있다.
std::vector<std::shared_ptr<Widget>> vpw{ pw1, pw2 };
  • 커스텀 삭제자를 지정해도 std::shared_ptr 객체의 크기가 변하지 않는다.
    • 삭제자를 할당한 메모리가 std::shared_ptr 객체의 일부가 아니기 때문이다.
    • 참조 카운트가 포함된 포인터 쪽의 공간에서 관리한다.
    • 공유 포인터 자체는 참조 카운트를 포함하는 제어 블록(control block)의 포인터만 가지므로 당연히 객체의 크기에는 변화가 없다.

 

 

 

제어 블록이 생성되는 타이밍
  • std::make_shared(항목 21 참조)를 사용하여 std::shared_ptr 객체를 생성하는 경우
    • 이 함수는 공유 포인터가 가리킬 객체를 새로 생성하기 때문에, 이전에 제어 블록이 존재할 리가 없다.
  • 고유 소유권 포인터(std::unique_ptr 혹은 std::auto_ptr)로부터 std::shared_ptr 객체를 생성하는 경우
  • raw 포인터로 std::shared_ptr 생성자를 호출하는 경우

 

 

 

하나의 raw 포인터로 여러 std::shared_ptr을 생성하면 미정의 행동
auto pw = new Widget;
...
std::shared_ptr<Widget> spw1(pw, loggingDel); // 공유 포인터 생성
...
std::shared_ptr<Widget> spw2(pw, loggingDel); // 또 다른 공유 포인터 생성?
// 제어 블록이 두 개가 된다!
// 범위를 벗어나면 파괴가 두 번 시도되게 된다!!(미정의 행동!)
  • 위의 예는 동적 할당 객체의 raw 포인터를 만드는 것이 왜 나쁜지의 또 다른 반례이기도 하다.
  • 잘못된 용법에서 배울 점
    • std::shared_ptr 생성자에 raw 포인터를 넘기는 일은 피하자.
      • std::make_shared를 사용한다.
      • 헌데 이걸 사용할 경우 커스텀 삭제자를 사용하지 못한다는 문제가 있긴 하다.
    • std::shared_ptr 생성자를 raw 포인터로 호출할 수밖에 없는 상황이라면, new의 결과를 직접 전달하자.
      • std::shared_ptr<Widget> spw1(new Widget, loggingDel); // new를 직접 사용
  • 또 다른 잘못된 용법 예
std::vector<std::shared_ptr<Widget>> processedWidgets;

class Widget
{
public:
    ...
    void process();
    ...
};

void Widget::process()
{
    ...
    processedWidgets.emplace_back(this); // 잘못된 방식!
    // 밖에서 이미 공유 포인터로 관리되고 있다면 큰일이다!
}
  • 위의 경우 std::enable_shared_from_this 템플릿을 사용하여 해결한다.
class Widget : public std::enable_shared_from_this<Widget>
{
// shared_from_this() 멤버 함수가 추가된다.
// 현재 객체에 대한 제어 블록을 조회하고,
// 그 제어 블록을 가리키는 공유 포인터를 생성한다.
// 즉, 밖에서 std::shared_ptr가 반드시 존재한다는 가정이 있다.
public:
    // 그러므로 팩토리 함수도 하나 제공해준다.
    template<typename... Ts>
    static std::shared_ptr<Widget> create(Ts&&... params);

    ...
    void process();
    ...
private:
    ... // 생성자들
    // std::shared_ptr가 하나도 없는 상황에서 shared_from_this가 불리지 않도록
    // 생성자는 private 처리한다.
};

void Widget::process()
{
    ...
    
    // 유효하다.
    processedWidgets.emplace_back(shared_from_this());
}

 

 

 

std::shared_ptr에 대한 추가적인 이야기
  • 제어 블록의 자원 소모
    • 크기는 몇 워드 정도이지만, 커스텀 삭제자나 할당자 때문에 더 커질 수도 있다.
    • 구현이 생각보다 복잡하다.
      • 상속을 활용하며, 가상함수도 있다.
    • 그래봤자 공유 포인터의 이점에 비하면 터무니없이 작은 자원 소모.
  • 혹 독점 소유권으로 충분하다면 std::unique_ptr을 사용하면 된다.
    • 언제든 shared_ptr로 업그레이드 할 수 있다.
    • 하지만 std::shared_ptr은 unique_ptr로 바꾸기 쉽지 않다.
  • 배열 관리를 할 수 없다.
    • 즉, std::shared_ptr<T[]> 같은 것이 없다.
    • std::shared_ptr<T>로 배열을 가리키되, 커스텀 삭제자에서 적법한 처리를 해주면 어떨까?
      • 허나 std::shared_ptr는 operator[]를 제공하지 않으므로 어색한 표현식을 동원해야 한다.
      • 그리고 std::shared_ptr는 파생 클래스 포인터에서 기반 클래스 포인터로의 변환을 지원하는데,
        • 배열에서는 그게 합당하지가 않다.
      • 또한! C++11은 그런 불편한 방식 외의 다양한 대안들이 있다.
        • std::array, std::vector, std::string 등
728x90
Comments