[Modern C++] 항목 20: std::shared_ptr처럼 작동하되 대상을 잃을 수도 있는 포인터가 필요하면 std::weak_ptr를 사용하라
카테고리: Cpp
이 글은 아래의 책을 정리하였습니다. 이펙티브 모던 C++, 스콧 마이어스 저자, 류광 번역
가독성이 떨어지는 직역들을 수정하며 정리하였습니다. e.g. 연역 → 추론, 중복적재 → 오버로딩
📦 4. 스마트 포인터
👉🏻 항목 20: std::shared_ptr처럼 작동하되 대상을 잃을 수도 있는 포인터가 필요하면 std::weak_ptr를 사용하라
🔍 std::weak_ptr란?
std::weak_ptr는 단독으로 쓰는 스마트 포인터가 아니라, std::shared_ptr를 보조하기 위한 수단이다. 생성은 std::shared_ptr로부터 하고, 실제 사용도 다시 std::shared_ptr로 되돌려서 한다. 소유권에 참여하지 않은 채 객체를 지켜보고만 있다가, 접근이 필요한 순간에만 std::shared_ptr로 승격시켜 쓰는 것이 std::weak_ptr의 역할이다.
std::shared_ptr처럼 행동하되 가리키는 대상의 소유권 공유에는 참여하지 않는 포인터가 필요한 경우가 있다.- 가리키는 객체의 참조 횟수에 영향을 주지 않는다.
std::weak_ptr자체로는 역참조할 수 없으며, 널인지 확인할 수도 없다. 독립적인 스마트 포인터가 아니기 때문이다.std::weak_ptr는 가리키는 대상이 이미 파괴되었을 수도 있다는 문제, 즉 대상을 잃은(expired) 상황을 감지할 수 있어야 한다.
🏗️ std::weak_ptr 생성
대체로 std::weak_ptr는 std::shared_ptr를 이용해서 생성한다.
auto spw =
std::make_shared<Widget>(); // spw가 생성된 후, 가리키는 Widget의
// 참조 횟수는 1이다
std::weak_ptr<Widget> wpw(spw); // wpw는 spw와 같은 Widget을 가리킨다;
// 참조 횟수는 여전히 1이다
spw = nullptr; // 참조 횟수가 0이 되고 Widget이 파괴된다;
// 이제 wpw는 대상을 잃은 상태다
- 만료 여부를 직접 확인할 수 있다.
if (wpw.expired()) … // wpw가 객체를 가리키지 않으면...
🔒 std::weak_ptr에서 std::shared_ptr 생성
만료 여부 점검과 가리키는 객체에 대한 접근을 하나의 원자적 연산으로 수행해야 한다. 만료 점검과 역참조를 분리하면 경쟁 조건이 발생할 수 있기 때문이다.
방법 1: lock() 멤버 함수 사용
lock()은 std::weak_ptr가 지켜보던 객체에 대한 std::shared_ptr를 만들어 돌려주는 멤버 함수다. 반환 형식은 std::shared_ptr<T>이다.
- 객체가 아직 살아 있으면 → 참조 횟수를 1 증가시킨
std::shared_ptr를 돌려준다. 따라서 반환값을 들고 있는 동안에는 객체가 파괴되지 않음이 보장된다. - 이미 만료되었으면 → 널
std::shared_ptr를 돌려준다. 예외를 던지지 않으므로, 반환값이 널인지 확인해서 분기하면 된다. - 만료 확인과
std::shared_ptr생성이 한 번에 일어나므로,expired()로 확인한 뒤 따로 접근하는 방식과 달리 경쟁 조건이 없다.
std::shared_ptr<Widget> spw1 = wpw.lock(); // wpw가 만료이면 spw1은 널
auto spw2 = wpw.lock(); // 위와 동일하나 auto를 사용했음
방법 2: std::weak_ptr를 인수로 받는 std::shared_ptr 생성자 사용
std::shared_ptr<Widget> spw3(wpw); // wpw가 만료이면 std::bad_weak_ptr가 발생
✅ std::weak_ptr의 활용 사례
사례 1: 캐싱(Caching)
팩터리 함수가 돌려준 객체를 클라이언트가 다 사용하고 나면 파괴되는데, 해당 캐시 항목은 대상을 잃게 된다. 따라서 캐시에 저장할 포인터는 자신이 대상을 잃었음을 감지할 수 있는 포인터, 즉 std::weak_ptr이어야 한다.
std::shared_ptr<const Widget> fastLoadWidget(WidgetID id)
{
static std::unordered_map<WidgetID,
std::weak_ptr<const Widget>> cache;
auto objPtr = cache[id].lock(); // 캐시에 있는 객체를 가리키는
// std::shared_ptr (캐시에 없으면 널)
if (!objPtr) { // 캐시에 없으면
objPtr = loadWidget(id); // 적재하고
cache[id] = objPtr; // 캐시에 저장
}
return objPtr;
}
사례 2: 관찰자(Observer) 패턴
관찰자 패턴의 주된 구성 요소는 관찰 대상(subject; 상태가 변할 수 있는 객체)과 관찰자(observer; 상태 변화를 통지받는 객체)이다. 관찰 대상에 합당한 설계 하나는, 관찰자들을 가리키는 std::weak_ptr들의 컨테이너를 자료 멤버로 두는 것이다. 그러면 먼저 만료 여부를 보고 관찰자가 유효한지 점검한 후에 관찰자에 접근할 수 있다.
사례 3: std::shared_ptr 순환 고리 방지
객체 A, B, C로 이루어진 자료구조에서 A와 C가 B의 소유권을 공유하고, B에서 다시 A를 가리키는 포인터가 필요한 상황을 생각해 보자.
---
config:
layout: dagre
theme: dark
look: handDrawn
---
flowchart LR
A["A"]
B["B"]
C["C"]
A -- "std::shared_ptr" --> B
C -- "std::shared_ptr" --> B
B -. "???" .-> A
B에서 A를 가리키는 포인터의 선택지:
- raw 포인터: A가 파괴되면 B가 가진 포인터는 대상을 잃게 되나, B는 그 사실을 알지 못한다. 미정의 행동 발생 가능.
std::shared_ptr: A와 B가 서로를 가리키는 순환 고리가 생겨 A와 B 둘 다 파괴되지 못한다. → 누수 발생.std::weak_ptr: A가 파괴되면 A를 가리키는 B의 포인터가 대상을 잃지만, B는 그 사실을 알 수 있다. 더 나아가 B의 포인터는 A의 참조 횟수에 영향을 미치지 않으며,std::shared_ptr들이 더 이상 A를 가리키지 않게 되면 A가 정상적으로 파괴된다. →std::weak_ptr가 제일 나은 선택.
트리처럼 엄격히 계층적인 자료구조에서는 일반적으로 자식 노드들을 오직 그 부모만 소유한다. 부모에서 자식으로의 링크는 일반적으로
std::unique_ptr로 표현하는 것이 최선이다. 자식에서 부모로의 역링크(backlink)는 raw 포인터로 구현해도 안전하다. 자식 노드의 수명이 부모의 수명보다 길 수는 없기 때문이다.
📊 성능 특성
std::weak_ptr객체는 그 크기가std::shared_ptr객체와 같다.std::shared_ptr가 사용하는 것과 같은 제어 블록(항목 19 참고)을 사용한다.- 생성, 파괴, 대입 같은 연산에 원자적 참조 횟수 조작이 관여한다.
- 단,
std::weak_ptr는 객체의 소유권 공유에 참여하지 않으며, 가리키는 객체의 참조 횟수에 영향을 미치지 않는다. 제어 블록에는 ‘두 번째’ 참조 횟수가 있으며, 그것이 바로std::weak_ptr가 조작하는 참조 횟수다. (항목 21 참고)
🧐 정리
std::shared_ptr처럼 작동하되 대상을 잃을 수도 있는 포인터가 필요하면std::weak_ptr를 사용하라.std::weak_ptr의 잠재적인 용도로는 캐싱, 관찰자 목록, 그리고std::shared_ptr순환 고리 방지가 있다.
댓글남기기