스마트 포인터 또다른 쓰임이다.
std::shared_ptr처럼 작동하되 대상을 잃을 수도 있는 포인터가 필요하면 std::weak_ptr를 사용하라.
=> 즉, 객체를 접근할 수 있냐 확인하는 용이라고 생각된다. 다시 말해서 자신이 가리키는 대상이 이미 파괴되었다는 문제를 해결할 수 있어야 한다.
다시 말해서 자신이 가리키게 되어 있는 객체가 사실 존재하지 않는 상황을 검출할 수 있어야 한다.
=> shared_ptr을 쓰지 말라고 하는 것이 아니라 weak_ptr은 shared_ptr을 보강한다.
대체로 std::weak_ptr는 std::shared_ptr를 이용해서 생성한다.
std::weak_ptr는 자신을 생성하는 데 쓰인 std::shared_ptr가 가리키는 것과 동일한 객체를 가리키나, 그 객체의 참조 카운트(reference count)에는 영향을 주지 않는다.
대상을 잃은 std::weak_ptr를 가리켜 만료되었다(expired)라고 말한다.
std::weak_ptr의 만료 여부는 멤버 함수 expired가 돌려주는 값으로 판단할 수 있다.
만료되지 않은 std::weak_ptr이라고 해도 피지칭 객체에 직접 접근하는 것은 불가능하다.
std::weak_ptr에는 역참조 연산이 없기 때문이다.
=>역참조를 하려면 일련의 과정을 거쳐야 한다.
역참조 연산이 가능하도록 하더라도, 만료 점검과 참조를 분리하면 경쟁 조건이 발생할 수 있다.
즉, expired 호출과 역참조 사이에서 다른 어떤 스레드가 해당 객체를 가리키는 마지막 std::shared_ptr를 재대입 또는 파괴할 수도 있기 때문이다.
그러면 해당 객체가 파괴되며, 포인터를 역참조하면 미정의 행동이 나온다.
=> 원자적 연산으로 수행되어야 하는 필요성
제대로 된 용법은 std::weak_ptr로부터 std::shared_ptr를 생성하여 사용하는 것이다.
1. weak_ptr의 만료 여부 점검
2-1 만료되었다면 그걸로 끝.
2-2. 만료되지 않았다면 피지칭 객체에 대한 접근을 돌려주는 연산을 하나의 원자적 연산으로 수행
=> 1. std::weak_ptr::lock을 이용해서 std::weak_ptr로부터 std::shared_ptr를 얻고 그 std::shared_ptr을 역참조한다.
=> 2. std::shared_ptr의 생성자에 std::weak_ptr를 넘겨준다.
0.
//-----------------------------------------------------------------------------
// std::weak_ptr는 std::shared_ptr로부터
// 얻을 수 있다.
auto spw = // spw가 생성된 후, 피지칭
std::make_shared<Widget>(); // Widget의 참조 카운트는 1
...
std::weak_ptr<Widget> wpw(spw); // wpw는 spw와 같은 Widget을
// 가리킨다. 카운트는 여전히 1이다.
...
spw = nullptr; // 카운트가 0이 되고 Widget이
// 파괴된다; 이제 wpw는
// 대상을 잃은 상태이다.
1.
//--------------------------------점검----------------------------------
// std::weak_ptr가 만료되었는지 알고 싶으면,
// expired 멤버 함수가 돌려주는 값을
// 점검하면 된다.
if (wpw.expired()) ... // wpw가 객체를
// 가리키지 않으면...
//---------------------------------역참조---------------------------------------
// 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가 사용하는 것과 같은 제어 블록을 사용하며, 생성이나 파괴, 대입 같은 연산에 원자적 참조 카운트 조작이 관여한다.
=> 앞서 std::weak_ptr가 참조 카운트 관리에 관여하지 않는다고 말한 것과 모순인 것처럼 보인다.
정확하게 말하자면 이렇다. std::weak_ptr는 객체의 소유권 공유에 참여하지 않으며, 따라서 피지칭 객체의 참조 카운트에 영향을 미치지 않는다.
앞에서 언급했듯이 제어 블록에는 '두 번째' 참조 카운트가 있으며 그것이 std::weak_ptr가 조작하는 참조 카운트이다.
std::weak_ptr의 잠재적인 용도로는 캐싱, 관찰자 목록(Observers) 그리고 std::shared_ptr 순환 고리 방지가 있다.
**캐싱의 예
어떤 팩토리 함수가 주어진 고유 ID에 해당하는 읽기 전용 객체를 가리키는 스마트 포인터를 돌려준다고 하자.
팩토리 함수의 반환 타입에 관한 항목 18의 조언에 따라, 그 팩토리 함수는 std::unique_ptr를 돌려준다.
std::unique_ptr<const Widget> loadWidget(WidgetID id);
loadWidget의 비용이 크다고 하자.
=> 캐싱의 이유
그리고 ID들이 되풀이해서 쓰이는 경우가 많다고 하자.
그렇다면 loadWidget과 같은 일을 하되 호출 결과들을 캐싱하는 함수를 작성하여 최적화를 할 수 있을 것이다.
=> 이미 있는 것은 load하지 않는다.
그런데 요청된 모든 Widget을 캐시에 담아 둔다면 그 자체로 성능상의 문제가 발생할 것이다.
=> 모든 것을 가지고 있는다면 캐시의 의미가 없다.
따라서 더 이상 쓰이지 않는 Widget은 캐시에서 삭제하는 것이 자연스럽다.
이러한 캐시 적용 팩토리 함수의 반환 타입을 std::unique_ptr로 두는 것은 그리 바람직하지 않다.
=> 소유권을 제어하는 것(읽기 전용으로 객체를 제공)이 unique_ptr을 쓰는 이유가 될 수는 있겠지만 캐싱을 할 수 없다.
호출자가 캐싱된 객체를 가리키는 스마트 포인터를 받아야 한다는 점은 확실하며 그 객체들의 수명을 호출자가 결정할 수 있어야 한다는 점도 확실하다.
캐시에 있는 포인터들은 자신이 대상을 잃었음을 검출할 수 있어야 한다.
=> 객체를 저장하는 것이 아닌 어떠한 객체를 가리키는 포인터를 캐싱하고 있다.
따라서 캐시에 저장할 포인터는 자신이 대상을 잃었음을 감지할 수 있는 포인터,즉 std::weak_ptr이어야 한다.
weak_ptr이 share_ptr에서 만들어진다고 앞서 말했듯이 캐싱을 위해선 팩토리 함수의 반환 타입이 반드시 std::shared_ptr이어야 함을 뜻한다.
=> weak_prt은 객체의 수명을 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
// (단, 객체가 캐시에 없으면 널)
if (!objPtr) { // 캐시에 없으면
objPtr = loadWidget(id); // 적재하고
cache[id] = objPtr; // 캐시에 저장
}
return objPtr;
}
// 이 코드가 제대로 작동하려면 WidgetID를 해싱하는
// 함수와 상등을 비교하는 함수도 지정해야 하지만,
// 그 부분은 생략했다.
// 이 fastLoadWidget 구현은 더 이상
// 쓰이지 않는(따라서 파괴된) Widget들에
// 해당하는 만료된 std::weak_ptr들이
// 캐시에 누적될 수 있다는 사실을 무시한다.
// 그 부분을 좀 더 개선할 수도 있지만, 지금의
// std::weak_ptr 논의 자체에 도움이 되는 것은
// 아니므로 이 정도로 마무리한다.
** 관찰자, Observer의 예
이 패턴의 주된 구성 요소는 관찰 대상(subject ,상태가 변할 수 있는 객체)과 관찰자(observer, 상태 변화를 통지받는 객체)이다.
대부분의 관찰자 패턴 구현에서, 각 관찰 대상 객체에는 자신의 관찰자들을 가리키는 포인터들을 담은 데이터 멤버가 있다.
=> 가지고 있는 이유 : 이런 데이터 멤버가 있으면 상태 변화를 관찰자들에게 손쉽게 통지할 수 있다.
관찰 대상은 관찰자들의 수명을 제어하는 데에는 관심이 없지만 자신이 파괴된 관찰자에 접근하는 일이 없도록 보장하는 데에는 관심이 아주 많다.
=> 관찰 대상이 파괴된 포인터를 가지고 접근을 시도하면 미정의 행동이 실행된다.
그러한 관찰 대상에 합당한 설계 하나는 관찰자들을 가리키는 std::weak_ptr들의 컨테이너를 데이터 멤버로 두는 것이다.
=> 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. 생 포인터 (raw_ptr)
이 접근방식에는 만일 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
이 경우에는 앞의 두 문제 모두 해결된다.
1. A가 파괴되면
A를 가리키는 B의 포인터가대상을 잃지만 B는 그 사실을 알 수 있다.
2. 비록 A와 B가 서로를 가리키지만
B의 포인터는 A의 참조 카운트에 영향을 미치지 않으며, 따라서 std::shared_ptr들이 더 이상 A를 가리키지 않게 되면 A가 정상적으로 파괴된다.
'프로그래밍 지식 > Effective Modern C++' 카테고리의 다른 글
Effective Modern C++, 항목 5 : 명시적 형식 선언보다는 auto를 선호하라 (0) | 2022.06.18 |
---|---|
Effective Modern C++, 항목 7 : 객체 생성시 괄호와 중괄호 구분하기 (0) | 2022.05.16 |
Effective Modern C++ 값의 종류 (0) | 2022.05.14 |