본문 바로가기
PROGRAMMING/Effective Modern C++

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

by La-KanTo 2021. 3. 11.

- std::shared_ptr처럼 작동하되 대상을 잃을 수도 있는 포인터가 필요하면 std::weak_ptr를 사용하라.
대체로 std::weak_ptr는 std::shared_ptr를 이용해서 생성한다. std::weak_ptr는 자신을 생성하는 데 쓰인 std::shared_ptr가 가리키는 것과 동일한 객체를 가리키나, 그 객체의 참조 카운트에는 영향을 주지 않는다.

대상을 잃은 std::weak_ptr를 가리켜 만료되었다(expired)라고 말한다. std::weak_ptr의 만료 여부는 멤버 함수 expired가 돌려주는 값으로 판단할 수 있다.

만료되지 않은 std::weak_ptr이라고 해도 피지칭 객체에 직접 접근하는 것은 불가능하다. std::weak_ptr에는 역참조 연산이 없기 때문이다.

역참조 연산이 가능하도록 하더라도, 만료 점검과 참조를 분리하면 경쟁 조건이 발생할 수 있다. 즉, expired 호출과 역참조 사이에서 다른 어떤 스레드가 해당 객체를 가리키는 마지막 std::shared_ptr를 재대입 또는 파괴할 수도 있기 때문이다. 그러면 해당 객체가 파괴되며, 포인터를 역참조하면 미정의 행동이 나온다.

제대로 된 용법은, std::weak_ptr로부터 std::shared_ptr를 생성하여 사용하는 것이다.

 

std::weak_ptr는 std::shared_ptr로부터 얻을 수 있다.

// spw가 생성된 후, 피지칭 Widget의 참조 카운트(이하 간단히 카운트)는 1이다
// (std::make_shared에 관해서는 항목 21을 보라).
auto spw = std::make_shared<Widget>();   
// wpw는 spw와 같은 Widget을 가리킨다; 카운트는 여전히 1이다.
std::weak_ptr<Widget> wpw(spw);
// 카운트가 0이 되고 Widget이 파괴된다; 이제 wpw는 대상을 잃은 상태이다.
spw = nullptr;   

std::weak_ptr가 만료되었는지 알고 싶으면, expired 멤버 함수가 돌려주는 값을 점검하면 된다.

if (wpw.expired())

std::weak_ptr가 가리키는 피지칭 객체를 역참조 하려면 std::weak_ptr로부터 std::shared_ptr를 얻고 그 std::shared_ptr을 역참조한다.


방법 1: lock 멤버 함수를 사용한다.

std::shared_ptr<Widget> spw1 = wpw.lock(); // wpw가 만료이면 spw1은 널
auto spw2 = wpw.lock(); // 위와 동일하나 auto를 사용했음

방법 2: std::shared_ptr의 생성자에 std::weak_ptr를 넘겨준다.

std::shared_ptr<Widget> spw3(wpw); // wpw가 만료이면 std::bad_weak_ptr(예외)가 발생

효율성 면에서 std::weak_ptr는 std::shared_ptr와 본질적으로 동일하다.

std::weak_ptr 객체는 그 크기가 std::shared_ptr 객체와 같으며, std::shared_ptr가 사용하는 것과 같은 제어 블록(항목 19 참고)을 사용하며, 생성이나 파괴, 대입 같은 연산에 원자적 참조 카운트 조작이 관여한다.

앞에서 std::weak_ptr가 참조 카운트 관리에 관여하지 않는다고 말한 것과 모순인 것처럼 보일 것이다.사실 앞에서 조금 모호하게 말했는데 정확하게 말하자면 이렇다. std::weak_ptr는 객체의 소유권 공유에 참여하지 않으며, 따라서 피지칭 객체의 참조 카운트에 영향을 미치지 않는다.

앞에서 언급했듯이 제어 블록에는 '두 번째' 참조 카운트가 있으며 그것이 std::weak_ptr가 조작하는 참조 카운트이다.

 

- std::weak_ptr의 잠재적인 용도로는 캐싱, 관찰자 목록, 그리고 std::shared_ptr 순환 고리 방지가 있다.

어떤 팩터리 함수가, 주어진 고유 ID에 해당하는 읽기 전용 객체를 가리키는 스마트 포인터를 돌려준다고 하자.
팩터리 함수의 반환 타입에 관한 항목 18의 조언에 따라, 그 팩터리 함수는 std::unique_ptr를 돌려준다.

std::unique_ptr<const Widget> loadWidget(WidgetID id);

이 loadWidget의 비용이 크다고 하자.

그리고 ID들이 되풀이해서 쓰이는 경우가 많다고 하자.

 

그렇다면 loadWidget과 같은 일을 하되 호출 결과들을 캐싱하는 함수를 작성하여 최적화를 할 수 있을 것이다.
그런데 요청된 모든 Widget을 캐시에 담아 둔다면 그 자체로 성능상의 문제가 발생할 것이므로, 더 이상 쓰이지 않는 Widget은 캐시에서 삭제하는 것이 자연스럽다.

이러한 캐시 적용 팩터리 함수의 반환 타입을 std::unique_ptr로 두는 것은 그리 바람직하지 않다.

호출자가 캐싱된 객체를 가리키는 스마트 포인터를 받아야 한다는 점은 확실하며, 그 객체들의 수명을 호출자가 결정할 수 있어야 한다는 점도 확실하다.
캐시에 있는 포인터들은 자신이 대상을 잃었음을 검출할 수 있어야 한다.

따라서 캐시에 저장할 포인터는 자신이 대상을 잃었음을 감지할 수 있는 포인터, 즉 std::weak_ptr이어야 한다.
이는 팩터리 함수의 반환 타입이 반드시 std::shared_ptr이어야 함을 뜻한다.

 

다음은 loadWidget의 캐싱 버전이다.

std::shared_ptr<const Widget> fastLoadWidget(WidgetID id)
{
    static std::unordered_map<WidgetID, std::weak_ptr<const Widget> > cache;

    // objPtr는 캐시에 있는 객체를 가리키는 std::shared_ptr
    // (단, 객체가 캐시에 없으면 널)
    auto objPtr = cache[id].lock();        
    
    // 캐시에 없으면 적재하고 캐시에 저장
    if (!objPtr) {                        
        objPtr = loadWidget(id);        
        cache[id] = objPtr;             
    }

    return objPtr;
}

이 코드가 제대로 작동하려면 WidgetID를 해싱하는 함수와 상등을 비교하는 함수도 지정해야 하지만, 그 부분은 생략했다. 이 fastLoadWidget 구현은 더 이상 쓰이지 않는(따라서 파괴된) Widget들에 해당하는 만료된 std::weak_ptr들이 캐시에 누적될 수 있다는 사실을 무시한다.

그 부분을 좀 더 개선할 수도 있지만, 지금의 std::weak_ptr 논의 자체에 도움이 되는 것은 아니므로 이 정도로 마무리한다.

 

std::weak_ptr가 유용하게 쓰이는 두 번째 사례는 관찰자(Observer) 설계 패턴이다.
이 패턴의 주된 구성 요소는 관찰 대상(subject; 상태가 변할 수 있는 객체)과 관찰자(observer; 상태 변화를 통지받는 객체)이다.
대부분의 관찰자 패턴 구현에서, 각 관찰 대상 객체에는 자신의 관찰자들을 가리키는 포인터들을 담은 데이터 멤버가 있다.
이런 데이터 멤버가 있으면 상태 변화를 관찰자들에게 손쉽게 통지할 수 있다.
관찰 대상은 관찰자들의 수명을 제어하는 데에는 관심이 없지만, 자신이 파괴된 관찰자에 접근하는 일이 없도록 보장하는 데에는 관심이 아주 많다.

그러한 관찰 대상에 합당한 설계 하나는, 관찰자들을 가리키는 std::weak_ptr들의 컨테이너를 데이터 멤버로 두는 것이다.

그러면 먼저 만료 여부를 보고 관찰자가 유효한지 점검한 후에 관찰자에 접근할 수 있다.

std::weak_ptr가 유용한 마지막 예로, 객체 A, B, C로 이루어진 자료구조에서 A와 C가 B의 소유권을 공유하는, 그래서 B를 가리키는 std::shared_ptr를 가지고 있는 상황을 생각해 보자.

 

   std::shared_ptr       std::shared_ptr
[ A ]----------------------→[ B ]←----------------------[ C ]

그런데 B에서 다시 A를 가리키는 포인터가 필요하게 되었다고 하자.
그 포인터는 어떤 종류의 포인터이어야 할까?

    std::shared_ptr       std::shared_ptr
[ A ]----------------------→[ B ]←----------------------[ C ]
 ↖__________________________/
      ???

 

선택은 세 가지이다.

1. 생 포인터.
이 접근방식에는 만일 C가 여전히 B를 가리키고 있는 상황에서 A가 파괴되면, B가 가진 포인터(A를 가리키는)는 대상을
잃게 되나, B는 그 사실을 알지 못한다.
따라서 B가 대상을 잃은 포인터를 의도치 않게 역참조해서 미정의 행동이 발생할 수 있다.

2. std::shared_ptr.
이 설계에서 A와 B는 서로를 가리키는 std::shared_ptr들을 가진다.
그러면 std::shared_ptr들의 순환 고리가 생기며, 결과적으로 A와 B 둘 다 파괴되지 못한다.
프로그램의 다른 자료구조에서 A와 B에 접근할 수 없게 된다고 해도, 둘의 참조 카운트는 여전히 1이다.
그런 일이 생기면, A와 B는 사실상 누수가 일어난 것이라 할 수 있다.
프로그램이 둘에 접근할 수 없으므로, 해당 자원들을 재확보할 수도 없다.

 

3. std::weak_ptr.
이 경우에는 앞의 두 문제 모두 해결된다. A가 파괴되면 A를 가리키는 B의 포인터가 대상을 잃지만, B는 그 사실을 알 수
있다.
비록 A와 B가 서로를 가리키지만, B의 포인터는 A의 참조 카운트에 영향을 미치지 않으며, 따라서 std::shared_ptr들이 더 이상 A를 가리키지 않게 되면 A가 정상적으로 파괴된다.
하지만 이런 상황은 흔히 있는 일은 아니다.

댓글