Item 20은 '대상을 잃을 수 있는 스마트 포인터에는 weak_ptr을 사용해라'이다.
---
shared_ptr처럼 작동하면서도 가리키는 자원의 공유 소유권에는 참여하지 않는 스마트포인터가 있으면 편리할 때가 있다.
다시 말해, 객체의 참조 횟수(reference count)에는 영향을 주지 않으면서, shared_ptr와 같은 포인터가 있다.
이런 종류의 스마트 포인터는 shared_ptr가 겪지 않는 문제, 즉 가리키는 대상이 이미 파괴되었을 가능성에 대처해야만 한다.
자신이 가리키는 대상을 잃었을 때(dangling), 즉, 가리키기로 되어 있는 객체가 더 이상 존재하지 않을 때를 추적함으로써 이 문제를 해결할 것이며, 그것이 정확히 weak_ptr이 하는 일이다.
weak_ptr가 어떻게 유용할 수 있는지에 대해 API를 좀 더 살펴보자. 이것들은 똑똑해 보이지 않을 수 있다.
weak_ptr는 역참조 할 수 없고, null인지도 테스트 할 수 없다. 이는 weak_ptr가 독립적인 스마트 포인터가 아니기 때문이다. 이것은 shared_ptr의 보완물이다.
weak_ptr는 일반적으로 shared_ptr로부터 생성된다. 이들은 자신을 초기화 하는 sharedm_ptr과 같은 곳을 가리키지만 가리키는 객체의 참조 횟수에는 영향을 주지 않는다.
auto spw =
std::make_shared<Widget>(); // spw가 생성된 후, 가리켜지는 Widget의
// 참조 횟수(RC)는 1입니다. (std::make_shared에
// 대한 정보는 Item 21 참조)
…
std::weak_ptr<Widget> wpw(spw); // wpw는 spw와 동일한 Widget을 가리킵니다.
// RC는 1로 유지됩니다.
…
spw = nullptr; // RC가 0이 되고, Widget은 파괴됩니다.
// wpw는 이제 대상을 잃습니다(dangles).
대상을 잃은 weak_ptr은 '만료되었다(expired)'고 말한다. 이를 직접 테스트 하려면 아래처럼 사용한다.
if (wpw.expired()) … // 만약 wpw가 객체를 가리키고 있지 않다면…
weak_ptr가 만료되었는지 확인하고, 만료되지 않았다면(즉, 대상을 잃지 않았다면) 가리키는 객체에 접근하는 것이 이상적이다. 하지만 이것은 쉽지 않다. weak_ptr에는 역참조 연산이 없기 때문에 코드를 작성할 방법이 없기 때문이다.
만약 방법이 있다 하더라도, 확인 절차와 역참조 절차를 분리하는 것은 '경합 조건(race conditioin)'을 유발할 수 있다. expired 호출과 역참조 동작 사이에 다른 스레드가 객체를 가리키는 마지막 shared_ptr를 재할당하거나 파괴하여 객체가 파괴될 수도 있기 때문이다. 이런 경우, 역참조는 정의되지 않은 동작을 초래할 것이다.
필요한 것은, weak_ptr가 만료되었는지 확인하고, 만료되지 않았다면 가리키는 객체에 접근할 수 있게 해주는 '원자적 연산(atomic operation)'이다.
이는 weak_ptr로부터 shared_ptr를 생성함으로써 수행된다. 이 연산은 shared_ptr를 생성하려고 할 때, weak_ptr가 이미 만료되었을 경우, 어떤 일이 일어나기를 원하는지에 따라 두 가지 형태로 제공된다.
1. shared_ptr를 반환하는 'weak_ptr::lock'
이 경우에서 만약 weak_ptr가 만료되었다면 반환된 shared_ptr는 null이다.
std::shared_ptr<Widget> spw1 = wpw.lock(); // wpw가 만료되었다면 spw1은 null임
auto spw2 = wpw.lock(); // 위와 동일하지만 auto를 사용함
2. weak_ptr를 인자로 받는 shared_ptr 생성자
이 경우는 만약 weak_ptr가 만료되었다면 예외가 발생한다.
std::shared_ptr<Widget> spw3(wpw); // wpw가 만료되었다면 std::bad_weak_ptr를 던짐
하지만 여전히 weak_ptr가 어떻게 유용한지에 대해 잘 감이 안올지 모른다.
첫 번째 유스케이스로, 고유 ID를 기반으로 읽기 전용 객체에 대한 스마트 포인터를 생성하는 팩토리 함수를 생각해 보자.
팩토리 함수의 반환 타입에 관한 Item 18의 조언에 따라, 이 함수는 unique_ptr를 반환한다.
std::unique_ptr<const Widget> loadWidget(WidgetID id);
만약 loadWidget 메서드가 비용이 큰 호출이고(예를 들면, 파일이나 DB I/O를 수행한다던지), 특정 ID가 반복적으로 사용되는 것이 흔하다면, loadWidget 메서드가 하는 결과를 캐시(Cache) 하는 함수를 작성하는 것이 합리적인 최적화일 것이다.
하지만 요청된 모든 Widget으로 캐시를 가득 채우는 것은 그 자체로 성능 문제를 일으킬 수 있으므로, 더 이상 사용되지 않는 캐시 된 Widget들을 파괴하는 기능을 추가하는 것이 또 다른 합리적인 최적화가 될 것이다.
이 캐싱 팩토리 함수의 경우, unique_ptr 반환 타입은 적합하지 않다. 호출자는 확실히 캐시된 객체에 대해 스마트 포인터를 받아야 하고, 호출자가 그 객체들의 수명을 결정해야 하지만, 캐시 또한 그 객체들에 대한 포인터를 가질 필요가 있다.
캐시의 포인터들은 대상을 잃었을 때(dangle) 이를 감지할 수 있어야 한다. 팩토리 클라이언트들이 팩토리에서 반환된 객체의 사용을 마쳤을 때, 그 객체는 파괴될 것이고, 그에 대응하는 캐시 항목은 대상을 잃게 될 것이기 때문이다. 따라서 캐시된 포인터들은 weak_ptr이어야 한다.
즉, 대상을 잃었을 때 이를 감지할 수 있는 포인터여야 한다는 의미이다. 이는 팩토리의 반환 타입이 shared_ptr여야 함을 의미한다. weak_ptr는 객체의 수명이 shared_ptr에 의해 관리될 대만 대상을 잃었음을 감지할 수 있기 때문이다.
다음은 캐싱 버전의 loadWidget을 간단하고 빠르게 구현한 코드이다.
std::shared_ptr<const Widget> fastLoadWidget(WidgetID id)
{
static std::unordered_map<WidgetID,
std::weak_ptr<const Widget>> cache;
auto objPtr = cache[id].lock(); // objPtr은 캐시된 객체에 대한 std::shared_ptr
// (객체가 캐시에 없으면 null)
if (!objPtr) { // 캐시에 없다면,
objPtr = loadWidget(id); // 로드하고
cache[id] = objPtr; // 캐시함
}
return objPtr;
}
-note : 객체를 살려두진 않지만 살아있다면 재사용하고 싶을 때 weak_ptr을 사용하면 좋다.
이 구현은 C++11의 해시 테이블 컨테이너 중 하나인 unordered_map을 사용하지만, 함께 존재해야 할 WidgetID 해싱 및 등치 비교 함수는 보여주지 않는다.
fastLoadWidget 메서드의 구현은 캐시에 더 이상 사용되지 않아 파괴된 Widget들에 대응하는, 만료된 weak_ptr들이 축적될 수 있다는 사실을 간과하고 있다. 이 구현은 더 정교해질 수 있지만, weak_ptr에 대한 추가적인 통찰을 주지 않는 문제에 시간을 쓰기보다는 두 번째 유스케이스인 관찰자(Observer) 디자인 패턴을 고려해 보자.
이 패턴의 주요 구성 요소는 '주체(Subject, 상태가 변경될 수 있는 객체)'와 '관찰자(Observer, 상태 변경이 발생했을 때 알림을 받는 객체)'이다. 대부분의 구현에서 각 주체는 관찰자들에 대한 포인터를 담는 데이터 멤버를 포함한다. 이는 주체가 상태 변경 알림을 발행하기 쉽게 만들며, 주체는 관찰자들의 수명(즉, 언제 파괴되는지)을 제어하는 데 관심이 없지만, 관찰자가 파괴되었을 경우 주체가 나중에 그에 접근하려 하지 않도록 확실히 하는데 큰 관심이 있다. 합리적인 설게는 각 주체가 관찰자들에 대한 weak_ptr 컨테이너를 보유하는 것이며, 이를 통해 주체는 포인터를 사용하기 전에 그것이 대상을 잃었는지 여부를 결정할 수 있게 된다.
- note : 알림을 보내야 하지만 관찰자의 생사 여부를 확신할 수 없을 때에 안전장치가 된다.
- note : 주체(Subject)가 weak_ptr 리스트를 돌면서 lock()을 시도하고, 성공한 객체에게만 알림을 보낸다 생각하면 된다.
weak_ptr 유용성의 마지막 예로, 객체 A, B, C가 있는 데이터 구조를 생각해 보자.
여기서 A와 C는 B의 소유권을 공유하므로, B에 대한 shared_ptr를 보유한다.

B에서 다시 A로 가는 포인터가 있으면 유용하다고 가정할 때, 이것은 어떤 종류의 포인터가 되어야 하는가?

여기에는 세 가지 선택지가 있다.
1. 생 포인터
- 이 방식에서 A가 파괴되었는데 C가 계속해서 B를 가리키고 있다면, B는 A에 대한 대상을 잃은 포인터를 포함하게 된다. B는 그것을 감지할 수 없으므로, 무심코 대상을 잃은 포인터를 역참조 할 수 있으며, 이는 정의되지 않은 동작을 초래한다.
2. shared_ptr
- 이 설계에서 A와 B는 서로에 대한 shared_ptr를 포함한다. 그 결과로 발생하는 'shared_ptr순환(cycle)'은 A와 B가 파괴되는 것을 막는다. A와 B가 프로그램의 다른 데이터 구조에서 접근 불가능하더라도(예를 들어, C가 더 이상 B를 가리키지 않더라도), 각각은 참조 횟수 1을 유지할 것이다. 만약 그런 일이 발생한다면 실질적으로 A와 B는 '누수(leak)'된 것이다. 프로그램이 그것들에 접근하는 것은 불가능하지만, 그 자원들은 결코 회수되지 않을 것이기 때문이다.
3. weak_ptr
- 이것은 위의 두 문제를 모두 피한다. 만약 A가 파괴되면 B의 되돌아가는 포인터는 대상을 잃게 되지만, B는 그것을 감지할 수 있을 것이다. 게다가 A와 B가 서로를 가리키더라도, B의 포인터는 A의 참조 횟수에 영향을 주지 않으므로, 더 이상 shared_ptr들이 A를 가리키지 않을 때 A가 파괴되는 것을 막지 않는다.
-note : shared_ptr끼리 서로 가리켜서 발생하는 메모리 누수는 weak_ptr로 교체해 해결해야 한다.
이러한 선택지 중에선 weak_ptr를 사용하는 것이 명백히 가장 좋다. 하지만 shared_ptr의 잠재적인 순환을 끊기 위해 weak_ptr를 사용해야 할 필요가 그리 흔한 일은 아니라는 점은 주목할만하다.
트리(tree)와 같이 엄격하게 계층적인 데이터 구조에서 자식 노드는 일반적으로 부모에 의해서만 소유된다. 부모 노드가 파괴될 때 그 자식 노드들도 함께 파괴되어야 한다. 따라서 부모에서 자식으로의 연결은 일반적으로 unique_ptr로 표현하는 것이 가장 좋다. 그리고 자식에서 부모로의 역방향 링크(back-link)는 생 포인터(raw pointer)로 안전하게 구현될 수 있는데, 자식 노드는 결코 부모보다 수명이 길어서는 안 되기 때문이다. 따라서 자식 노드가 대상을 잃은 부모 포인터를 역참조 할 위험은 없다.
물론 모든 포인터 기반 데이터 구조가 엄격하게 계층적인 것은 아니며, 그러한 경우나 캐싱, 그리고 관찰자 리스트의 구현과 같은 상황에서는 weak_ptr가 준비되어 있다는 사실을 아는 것이 좋다.
효율성의 관점에서 weak_ptr이야기는 본질적으로 shared_ptr와 동일하다.
weak_ptr 객체는 shared_ptr객체와 크기가 같고, 동일한 제어 블록을 사용하며(Item 19 참조), 생성, 소멸, 대입과 같은 연산은 원자적 참조 횟수 조작을 수반한다.
weak_ptr은 공유 소유권에 참여하지 않으며, 따라서 가리켜지는 객체의 참조 횟수(shared count)에 영향을 주진 않는다. 다만 제어 블록에는 두 번째 참조 횟수(weak count)가 존재하며, weak_ptr가 조작하는 것은 바로 이 두번째 참조 횟수이다. 자세한 내용은 Item 21에서 이어진다.
- note : shared count가 0이 되면 객체가 파괴되지만, weak count가 0이 되어야만 제어블록(Control Block)이 메모리에서 해제된다. 즉, weak_ptr가 하나라도 남아있으면 제어 블록은 죽지 못하고 남아있다.
기억해야 할 사항들
- 대상을 잃을 수도 있는(dangle), shared_ptr와 유사한 포인터가 필요할 때는 weak_ptr을 사용하라
- weak_ptr의 잠재적인 유스케이스로는 캐싱(caching), 관찰자 리스트(Observer lists), 그리고 shared_ptr 순환방지가 있다.