ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • [Effective Modern C++] Smart pointer : std::make_unique<>, std::make_shared<>
    C++/Effective Modern C++ 2022. 5. 25. 12:03

    이전 글에서 스마트 포인터 중 std::unique_ptr, std::shared_ptr, std::weak_ptr에 대하여 살펴보았습니다.

    스마트 포인터를 생성할 때에는 Raw pointer와 유사하게 new연산자를 사용하는 방법이 있고, 표준 라이브러리에 존재하는 std::make_unique, std::make_shared와 같은 함수를 사용하는 방법이 있습니다.

    생성하는 방법은 두 가지지만, 특정 경우를 제외할 경우 new연산자 보다는 make함수를 사용하는 것이 권장됩니다.

     

    이번 글에서는 두 종류의 make함수와 이것이 new와 비교하여 가지는 장점이 무엇인지 살펴보도록 하겠습니다.

     


     

    1. 코드 중복 (Duplicate)

     

    더보기

    make함수들의 장점으로 보일 수 있는 가장 간단한 예시는 아래 예제와 같습니다.

    auto up_1(std::make_unique<MyClass>());
    std::unique_ptr<MyClass> up_2(new MyClass);
    
    auto sp_1(std::make_shared<MyClass>());
    std::shared_tr<MyClass> sp_2(new MyClass);

    new를 사용했을 경우와 비교하여, 객체의 형식을 두번 기술하지 않는다는 차이가 있습니다.

    소스 코드에 중복이 있을 경우 컴파일 시간이 늘어나고, 목적 코드의 규모가 커지는 등의 성능 이슈, 이후 유지 보수 단계에서 일관성이 없어지는 등의 가독성 이슈가 발생할 수 있습니다.

     

    위의 문제가 단순한 소스 코드 중복으로 발생할 수 있다는 점을 고려해볼 때, 중복된 코드를 정리할 수 있는 make함수를 선택할 이유는 충분할 것 입니다. 

     

    2. 예외 안정성

     

    더보기

    make함수는 예외 안정성과도 관련이 있습니다.

    void process_mc(std::shared_ptr<MyClass> sp, int priority);
    ...
    int compute_priority();

    위와 같이 특정 객체를 우선순위에 따라 적절히 처리하는 함수와, 그 우선 순위를 결정하는 함수를 가정해보도록 하겠습니다.

     

    process_mc함수를 new를 사용한 호출은 아래와 같을 것 입니다.

    process_mc(std::shared_ptr<MyClass>(new MyClass), compute_priority());

    이 호출은 MyClass에 대한 누수가 발생할 수 있습니다.

    자원관리를 자동으로 수행하는 std::shared_ptr를 사용함에도 누수가 발생할 가능성이 있는 이유는, 컴파일러가 소스 코드르 목적 코드(Object code)로 변환하는 방식과 관련이 있습니다.

    실행 시점에서 함수가 호출될 때, 코드가 실행되기 전에 인수들이 먼저 평가됩니다.

    위의 process_mc의 경우에는 아래와 같은 일이 일어납니다.

    • 표현식 new MyClass가 평가됩니다. 이 과정에서 MyClass가 힙에 생성됩니다.
    • new가 산출한 포인터를 관리하는 std::shared_ptr<MyClass>의 생성자가 실행됩니다.
    • compute_priority가 실행됩니다.

    중요한 것은, 위 세 가지 일이 순서대로 실행될 필요가 없다는 것 입니다.

    std::shared_ptr의 생성자가 실행될 때 인수가 먼저 평가되어야 하므로 new MyClass가 생성자 호출보다 먼저 평가되는 것은 확실합니다. 하지만 compute_priority의 경우 어느 시점에서 실행될 지 알 수 없습니다.

    예를 들어, 아래와 같은 순서를 생각해보도록 하겠습니다.

    1. new MyClass가 실행됩니다.
    2. compute_priority가 실행됩니다.
    3. std::shared_ptr의 생성자가 실행됩니다.

    이러한 순서의 코드 실행 시, compute_priority에서 예외가 발생한다면, new MyClass객체에 누수가 발생하게 됩니다.

    예외가 발생한 시점에서 new MyClass는 std::shared_ptr에 의해 관리되지 않는 Raw pointer이기 때문입니다.

     

    위의 new연산자를 사용한 함수 호출을 std::make_shared를 사용할 경우 위와 같은 문제가 발생하지 않습니다.

    new대신 사용되는 std::make_shared는 std::shared_ptr를 반환하여, MyClass가 생성되는 시점에 이미 std::shared_ptr의 관리를 받기 때문입니다.

     

    std::unique_ptr와 std::make_unique에도 같은 추론이 적용됩니다.

     

    3. 명령어 최적화

     

    더보기

    std::make_shared는 new에 비해 더 효율적입니다.

    아래와 같은 할당 코드를 살펴보도록 하겠습니다.

    std::shared_ptr<MyClass> sp(new MyClass);
    
    auto sp = std::make_shraed<MyClass>();

    위 두 할당 코드에서 메모리 할당이 각각 한 번씩 일어날 것이라 추측할 수 있습니다.

    하지만, new를 사용한 메모리 할당은 두 번 일어납니다.

    MyClass객체에 대한 메모리 할당과, 그에 대한 제어 블록의 할당으로 두 번입니다.

    이에 비해, std::make_shared는 MyClass객체와 제어 블록을 모두 담을 수 있는 크기의 메모리 블록을 한 번에 할당합니다.

     

    이 차이는 메모리 호출 코드의 사용 횟수와 연관되어 프로그램의 정적인 크기에 차이가 존재하며, 실행 시점에서 메모리 할당의 횟수와 연관되어 프로그램의 실행 속도에 차이가 존재합니다.

    이에 더해, std::make_shared는 제어 블록에 일정 정도의 내부 관리용 정보를 포함할 필요가 없기 때문에 프로그램의 전체적인 메모리 사용량도 줄어들 수 있습니다.

     

    4. 예외 : 커스텀 삭제자

     

    더보기

    위 문단의 예시들을 통해, new보다는 make함수를 사용하는 것이 좋은 이유를 살펴보았습니다.

    하지만 make함수를 사용할 수 없거나, 사용하지 않아야 하는 상황이 존재합니다.

     

    make함수는 커스텀 삭제자를 지정할 수 없습니다. 

    auto custom_deleter = [](MyClass* pw) { ... };
    ...
    std::unique_ptr<MyClass, decltype(custom_deleter)> up(new MyClass, custom_deleter);
    std::shared_ptr<MyClass> sp(new MyClass, custom_deleter);

    위와 같이 new를 사용한 스마트 포인터의 생성은 커스텀 삭제자를 등록할 수 있지만, make함수를 사용한 생성에는 커스텀 삭제자를 등록할 수 없습니다.

     

    이 내용을 위 문단의 new를 사용했을 때의 예외 안정성과 함께 생각해보도록 하겠습니다.

    아래와 같은 호출은 자원 누수의 위험이 있습니다.

    auto custom_deleter = [](MyClass* pw) { ... };
    ...
    process_mc(
        std::shared_ptr<MyClass>(new MyClass, custom_deleter), 
        compute_priority()
    );

    위 코드를 안전하게 바꾸려면 std::shared_ptr의 생성과, process_mc의 호출을 분리하는 것 입니다.

    std::shared_ptr<MyClass> sp(new MyClass, custom_deleter);
    ...
    process_mc(
        std::move(sp), 
        compute_priority()
    );

    호출을 분리하는 것에 더해, l-value에 의한 복사 생성자 호출을 막기 위해 이동 함수를 추가하여 최적화 한 모습입니다.

     

    5. 예외 : std::initializer_list

     

    더보기

    make함수들은 자신의 매개변수를 객체의 생성자에 완벽하게 전달합니다.

    하지만 이 과정에서 중괄호가 아닌 괄호를 사용하기 때문에, std::initializer_list를 활용한 생성자를 사용할 수 없습니다.

    auto upv = std::make_unique<std::vector<int>>(10, 20);
    auto spv = std::make_shared<std::vector<int>>(10, 20);

    위와 같은 선언에서 두 선언 모두 값이 20이고, 갯수 10개의 std::vector를 생성합니다.

    이것은, 중괄호 초기치를 사용하려면 make함수가 아닌 new연산자를 사용해야 하는 것을 의미합니다.

     

    std::initializer_list를 사용하고자 할 경우에는, 아래와 같은 방식으로 우회할 수 있습니다.

    auto init_list = { 10, 20 };
    ...
    auto spv = std::make_shared<std::vector<int>>(init_list);

    중괄호 초기치를 통해 std::initializer_list를 생성하고, 그 객체를 make함수에 전달하는 방식입니다.

     

    6. 예외 : 메모리 해제

     

    더보기

    new에 대한 make함수의 크기, 속도상의 장점은 std::shared_ptr의 제어 블록이 관리 대상 객체와 동일한 메모리 조각에 놓인다는 점에 기인합니다.

    하지만 객체의 소멸 시점에 대해 고려해볼 경우, 조금 부적합 한 예시를 떠올릴 수 있습니다.

    객체가 소멸하지만, 객체의 제어 블록이 소멸하지 않은 경우를 생각해보도록 하겠습니다.

    이 경우는 std::shared_ptr의 참조 횟수가 0이고, std::weak_ptr의 참조 횟수가 0이 아닐 경우가 해당합니다.

    std::waek_ptr는 제어 블록 내부에 추가적인 참조 횟수(Weak count)가 존재합니다.

    이 Weak count가 0이 아닌 한, 객체는 만료 상태로 존재해야 합니다.

     

    만약 객체의 형식이 매우 크고, std::shared_ptr의 파괴 시점과 std::weak_ptr의 파괴 시점 사이의 시간 간격이 길 경우에, 객체에 할당된 메모리가 해제되기 까지의 시간 지연이 생길 수 있습니다.

    auto big_obj = std::make_shared<BigType>();
    ...
    // Using std::shared_ptr, std::weak_ptr
    ...
    // Remove std::shared_ptr
    ...
    // Memory still allocated
    ...
    // Remove std::weak_ptr
    // Memory deallocated

    위 예제와 같이, make함수를 사용했을 경우 객체를 참조하는 std::shared_ptr과 std::weak_ptr 모두 해제되었을 경우에 메모리가 해제됩니다.

    std::shared_ptr<BigType> big_obj(new BigType);
    ...
    // Using std::shared_ptr, std::weak_ptr
    ...
    // Remove std::shared_ptr
    // Deallocated Object memory
    ...
    // Memory still allocated only control block
    ...
    // Remove std::weak_ptr
    // Deallocated Control block memory

    하지만 new를 사용했을 경우에는 객체의 메모리와 제어 블록의 메모리가 따로 해제됩니다.

     


     

    make함수들이 new연산자에 대해 가질 수 있는 장점들과, 그럼에도 new연산자를 사용해야 하는 상황에 대한 예제를 몇 가지 살펴보았습니다.

    주어진 예제들과 같이 특정한 경우, 그리고 예제와 유사하게 new를 사용하는 것이 정답인 상황에서는 new를 사용하는 것이 좋습니다.

    하지만, 그렇게 고려해야 할 요소가 없을 경우에는 성능 및 안정성에 대한 이유로 make함수를 사용하는 것이 좋겠습니다.

     

    감사합니다.

    댓글

Designed by Tistory.