해당 문서는 다음 자료들을 참고하였습니다.

 


https://modoocode.com/229

 

씹어먹는 C ++ - <13 - 1. 객체의 유일한 소유권 - unique_ptr>

모두의 코드 씹어먹는 C ++ - <13 - 1. 객체의 유일한 소유권 - unique_ptr> 작성일 : 2018-09-18 이 글은 60741 번 읽혔습니다. 이번 강좌에서는 C++ 의 RAII 패턴unique_ptr안녕하세요 여러분! 지난번 강좌에서 다

modoocode.com

https://modoocode.com/252

 

씹어먹는 C ++ - <13 - 2. 자원을 공유할 때 - shared_ptr 와 weak_ptr>

모두의 코드 씹어먹는 C ++ - <13 - 2. 자원을 공유할 때 - shared_ptr 와 weak_ptr> 작성일 : 2018-12-21 이 글은 51016 번 읽혔습니다. 이번 강좌에서는 shared_ptrenable_shared_from_thisweak_ptr에 대해 다룹니다.안녕하

modoocode.com

 


 

C++에 들어오면서, "기존의 로우 포인터(=Raw Pointer) 에서 발생할 수 있는 문제들을 해결하기 위한 포인터"를 만들겠다는 명목하에 스마트 포인터라는 포인터가 추가되었다.

 

Raw Pointer 를 쓰면 생기는 문제점은 크게

1. 메모리를 사용했는데 해제 안한 경우
= 메모리가 낭비되는 현상 (=메모리 누수 / Memory Leak) 가 발생

2. 메모리를 사용하던 중에 해제한 경우, 해제한 메모리를 접근하는 경우

= 잘못된 참조  (Dangling Pointer) 발생으로 크래시 위험

 

둘다 문제가 많은 상황인데,

해제를 안한 경우가 "언제 터질지 모르는 시한폭탄" 이라면, 해제된 메모리 접근은 "지뢰" 라고 볼 수 있다.

둘다 터지면 머리아픈건 매한가지지만

 

 

주요 이론은, "객체는 (특별한 문제가 없는 한) 사라질때 소멸자가 반드시 호출되며, 이때 자원 해제를 하게 되면 자원 해제와 관련된 문제가 해결되지 않을까?" 라는 마인드에 입각하여(*1) 
포인터를 포인터 객체로 만들어서, 해당 객체가 소멸시 데이터까지 같이 delete 하도록 하여 각종 포인터 예외를 대응하고자 한것이다. 

 

다양한 스마트 포인터가 있고, 각자 다양한 목적을 위해 사용하는데 각각에 대한 정보를 정리를 한번 해야할것 같아 스스로 이해한 부분에 대해 정리를 해보고자 한다.

 

물론, 이미 Duplicated 된 Auto_Ptr은 생략한다...

 

 

 

 

해당 문서에는 각 스마트포인터의 사용법보단, 구조 및 원리에 대해 주로 설명합니다.

사용법이 필요하다면 적당히 찾아보시거나 직접 연구해보시는것을 추천합니다.

 

 

 

1) 해당 내용과 관련된 디자인 패턴이 RAII 라고 불리는 객체를 통한 자원 획득 디자인 패턴임.
RAII : Resource Acquisition Is Initialization


Unique_Ptr

 

unique_ptr은 특정 객체에 대한 유일한 소유권을 가지는 스마트 포인터이다.

대체로, 유일한 소유권을 가지는 경우에 대해 "이걸 왜 쓰냐" 라는 의문을 가질때가 종종 있는데

 

다음과 같은 경우에 주로 문제가 발생한다.

 

Data* data1 = new Data();
Data* data2 = data1;

delete data1;// = data1 에 연결된 객체 소멸 (=new data() 로 선언한 객체)
delete data2;// = data2 에 연결된 객체 소멸 (=data1 (= new data()로 선언한 객체)

 

간단히 설명하면, data1 포인터에 새로운 data 객체를 만들어줬고, 이를 data2에서도 참조할 수 있도록 해놨는데,

수행 이후 data1 포인터를 통해 객체를 소멸시켰으나, data2에서도 연결된 객체를 소멸시키려고 하는 상황이다.

이때, 이미 삭제된 객체를 삭제하려고 시도해서 메모리 오류가 발생하며 Crash가 발생하게 된다.

 

"그럼 객체 소멸을 한가지 포인터만 하게 하면 되지 않나요?" 란 의문에서 나온것이 바로 unique_ptr 되시겠다.

 

특정 객체의 소유권을 지정하여 해당 포인터에서만 객체 접근 및 소멸을 담당하게 하면, 다른 포인터가 임의로 객체를 파괴시키는 현상은 일어나지 않게 될것이다. 또한, 위에서 설명한 문제중 "포인터를 쓰고 해제 하지 않는 문제"를 RAII 패턴에 입각하여 대응을 한것이 unique_ptr이고, 이는 일반적인 포인터처럼 사용할 수 있으나 다음과 같은 특징을 가지고 있다.

 

1. 특정 객체 (= 특정 클래스로 이루어진 메모리 영역) 에 대해 유일한 소유권을 가진다.

2. 함수 스택에 포함된 객체로, 함수가 끝나면 소멸자가 호출되며 메모리를 자동으로 해제한다.

 

 

사실, 2번에 대해서는 이해하기가 쉬운데, 1번은 "그걸 어떻게 알 수 있는데?" 라는 의문이 생길 수 있다.

안그래도 궁금해서 좀 찾아보았는데, unique_ptr 구현부에 다음과 같은 항목이 있었다.

 

      /** Takes ownership of a pointer.
       *
       * @param __p  A pointer to an object of @c element_type
       * @param __d  A reference to a deleter.
       *
       * The deleter will be initialized with @p __d
       */
      template<typename _Del = deleter_type,
               typename = _Require<is_copy_constructible<_Del>>>
        unique_ptr(pointer __p, const deleter_type& __d) noexcept
        : _M_t(__p, __d) { }
      /** Takes ownership of a pointer.
       *
       * @param __p  A pointer to an object of @c element_type
       * @param __d  An rvalue reference to a (non-reference) deleter.
       *
       * The deleter will be initialized with @p std::move(__d)
       */
      template<typename _Del = deleter_type,
               typename = _Require<is_move_constructible<_Del>>>
        unique_ptr(pointer __p,
                   __enable_if_t<!is_lvalue_reference<_Del>::value,
                                 _Del&&> __d) noexcept
        : _M_t(__p, std::move(__d))
        { }

 

위는 할당, 아래는 "이동" 연산

 

여기서 보면 _M_t_ 라는 변수가 보이는데, 이는 __uniq_ptr_data 라는 구조체 변수이고, 해당 변수는 내부에 "__unique_ptr_impl" 라는 변수를 가지고 있다.

 

//__uniq_ptr_data 구현부
template <typename _Tp, typename _Dp,
            bool = is_move_constructible<_Dp>::value,
            bool = is_move_assignable<_Dp>::value>
    struct __uniq_ptr_data : __uniq_ptr_impl<_Tp, _Dp>
    {
      using __uniq_ptr_impl<_Tp, _Dp>::__uniq_ptr_impl;
      __uniq_ptr_data(__uniq_ptr_data&&) = default;
      __uniq_ptr_data& operator=(__uniq_ptr_data&&) = default;
    };
    
    
    
    
 // Manages the pointer and deleter of a unique_ptr
  template <typename _Tp, typename _Dp>
    class __uniq_ptr_impl
    {
      template <typename _Up, typename _Ep, typename = void>
        struct _Ptr
        {
          using type = _Up*;
        };
      template <typename _Up, typename _Ep>
        struct
        _Ptr<_Up, _Ep, __void_t<typename remove_reference<_Ep>::type::pointer>>
        {
          using type = typename remove_reference<_Ep>::type::pointer;
        };
    public:
      using _DeleterConstraint = enable_if<
        __and_<__not_<is_pointer<_Dp>>,
               is_default_constructible<_Dp>>::value>;
      using pointer = typename _Ptr<_Tp, _Dp>::type;
      static_assert( !is_rvalue_reference<_Dp>::value,
                     "unique_ptr's deleter type must be a function object type"
                     " or an lvalue reference type" );
      __uniq_ptr_impl() = default;
      __uniq_ptr_impl(pointer __p) : _M_t() { _M_ptr() = __p; }
      template<typename _Del>
      __uniq_ptr_impl(pointer __p, _Del&& __d)
        : _M_t(__p, std::forward<_Del>(__d)) { }
      __uniq_ptr_impl(__uniq_ptr_impl&& __u) noexcept
      : _M_t(std::move(__u._M_t))
      { __u._M_ptr() = nullptr; }
      __uniq_ptr_impl& operator=(__uniq_ptr_impl&& __u) noexcept
      {
        reset(__u.release());
        _M_deleter() = std::forward<_Dp>(__u._M_deleter());
        return *this;
      }
      pointer&   _M_ptr() { return std::get<0>(_M_t); }
      pointer    _M_ptr() const { return std::get<0>(_M_t); }
      _Dp&       _M_deleter() { return std::get<1>(_M_t); }
      const _Dp& _M_deleter() const { return std::get<1>(_M_t); }
      void reset(pointer __p) noexcept
      {
        const pointer __old_p = _M_ptr();
        _M_ptr() = __p;
        if (__old_p)
          _M_deleter()(__old_p);
      }
      pointer release() noexcept
      {
        pointer __p = _M_ptr();
        _M_ptr() = nullptr;
        return __p;
      }
    private:
      tuple<pointer, _Dp> _M_t;				//<<<---포인터 소유권을 관리하는 tuple
    };

 

해당 로직에서, _M_t 변수가 unique_ptr 내부의 포인터 변수임을 알 수 있었다.

 

그리고, unique_ptr이 특정 객체를 유일하게 소유하게 하는 방법은 다름이 아닌 "일부 생성자 함수를 삭제" 하는 방식이었는데, unique_ptr 구현부를 일부 뒤적대다보면 다음과 같이 기록되어 있다.

 

      // Disable copy from lvalue.
      unique_ptr(const unique_ptr&) = delete;
      unique_ptr& operator=(const unique_ptr&) = delete;

 

즉, 일반적인 복사 생성자나 대입 연산자는 삭제되어있으니, 당연히 접근하면 오류가 발생하는것이다.

 

'std::unique_ptr<A,std::default_delete<_Ty>>::unique_ptr(const std::unique_ptr<_Ty,std::default_delete<_Ty>> &)': attempting to reference a deleted function

 

오류 메시지는 다음과 같이 나온다. 삭제된 함수 (= 여기서는 복사, 대입 연산자)에 접근하려고 했다는 뜻.

 

 

이와 같은 방법으로 unique_ptr 간의 소유권을 지정할 수 있게 되었으며 (정확히는 조금.... 눈가리고 아웅 같긴 하지만) RAII 패턴에 힘입어 안전한 해제까지 보장되게 된 것이다.

 

 

 

p.s -

주의사항 : 물론, 설명에 없는것에서 눈치챘을수도 있지만 소유권이 이전된 빈 포인터가 된 unique_ptr에 포인터 연산을 시도 시 댕글링 포인터 문제로 인해 런타임 오류가 발생한다.

 


 

Shared_Ptr

 

근데 내가 특정 객체를 여러번 써야하는 경우가 있다면?

사실 이런 경우가 굉장히 흔하다. (오히려 unique_ptr을 쓰는 경우는 되게 드물었던것으로 기억한다.)

 

그렇다면 요구사항은 다음과 같이 정리된다.

 

1. 나는 한개의 객체를 여러개의 포인터로 접근하고 싶다.
2. 하지만, 그러면서 스마트 포인터의 특징 (= 안전한 해제)를 보장받고 싶다.

 

이를 위해 만들어진것이 바로 shared_ptr 되시겠다.

 

shared_ptr은 같은 객체를 다수의 스마트 포인터가 가리킬 수 있고, 내부적으로 레퍼런스 카운팅을 통해 몇번 참조중인지를 알 수 있는 스마트 포인터다.

레퍼런스 카운터가 0이 되면, 자동으로 해제되는 형태의 스마트 포인터로 알려져있는데.....

 

그럼 그 레퍼런스 카운터는 어떻게 되는가... 하면

__shared_count라는 서브 클래스가 따로 있고, (해당 클래스 내부에는 "_Sp_counted_base" 라는 클래스가 있다) 해당 클래스에서 카운트를 실시하는 스타일이다.

 

  template<>
    inline void
    _Sp_counted_base<_S_single>::_M_add_ref_copy()
    { ++_M_use_count; }
  template<>
    inline void
    _Sp_counted_base<_S_single>::_M_release() noexcept
    {
      if (--_M_use_count == 0)
        {
          _M_dispose();
          if (--_M_weak_count == 0)
            _M_destroy();
        }
    }

 

해당 정보는 같은 포인터를 가리키는 shared_ptr 끼리 공유하며 해당 공유 정보를 Control Block이라고 부른다.

즉, 레퍼런스 카운트 정보를 동일한 Control Block이 소유하고, 이를 각각의 shared_ptr이 공유하면서 사용하면 굳이 각 포인터에서 각자 카운터를 조작할 필요가 없게 되는것...

 

물론, 이를 위해서는 최초의 shared_ptr에서 접근을 늘려가야 하며 (즉, 최초의 shared_ptr을 제외한 N번째 shared_ptr은 최초의 shared_ptr을 가리켜야 한다), 1개의 raw pointer에서 다수의 shared_ptr을 새로 선언하면 shared_ptr이 자랑하는 레퍼런스 카운트 기능이 딱히 의미가 없어진다.

 

즉, shared_ptr에 raw pointer (=주소값)이 전달되면, 해당 shared_ptr은 본인이 해당 주소값을 최초로 소유한것으로 인지하게 된다는 것이다.

 

예시를 들자면, 다음과 같은 상황이다.

Data* data = new Data();	
    
std::shared_ptr<Data> p1(data);
std::shared_ptr<Data> p2(p1);
std::shared_ptr<Data> p3(data);


std::cout << p1.use_count() << std::endl;
std::cout << p2.use_count() << std::endl;
std::cout << p3.use_count() << std::endl;

 

이때의 결과값은

p1 = 2

p2 = 2

p3 = 1

 

이라는 결과가 나온다.

 

이 경우가 shared_ptr을 쓸때 가장 주의해야 하는 상황인데, 총 참조 회수는 3회임에도 불구하고,
시스템 상에서는 [p1,p2], [p3] 으로 2개의 제어 블록이 붙은 상황이라 카운팅이 다르게 발생한다.

이 경우, p3가 만일 사라진다면 p3에 연결된 원본 데이터도 같이 소멸하면서 p1, p2 포인터에서 오류가 발생한다.

 

해당 상황을 의도적으로 만들어야하는 경우가 아니라면, 동일한 raw pointer로 여러개의 shared_ptr을 만드는것은 지양해야 할것이다...

 

 

 

 

한가지 더. shared_ptr을 찾아보면 항상 나오는 얘기가 바로 순환참조이다.

순환참조를 아주 간단히 설명하면,
"A가 지워지려면 B에 있는 참조가 지워져야 하는데 B가 지워지려면 A에 있는 참조가 지워져야 됨"

즉, 각각의 객체에 다른 객체를 가리키는 shared_ptr이 존재하며 서로 레퍼런스 카운트를 차지하므로써 소멸이 이루어지지 않는 현상을 의미한다.

이 경우에는 시스템이 종료되기 전까지는 무슨짓을 해도 안 날아가기때문에, 메모리를 영원히 차지하는 문제가 생긴다.

 

 

 

 

주의사항 요약 : 

1. 동일 raw pointer로 다수의 shared_ptr 만들기 금지

2. shared_ptr을 서로 참조하는 순한참조 만들기 금지

 


Weak_ptr

 

이쯤되면, 이런 고민도 들 것이다.

"나는 그냥 포인터 값만 확인하고 싶고 레퍼런스 카운터에는 영향을 주고싶지 않아요"

"나는 순환 참조 문제를 피하면서 스마트 포인터를 쓰고 싶어요"

 

이럴때를 위한 스마트 포인터가 바로 weak_ptr 되시겠다.

 

weak_ptr은 shared_ptr과 유사하게 작동하나, 레퍼런스 카운터에는 영향을 미치지 않는다는 특징이 있다.

물론 weak_ptr에도 내부적으로 몇개의 weak_ptr이 사용중인가를 카운트하는 무언가가 있긴 하지만, shread_ptr의 레퍼런스카운트 보다는 크게 중요하지는 않아 보인다.

 

Data* data = new Data();	
    
std::shared_ptr<Data> p1(data);
std::shared_ptr<Data> p2(p1);
std::weak_ptr<Data> p3(p1);


std::cout << p1.use_count() << std::endl;
std::cout << p2.use_count() << std::endl;
std::cout << p3.use_count() << std::endl;

 

사용은 다음과 같이 할 수 있고, 카운트는 각각 2,2,2 로 동일한 숫자가 나오게 된다.

 

weak_ptr의 특이사항으로, weak_ptr로 객체를 할당받았음에도 불구하고 weak_ptr에서 임의로 객체에 접근할 수 는 없다.

이유는 어찌보면 당연한데, weak_ptr을 이용해서 직접 데이터를 접근하는 도중 원본 shared_ptr의 레퍼런스 카운트가 0이 된다면? 이라는 질문에 대한 답을 찾아보면 쉽게 이해할 수 있을것이다.

 

답은 뻔할것이다. 메모리 오류가 터지면서 Crash가 발생하게 될것이다.

 

이를 막기 위해, weak_ptr 에서 데이터에 접근하고 싶으면, weak_ptr에 포함된 Lock() 이란 함수를 이용해주면 된다.

해당 함수는 weak_ptr에서 값에 접근을 시도하겠다고 알리면서, shared_ptr을 하나 추가로 생성하는 (= 레퍼런스 카운트를 1 올리는) 연산을 수행한다.

 

__shared_ptr<_Tp, _Lp>
lock() const noexcept
{ return __shared_ptr<element_type, _Lp>(*this, std::nothrow); }

 

이렇게 되면, weak_ptr에 연결된 객체에 접근 가능한 임시 shared_ptr이 생성되며, 해당 shared_ptr을 통해 데이터를 사용할 수 있게 된다.

 

std::shared_ptr<Data> p2 = new Data();
std::weak_ptr<Data> p2 (p1)
    
p2.lock()->GetData();	//이렇게도 되고
std::shared_ptr<List> p3 = p2.lock();
p3->GetData();		//이렇게도 된다.

 

 


 

뭔가 사용법에 대한 설명은 쏙 빼놓고 설명하니까 반절정도 밖에 이해를 못한거 같은데,

사용법은... 사실 실제로 써보는게 더 빠르니까 그 방법을 택하는걸 추천 한다.

+ Recent posts