ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • [Effective Modern C++] Smart pointer : std::weak_ptr<>
    C++/Effective Modern C++ 2022. 5. 19. 18:07

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

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

     

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

     


     

    1. std::weak_ptr<>

     

    더보기

    때때로, std::shared_ptr의 소유권 공유 기능을 가지되, 참조 횟수에는 영향을 미치지 않는 포인터가 필요할 수 있습니다.

    std::weak_ptr는 그러한 조건을 만족하는 포인터입니다.

    #include <iostream>
    #include <memory>
    
    int main(){
        auto sp = std::make_shared<int>();
        ...
        std::weak_ptr<int> wp(sp);
        ...
        sp = nullptr;
        
        if(wp.expired()) {
            std::cout << "Weak ptr is null\n";
        }
    }
    Weak ptr is null

    위 예제처럼 std::weak_ptr는 참조 횟수에 영향을 미치지 않는 스마트 포인터 입니다.

    expired함수를 통해 weak_ptr가 만료되었는지(대상을 잃었는지) 알 수 있습니다.

    하지만 expired함수를 통해 검사 후 객체에 접근하는 방식은 불가합니다. std::weak_ptr는 역참조 연산을 허용하지 않습니다.

     

    역참조 연산을 허용하지 않기 때문에 대상 객체를 참조하기 위해서는 std::weak_ptr로부터 std::shared_ptr를 획득해야 합니다.

    이 방법에는 두 가지가 있습니다.

    #include <iostream>
    #include <memory>
    
    int main(){
        auto sp = std::make_shared<int>(5);
        std::weak_ptr<int> wp(sp);
    
        ...
        if (wp.expired()) {
            auto sp_2 = wp.lock();
        }
        ...
        std::shared_ptr<int> sp_3(wp);
    }

    첫 번째 방법은 lock함수를 통해 std::shared_ptr객체를 얻을 수 있습니다.

    만약 lock함수를 호출한 std::weak_ptr가 만료되어 있을 경우, 반환된 std::shared_ptr는 nullptr입니다.

     

    두 번째 방법은 std::weak_ptr객체로 std::shared_ptr를 생성하는 방법 입니다.

    이 방법은 std::weak_ptr가 만료되어 있을 경우, bad_weak_ptr 예외가 발생합니다.

     

    2. 에시 : 캐싱 (Caching)

     

    더보기

    특정 팩토리 함수가 주어진 고유 ID에 해당하는 읽기 전용 객체를 가리키는 스마트 포인터를 반환하는 상황을 가정해보겠습니다. 이는 조금 더 구체적으로, DB에 접근하여 데이터를 읽어 오는 함수가 될 수도 있습니다.

    이와 같은 함수의 서명은 아래와 같은 형태로 구성할 수 있습니다.

    std::unique_ptr<const Data> load_data(DataID id);

    추가적으로, 위 함수의 비용이 크다고 가정해보겠습니다.

    이 경우 자연스럽게 해당 결과를 캐싱하여 최적화 하는 방안을 떠올릴 수 있습니다.

    추가적으로, 캐싱된 데이터가 캐시에 그대로 남아있을 경우 성능상 문제가 추가적으로 발생할 수 있으니, 사용되지 않는 데이터는 캐시에서 삭제하는 것을 떠올릴 수 있습니다.

     

    이 최적화에는 std::weak_ptr를 사용해야 하는데, 그 이유는 다음과 같습니다.

    • 객체들의 수명을 호출자가 결정할 수 있어야 합니다.
    • 캐시에 있는 포인터는 자신이 대상을 잃었음을 검출해 낼 수 있어야 합니다.
    • 팩토리 함수가 반환한 객체를 클라이언트가 다 사용했을 경우, 그 객체는 파괴되어야 합니다.

    추가적으로, 이 경우 기존 팩토리 함수의 std::unique_ptr또한 std::shared_ptr가 되어야 합니다.

     

    위 내용들을 종합하여 추가적으로 만든 load_data의 캐싱 버전은 다음과 같이 구성할 수 있습니다.

    std::shared_ptr<const Data> fast_load_data(DataID id) {
        static std::unordered_map<DataID, std::weak_ptr<Data>> cache;
        
        auto obj_ptr = cache[id].lock();
        
        if(!obj_ptr) {
            obj_ptr = load_data(id);
            cache[id] = obj_ptr;
        }
        return obj_ptr;
    }

     

    3. 예시 : 관찰자 패턴 (Observer pattern)

     

    더보기

    관찰자 패턴이란 한 객체를 관찰하는 관찰자 (Observer)가 존재하여, 대상 객체의 변화에 반응하는 디자인 패턴입니다.

    이 패턴에서, 일반적으로 관찰 대상이 되는 객체 (Subject)들은 자신의 관찰자들을 가리키는 포인터를 멤버로 가지고 있습니다. 자신에게 상태 변화가 있을 경우 관찰자들에게 알리는 방식입니다.

     

    관찰 대상의 기준에서, 자신이 가리키고 있는 관찰자의 수명은 주요한 관심거리가 아니지만, 자신이 가리키고 있는 관찰자가 소멸했는지의 여부는 중요합니다.

    즉, 자신이 가리키고 있는 관찰자가 파괴되었는지 확인할 수 있어야 합니다.

    이 때 객체의 만료 여부를 확인할 수 있는 std::weak_ptr를 사용할 수 있습니다.

     

    관찰자 패턴에 관해서는 다른 글에서 더 자세하게 다룰 수 있도록 하겠습니다.

     

    4. 예시 : 순환 참조 (Circular dependancy)

     

    더보기

    아래와 같이, 3개의 객체 A, B, C로 이루어진 자료구조가 있다고 가정해보겠습니다.

    A std::shared_ptr -> B <- std::shared_ptr C

    세 객체 A, B, C는 위 그림과 같이 std::shared_ptr를 통해 A와 C가 B에 대한 소유권을 공유하고 있습니다.

    여기서 다음 그림과 같이 .B에서 A를 가리키는 포인터가 필요하게 되었다고 가정해보겠습니다.

    A std::shared_ptr -> B <- std::shared_ptr C
    <- (pointer)

    위 포인터에 올 수 있는 포인터는 세 종류 입니다.

    • Raw pointer
      • C가 B를 가리키고 있는 상황에서 A가 파괴되었을 경우, B가 가진 Raw pointer는 대상을 잃게 되지만 B는 그 사실을 검출할 수 없습니다. 이 때 포인터를 참조할 경우, 미정의 행동이 발생합니다.
    • std::shared_ptr
      • A와 B가 서로 순환 참조를 하게 됩니다. 이 경우 A와 B는 둘 다 파괴되지 못하는 상황이 발생합니다. 이 때 C가 파괴되어도 A와 B의 참조 횟수는 1이고, A와 B에는 더 이상 접근할 수 없으므로 누수가 발생합니다.
    • std::weak_ptr
      • A가 파괴될 경우 B가 그 사실을 확인할 수 있고, B가 std::weak_ptr를 사용하기 때문에 순환 참조가 발생하지 않습니다.

    std::weak_ptr는 Raw pointer의 미정의 행동, std::shared_ptr의 자원 누수가 발생하지 않습니다.

    따라서 위와 같은 상황에서는 std::weak_ptr를 사용하는 것이 제일 바람직하다고 볼 수 있습니다.

     


     

    본문 1문단에서 std::weak_ptr를 소개할 때, 참조 횟수에 영향을 미치지 않는 포인터라는 언급이 있었습니다.

    조금 더 정확한 표현으로는 소유권 공유에 참여하지 않으며, 대상 객체의 참조 횟수에 영향을 미치지 않는다고 할 수 있겠습니다.

    std::weak_ptr는 std::shared_ptr와 마찬가지로 제어 블록을 사용하며, 이 제어 블록에서 std::weak_ptr의 참조 횟수를 카운팅 하기 때문입니다.

     

    효율성 측면에서 볼 경우, std::shared_ptr와 std::weak_ptr는 본질적으로 동일합니다.

    크기도 같고, 제어 블록의 사용 여부도 동일하고, 생성, 파괴, 배정 연산에서 원자적인 참조 횟수 조작을 하기 때문입니다.

    따라서, 성능보다는 위의 예제와 같이 사용해야 하는 상황을 구분할 수 있는 것이 좋겠습니다.

     

    감사합니다.

    댓글

Designed by Tistory.