본문 바로가기

컴퓨터과학

C++ (20-2) std::shared_ptr처럼 작동하되 대상을 잃을 수도 있는 포인터가 필요하면 std::weak_ptr를 사용할 것

c++ weak_ptr

[std::shared_ptr처럼 작동하되 대상을 잃을 수도 있는 포인터가 필요하면 std::weak_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 (단, 객체가 캐시에 없으면 널)

 if (!objPtr) 
 { // 캐시에 없으면 적재하고 캐시에 저장
  objPtr = poadWidget(id);
  cache[id] = objPtr;
 }
 return objPtr;
}

(*[20-1] std::shared_ptr -> std::weak_ptr 사용에 이은 내용)
이 코드가 제대로 작동하려면 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를 가지고 있는 상황을 생각해 봅시다.

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

그런데 B에서 다시 A를 가리키는 포인터가 필요하게 되었다고 합시다. 그 포인터는 어떤 종류의 포인터이어야 할까요??
선택은 세 가지 입니다.

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

- std::shared_ptr : 이 설계에서 A와 B는 서로를 가리키는 std::shared_ptr들을 가집니다. 그러면 std::shared_ptr들의 순환 고리(cycle; A가 B를 가리키고 B가 A를 가리키는)가 생기며, 결과적으로 A와 B 둘 다 파괴되지 못합니다. 프로그램의 다른 자료구조에서 A와 B에 접근할 수 없게 된다고 해도(이를테면 C가 더 이상 B를 가리키지 않게 되어서), 둘의 참조 횟수는 여전히 1입니다. 그런 일이 생기면, A와  B는 사실상 누수가 일어난 것이라 할 수 있습니다. 프로그램이 둘에 접근할 수 없으므로, 해당 자원들을 재 확보할 수도 없습니다.

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

세 경우 중 std::weak_ptr가 제일 나은 선택입니다. 그러나 잠재적인 std::shared_ptr 순환 고리를 깨기 위해 std::weak_ptr를 사용해야 하는 상황이 아주 흔한 것은 아니라는 점도 기억해 두기 바랍니다. 트리(나무 구조)처럼 엄격히 계통적인 자료구조에서는 일반적으로 자식 노드들을 오직 그 부모만 소유합니다. 보무 노드가 파괴되면 자식 노드들도 파괴되어야 합니다. 따라서 부모에서 자식으로의 링크는 일반적으로 std::unique_ptr로 표현하는 것이 최선입니다. 자식에서 부모로의 역링크(backlink)는 생 포인터로 구현해도 안전합니다. 어차피 자식 노드의 수명이 부모의 수명보다 길 수는 없기 때문입니다. 즉, 자식 노드가 대상(부모)을 잃은 포인터를 역참조 할 위험은 없습니다.
물론 모든 포인터 기반 자료구조가 엄격히 계통적이지는 않습니다. 캐싱이나 관찰자 목록을 구현할 때와 마찬가지로, 엄격히 계통적이지는 않은 자료구조를 다룰 때에는 std::weak_ptr가 만반의 준비를 하고 기다리고 있음을 기억해 두기 바랍니다.
효율성 면에서 std::weak_ptr는 std::shared_ptr와 본질적으로 동일합니다. std::weak_ptr 객체는 그 크기가 std::shared_ptr 객체와 같으며, std::shared_ptr가 사용하는 것과 같은 제어 블록(Chapter(19) 참고)을 사용하며, 생성이나 파괴, 배정 같은 연산에 원자적 참조 횟수 조작이 관여합니다. 그런데 마지막 사항은 이번 항목의 시작 부분에서 std::weak_ptr가 참조 횟수 관리에 관여하지 않는다고 말한 것과 모순인 것처러 보일 것입니다. 사실 앞에서 조금 모호하게 말했는데, 여기서 정확히 말하자면 이렇습니다: std::weak_ptr는 객체의 소유권 공유에 참여하지 않으며, 따라서 피지칭 객체의 참조 횟수에 영향을 미치지 않습니다. 앞에서 언급했듯이 제어 블록에는 '두 번째' 참조 횟수가 있으며 그것이 바로 std::weak_ptr가 조작하는 참조 횟수입니다. 이 부분은 다음 항목(Chapter (21))에서 좀 더 자세히 이야기하도록 하겠습니다.


*** Key Point ***
- std::shared_ptr처럼 작동하되 대상을 잃을 수도 있는 포인터가 필요하면 std::weak_ptr를 사용할 것.
- std::weak_ptr의 잠재적인 용도로는 캐싱, 관찰자 목록, 그리고 std::shared_ptr 순환 고리 방지가 있습니다.


[Chapter 21 : new를 직접 사용하는 것보다 std::make_unique와 std::make_shared를 선호할 것]

우선 std::make_unique와 std::make_shared의 기본부터 짚고 넘어갑시다. std::make_shared는 C++11의 일부이지만 아쉽게도 std::make_unique는 아닙니다. std::make_shared는 C++14에서 표준 라이브러리에 포함되었습니다. 그러나 C++을 사용하는 독자라도 걱정할 필요는 없습니다. std::make_unique의 기본적인 버전을 독자가 직접 작성하는 것이 어렵지 않기 때문입니다.