프로그래밍 지식/Effective Modern C++

Effective Modern C++, 항목 20 : std::shared_ptr처럼 작동하되 대상을 잃을 수도 있는 포인터가 필요하면 std::weak_ptr를 사용하라

게임이 더 좋아 2022. 6. 15. 23:21
반응형
728x170

스마트 포인터 또다른 쓰임이다.


 

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와 CB의 소유권을 공유하는, 그래서 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가 정상적으로 파괴된다.
 

반응형
그리드형