ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • [Effective Modern C++] Smart pointer : std::shared_ptr<>
    C++/Effective Modern C++ 2022. 5. 16. 15:26

    스마트 포인터란 Raw pointer를 감싸는 래퍼(Wrapper) 클래스 입니다.

    기존의 포인터가 가진 몇 가지 문제점들을 회피하면서, 포인터의 기능을 제공합니다.

     

    이번 글에서는 스마트 포인터의 한 종류인 std::shared_ptr<>에 대하여 살펴보도록 하겠습니다.

     


     

    1. std::shared_ptr<>

     

    더보기

    std::shared_ptr<>는 공유되는 포인터 입니다.

    한 객체를 포인팅하는 여러개의 std::shared_ptr는 객체에 대한 공동 소유권(Shared ownership)을 가지고 있습니다.

    해당 객체는 객체를 소유하는 모든 std::shared_ptr가 객체를 가리키지 않게 되었을 때 소멸합니다.

    위와 같은 공동 소유권을 구현하기 위해, std::shared_ptr는 내부적으로 자원의 참조 횟수(Reference count)를 가지게 됩니다.

    위 카운트는 해당 객체를 가리키는 std::shared_ptr의 갯수이며, 이 카운트가 0이 되었을 때 해당 객체가 소멸합니다.

     

    위와 같은 특징은 성능에 대한 아래와 같은 영향이 있습니다.

    • std::shared_ptr은 자원의 참조 횟수를 가리키는 포인터가 추가로 존재해야 합니다. 따라서, Raw pointer에 비해 최소 두 배의 크기를 가지게 됩니다.
    • 참조 횟수는 공유 객체와 관련된 정보지만, 그 객체는 참조 횟수를 알 수 없습니다. 이는 공유 객체가 참조 횟수를 위한 메모리를 추가적으로 할당하지 않는다는 뜻 입니다. 따라서, 참조 횟수를 담는 메모리는 동적으로 할당되어야 합니다.
    • 참조 횟수의 증감 연산은 원자적 연산이어야 합니다. 여러 스레드에서 동시에 읽기, 쓰기 연산이 발생할 경우, 올바르지 않은 값이 될 수 있기 때문입니다. 그런데 원자적 연산은 비 원자적 연산보다 느리므로, std::shared_ptr을 이용하는 연산은 그렇지 않은 연산보다 느릴 것이라고 가정하는게 마땅합니다.

     

    2. Reference count

     

    더보기

    참조 횟수는 해당 객체를 가리키는 std::shared_ptr의 갯수입니다.

    참조 횟수는 std::shared_ptr객체의 소멸, 생성, 이동에 따라 자동으로 변하게 됩니다.

    #include <iostream>
    #include <memory>
    
    class Integer {
    public:
    	Integer(int val) : data(val) { }
    private:
    	int data;
    };
    
    int main(){
        std::shared_ptr<Integer> a(new Integer(10));
        std::shared_ptr<Integer> b(a);
        std::shared_ptr<Integer> c = std::move(a);
        a.reset(new Integer(5));
    
        std::cout << a.use_count() << "\n";
        a.~shared_ptr();
        std::cout << b.use_count() << "\n";
        std::cout << c.use_count() << "\n";
    }
    0
    2
    2

    a와 b가 같은 객체를 가리키다가, c를 생성할 때 a를 이동하여 생성했습니다.

    위의 결과로 a는 아무 객체도 가리키지 않게 되어 카운트가 0이 되었고, b와 c가 동일한 객체를 가리키고 있어 카운트가 2인것을 볼 수 있으며, a가 가리키고 있는 대상이 없기 때문에 a의 소멸자를 호출해도 b와 c의 참조 횟수가 변하지 않음을 볼 수 있습니다.

     

    3. Delete, custom deleter

     

    더보기

    std::unique_ptr와 유사하게 std::shared_ptr도 자원 파괴 시 기본적으로 delete를 사용합니다.

    또한, 커스텀 삭제자를 지원한다는 것도 유사합니다.

    하지만, 커스텀 삭제자를 이용하는 방식은 std::unique_ptr와는 다릅니다.

     

    std::unique_ptr는 삭제자의 형식이 스마트 포인터의 일부(템플릿 인수)였지만, std::shared_ptr는 그렇지 않습니다.

    #include <iostream>
    using namespace std;
    
    class Integer {
    public:
        Integer(int val) : data(val) { }
        virtual ~Integer();
    private:
        int data;
    };
    
    int main() {
        auto del_int = [](Integer* p_int) {
            std::cout << "Delete Integer\n";
            delete p_int;
        };
    
        std::shared_ptr<Integer> a(new Integer(10), del_int);
    }
    Delete Integer

    이전 글(std::unique_ptr)의 예제와 사용하는 스마트 포인터만 달라진 예제입니다.

    비교해볼 경우 std::shared_ptr는 포인터 객체의 형식과는 무관함을 볼 수 있습니다.

    이는 std::shared_ptr가 상대적으로 더 유연한 설계를 가능하게 합니다.

    예제를 살펴보도록 하겠습니다.

    #include <iostream>
    #include <memory>
    
    class Integer {
    public:
        Integer(int val) : data(val) { }
    private:
        int data;
    };
    
    int main(){
        auto custom_del_1 = [](Integer* i) {
            ...
            delete i;
        };
    
        auto custom_del_2 = [](Integer* i) {
            ...
            delete i;
        };
    
        std::shared_ptr<Integer> a(new Integer(5), custom_del_1);
        std::shared_ptr<Integer> b(new Integer(10), custom_del_2);
        std::vector<std::shared_ptr<Integer>> vec{ a,b };
    }

    커스텀 삭제자의 형식이 달라도, std::shared_ptr는 같은 형식이기 때문에 아래와 같이 컨테이너를 통한 관리가 가능해집니다.

    또한, 배정 연산 및 다른 함수에 파라미터로 전달하는 것도 std::unique_ptr에 비해 수월하게 진행할 수 있습니다.

     

    4. 제어 블록 (Control block)

     

    더보기

    std::shared_ptr의 크기는 커스텀 삭제자와 무관하게 항상 포인터 두 개의 분량입니다.

    하지만, 함수 객체를 커스텀 삭제자로 사용할 수 있음에도 그 크기가 고정적이라는 것은 앞뒤가 맞지 않습니다.

     

    이게 가능한 이유는, std::shared_ptr가 사용하는 추가적인 메모리가 std::shared_ptr객체의 일부가 아니기 때문입니다.

    std::shared_ptr는 제어 블록 (Control block)이라고 하는 자신이 참조하는 객체가 가지는 추가적인 메모리를 가리킵니다.

    이전 문단에서 언급된 참조 횟수를 가리키는 포인터는 사실 제어 블록을 가리키는 포인터이며, 제어 블록에는 참조 횟수, 약한 횟수(Weak count), 커스텀 삭제자 및 할당자와 같은 기타 자료들이 있습니다.

     

    이러한 제어 블록은 그 객체를 가리키는 std::shared_ptr이 처음 생성될 때 설정됩니다.

    특정 객체를 가리키는 std::shared_ptr를 생성할 때, 이미 그 객체가 가리키는 std::shared_ptr가 존재하는지 알아낼 수는 없지만, 제어 블록의 생성에 관하여는 아래와 같은 특징들을 유추할 수 있습니다.

    • std::make_shared는 항상 제어 블록을 생성합니다. 
      • std::make_shared함수는 공유 포인터가 가리킬 객체를 새로 생성하기 때문입니다.
    • 독점 소유권을 가진 포인터 (std::unique_ptr, std::auto_ptr)로부터 std::shared_ptr를 생성할 때 제어 블록이 생성됩니다.
      • 독점 소유권을 가진 포인터는 제어 블록을 생성하지 않기 때문입니다.
    • Raw pointer로 std::shared_ptr를 생성할 경우 제어 블록이 생성됩니다.
      • 이것은 이미 제어블록이 있는 객체라도 새 제어블록을 생성합니다.

    제어 블록이 여러개라는 것은 참조 횟수가 여러개라는 뜻 입니다.

    이것은 객체가 여러 번 파괴된다는 말과 동일하며, 이것은 곧 미정의 행동을 의미합니다.

    따라서, 다음과 같은 코드는 매우 지양해야 합니다.

    auto p = new Obejct;
    ...
    std::shared_ptr<Object> sp_1(p, del_func);
    ...
    std::shared_ptr<Obejct> sp_2(p, del_func);

    위 코드는 하나의 객체에 대한 두 개의 제어 블록을 생성합니다.

     

    위와 같은 상황 (Raw pointer로 인해 제어 블록이 여러개 생성되는 상황)을 해결하기 위한 간단한 방법은 아래와 같은 방식을 이용하는 것 입니다.

    std::shared_ptr<Object> sp_1(new Object, del_func);
    ...
    std::shared_ptr<Object> sp_2(sp_1);

    std::make_shared함수는 커스텀 삭제자를 사용할 수 없기 때문에, 위와 같은 상황에서는 사용할 수 없습니다.

     

    5. 제어블록과 std::enable_shared_from_this

     

    더보기

    한가지 상황을 가정해보도록 하겠습니다.

    • 특정 클래스를 std::shared_ptr을 통해 관리하고, 그 포인터들의 집합을 std::vector를 통해 관리합니다.

    추가적으로, std::vector에 삽입할 때 아래와 같은 코드를 사용한다고 하겠습니다.

    std::vector<std::shared_ptr<MyClass>> class_manager;
    
    class MyClass {
    public:
        ...
        void process();
        ....
    };
    
    ...
    
    void MyClass::process() {
        ...
        class_manager.emplace_back(this);
    }

    emplace_back함수는 push_back과 유사하지만, 인자로 받은 값을 이용해 클래스를 생성 후 삽입하는 특징을 가지고 있습니다.

    위와 같은 가정을 한 이유는, std::vector에 삽입할 때 this포인터를 사용하는 상황을 만들어 보기 위함이었습니다.

    이전 문단에서 설명했듯이, Raw pointer로 객체를 생성할 경우 기존 제어 블록의 유무와 관계 없이 제어 블록이 생성됩니다. 

    this포인터 또한 Raw pointer이기 때문에, 위 std::shared_ptr객체를 가리키는 포인터가 이미 존재했다면 위와 같은 코드는 제어 블록을 여러 개 생성할 수 있고, 이것은 곧 미정의 행동이 될 수 있습니다.

     

    이와 같은 상황을 방지하기 위해, std::enable_shared_from_this라는 클래스 템플릿을 이용할 수 있습니다.

    이 클래스를 상속하는 클래스는 this포인터로 std::shared_ptr을 생성할 때 위와 같은 상황으로부터 안전합니다.

    예제는 다음과 같습니다.

    class MyClass : public std::enable_shared_from_this<MyClass> {
    public:
        ...
        void process();
        ...
    };

     

    위와 같이파생클래스 자신을 템플릿 인수로 받는 클래스 템플릿을 상속하는 것을 CRTP(Curiously Recurring Template Pattern)이라고 합니다.

    std::enable_shared_from_this를 상속받은 클래스는 아래와 같이 안전하게 std::shared_ptr을 생성할 수 있습니다.

    void MyClass::process() {
        ...
        class_manager.emplace_back(shared_from_this());
    }

    shared_from_this함수는 객체에 대한 제어 블록을 조회하고, 그 제어 블록을 가리키는 std::shared_ptr을 생성합니다. 

    이것은 객체에 이미 제어 블록이 있다고 가정하는 기능이기 때문에 제어 블록이 없는 객체일 경우 함수의 행동이 정의되지 않습니다.

    위와 같이 유효한 객체를 가리키지 않을 때 shared_from_this함수를 선언하는 것을 방지하기 위해 std::enable_shared_from_this를 상속받는 객체는 생성자를 private로 정의하고, 팩토리 함수를 따로 정의합니다.

    class MyClass : public std::enable_shared_from_this<MyClass> {
    public:
        ...
        template<typename... Ts>
        static std::shared_ptr<MyClass> create(Ts&&... params);
        
        void process();
        ...
    private:
        ...
        {Constructors}
    };

    위와 같이 생성자 대신 create함수를 통해 객체를 생성할 경우 shared_from_this함수가 제어 블록이 없는 객체를 가리키는 상황을 방지할 수 있습니다. 

     


     

    std::shared_ptr는 Raw pointer, std::unique_ptr에 비해 무겁게 느껴질 수 있습니다.

    하지만 std::shared_ptr의 기능적인 이점을 생각하면 이것은 합당할 수 있습니다.

    비용 또한 기본 생성자 및 기본 삭제자를 사용하고 std::shared_ptr의 생성에 std::make_shared 함수를 사용할 경우 할당 비용은 크게 줄어들고, 역참조 및 참조 횟수 조작 연산(복사 생성, 소멸 등)의 원자젹 연산 또한 크지 않습니다.

    또한 std::shared_ptr는 사용이 강제되지 않습니다. 사용할 객체가 소유권 독점으로 충분할 가능성이 있을 경우, std::unique_ptr를 사용하다 이후 std::shared_ptr로 변경할 수 있습니다.

     

    std::shared_ptr를 사용할 경우 std::unique_ptr로 변경하거나 할 수는 없지만 공유 객체에 대한 수명 관리가 수월해진다는 큰 이점이 생기므로, 사용을 자제할 필요는 없을 것 입니다.

     

    감사합니다.

     

     

    댓글

Designed by Tistory.