6. 스마트 포인터

unique_ptr

C++11이 등장하기 전에도 이미 std::auto_prt 이라는 스마트 포인트를 사용했었다. 그러나 auto_ptr이 포인터에는 소유권의 문제가 있다. 내부 구현에서 auto_ptr의 복사 생성자와 할당 연산자 구현이 멤버 데이터에 대한 깊은 복사(전체 내용의 복사) 대신 얕은 복사(포인터만 복사)를 하도록 되어 있기 때문이다. 그래서 함수 안으로 온전한 auto_prt객체를 전달하기 힘들다는 문제가 있다.

함수 인자로 auto_ptr을 전달하면 복사 생성자가 호출되고 그 결과 얕은 복사가 발생하기 때문이다. 얕은 복사를 하는 특성 덕분에 특정 순간 객체의 소유권이 유일하게 하나의 auto_ptr 객체에만 존재한다는 장점도 있다. 말 그대로 복사를 허용하지 않는 것이다. 하지만 이러한 장점을 전부 덮어버릴 만한 단점이 있었으니, auto_ptr 객체는 복사가 필요한 곳에서는 사용될 수 없다는 점이다.

새로운 C++11 표준에서는 복사 시맨틱에 추가로 새로운 개념인 이동 시맨틱이 등장했다. 이동 시맨틱은 복사 시맨틱처럼 두 객체 사이에 복사를 수행하는 대신, 객체의 데이터 필드 하나하나를 이동시키는 역할을 수행한다. 두 객체간의 데이터 이동이 발생한 후에는 해당 데이터에 대한 소유권은 데이터를 받는 쪽이 갖는다. 이동 시맨틱이 필요한 이유는 무엇일까? STL의 벡터나 리트스와 같은 컨테이너의 경우, 일동의 동적 배열이기 때문에 그 크기가 상황에 따라 두배씩 늘어난다. 이때 메모리 내부에서는 대량의 복사가 발생한다. 그리고서 원본은 파괴하고 사본만 사용을 한다.

단지 배열의 크기를 늘리기 위해서 불필요한 복사 동작을 하는점, 그리고 이로 인해 쓸데없는 객체를 생성하거나 사용하지 않는 원본 객체를 파괴한다는 점등 일련의 불필요한 동작은 C++성능 저하의 주범이라는 눈총을 받았다. 이런 경우 복사보다는 이동이 낫겠다고 판단하여 이동시맨틱을 도입했고, 이를 위해 이동 생성자라는 개념을 도입했다.

C++에서는 std::unique_ptr이라는 이름의 새로운 단일 포인터 타입을 도입하여, std::auto_ptr과 하위호환을 이룬다. std::unique_ptr 내부에서는 복사 생성자와 할당 연산자가 아예 구현되어 있지 않다. unique_ptr객체는 복사가 원천 봉쇄되어 있고 단지 이동만 가능하다. 반드시 std::move()라는 함수를 이용해야만 이동할 수 있다.

#include <memory>     // for unique_ptr
int main(int argc, char** argv) 
{
    std::unique_ptr<int> p1(new int(5));

    // std::unique_ptr<int> p2 = p1; // compile Error (복사를 허용하지 않음)
    std::unique_ptr<int> p3 = std::move(p1); // move p3, p1은 존재하지 않음.
    p3.reset(); // 메모리 영역 초기화
    p1.reset(); // 이미 없으므로 효과없음.
}
#include <memory>
using namespace std;

int main(int argc, char** argv) 
{
    unique_ptr<int> p1(new int(5));     // create p1
    unique_ptr<int> p3 = move(p1);  // move to p3

    cout<<p1.get()<<endl;
    cout<<p3.get()<<endl; // 주소 반환

    cout<<*p3<< endl;   // (5)라는 값을 얻음
    auto a = *p3;
    cout<<a<< endl;     // (5)
    auto& a2 = p3;
    cout<<*a2<< endl;   // (5)

    // auto b = *p1; // 런타임 에러(p3로 이전됨)
    // cout<<b<< endl; // 런타임 에러
    p3.reset(); // 메모리 삭제
    p1.reset(); // 아무것도 하지 않음
    return 0;
}
#include <memory> // unique_ptr
#include <iostream> // cout, endl
#include <string>

using namespace std;
class Person 
{
public:
    Person() {};
    Person(int age, std::string name) : age(age), name(name) {}
    ~Person() {};
public:
    int GetAge() { return age; }
    string GetName() { return name; }
private:
    int age;
    string name;
};

int main(int argc, char** argv) 
{
    unique_ptr<Person> p(new Person(1, "Baby"));
    cout<<"Name: "<<p->GetName()<<endl;
    cout<<"Age: "<<p->GetAge()<<endl;
    getchar();
    return 0;
}

shared_prt

shared_ptr은 이름이 의미하듯 포인터가 가리키는 객체의 소유권을 이곳 저곳에서 공유할 수 있도록 디자인된 포인터이다. 바로 이 점이 unique_ptr과 shared_ptr을 구별하는 가장 큰 성질이라고 할 수 있다. 복사를 허용하지 않았던 uinique_ptr과는 달리 shared_ptr에서는 특정 보인터 객체를 여러 개의 shared_ptr 객체가 가리키도록 할 수 있다.

이런 shared_ptr을 컴파일어에서 구현하려면 레퍼런스 카운팅 이라는 방법을 사용합니다. 레퍼런스 카운팅은 여기저기로 복사되는 shared_ptr객체를 추적하려고 컴파일러가 몇 번이나 복사되는지 횟수를 기억하는 방식이다. shared_ptr 객체가 새롭게 복사될 때마다 카운터는 하나씩 증가되고, shared_ptr 객체가 삭제될 때는 그만큼 카운터의 횟수를 줄인다 이러한 메커니즘을 이용하여 객체의 저장 메모리 공간은 한 곳만 사용하고, 몇번 복사됐는지 횟수만 기억함으로써 메모리 공간도 절약할 수 있고, 처리속도도 향상시킬 수 있다. 해당 메모리 공간이 해제 되는 시점은 Shared_ptr 객체의 레퍼런스 카운트가 0이 되는 때 이다.

#include <memory> // shared_ptr
int main(int argc, char** argv) 
{
    std::shared_ptr<int> p1(new int(5)); // create, refcount = 1
    std::shared_ptr<int> p2 = p1; // refcount = 2

    p1.reset(); // refcount = 1
    p2.reset(); // refcount = 0, free
    return 0;
}
  • namespace와 필요헤더
    • namespace : std
    • header : < memory >
  • 선언
class Car {...};

// Resource Acquisition Is Initializing : RAII
std::shared_ptr<Car> Avante( new Car() );

// 즉, std::shared_ptr<_Ty> Object( new _Ty(construct) );의 형식을 띈다.
  • Reference Count의 증가와 감소
    • 증가 : shared_ptr 객체의 복사나 대입이 발생하여 참조 shared_ptr 객체 수 증가.
    • 감소 : shared_ptr이 가리키고 있는 객체를 참조하는 shared_ptr 객체 수의 감소.
  • 소멸시 주의 사항

    기본적으로, shared_ptr은 소멸시 참조 카운트가 0 이 되면, 참조하는 객체에 대해 delete 연산자를 사용한다. delete만 사용한다는 소리다. 즉, delete [] 따윈 사용해 주지 않는단 말이다.

따라서, 아래와 같이 하면 new-delete, new [] - delete []를 지키지 않았을 때의 문제가 그대로 나타나는 것이다.

std::shared_ptr<int> spi( new int[1024] );

즉, 아래와 같이 하라는 것이다.

std::vector< std::shared_ptr<int> > spVec;
spVec.push_back( std::shared_ptr<int>( new int(3) ) );

또한, 배열 삭제를 지원하는 deleter를 지정하여 해결할 수도 있다.

shared_ptr의 생성자 함수는 크게 다음 세 가지 형태로 정의되어 있다.

template<class _Ux>
explicit shared_ptr(_Ux *_Px)
{       // construct shared_ptr object that owns _Px
        _Resetp(_Px);
}

template<class _Ux, class _Dx>
shared_ptr(_Ux *_Px, _Dx _Dt)
{       // construct with _Px, deleter
        _Resetp(_Px, _Dt);
}

template<class _Ux, class _Dx, class _Alloc>
shared_ptr(_Ux *_Px, _Dx _Dt, _Alloc _Ax)
{       // construct with _Px, deleter, allocator
        _Resetp(_Px, _Dt, _Ax);
}

두 번째 생성자의 정의부터 보이는 class _Dx를 우리가 정의한 클래스로 지정시, 이는 shared_ptr의 참조 카운트가 0 이 될 때의 deleter 클래스가 된다.

// deleter 클래스 정의
template<typename T>
struct ArrayDeleter
{      
    void operator () (T* p)
    {
        delete [] p;
    }
};

// shared_ptr 생성시 두 번째 인자로 deleter class를 넘기면...
// 아무런 문제없이 객체 배열도 제대로 delete [] 처리가 된다.
std::shared_ptr<int> spi( new int[1024], ArrayDeleter<int>() );
  • 참조 객체 형변환
    shared_ptr 비멤버 함수를 통해 shared_ptr이 참조하고 있는 객체의 형변환을 수행할 수 있다.
template<class _Ty1, class _Ty2>
shared_ptr<_Ty1> static_pointer_cast(const shared_ptr<_Ty2>& _Other)
{      
    // return shared_ptr object holding static_cast<_Ty1 *>(_Other.get())
    return (shared_ptr<_Ty1>(_Other, _Static_tag()));
}

template<class _Ty1, class _Ty2>
shared_ptr<_Ty1> const_pointer_cast(const shared_ptr<_Ty2>& _Other)
{      
    // return shared_ptr object holding const_cast<_Ty1 *>(_Other.get())
    return (shared_ptr<_Ty1>(_Other, _Const_tag()));
}

template<class _Ty1, class _Ty2>
shared_ptr<_Ty1> dynamic_pointer_cast(const shared_ptr<_Ty2>& _Other)
{      
    // return shared_ptr object holding dynamic_cast<_Ty1 *>(_Other.get())
    return (shared_ptr<_Ty1>(_Other, _Dynamic_tag()));
}
class Car {...};
class Truck : public Car {...};

// Truck 타입의 객체를 Car 타입의 객체를 참조하는 shared_ptr에 초기화
shared_ptr<Car> pCar( new Truck() );

// shared_ptr<Car>가 참조하고 있던 객체를 Truck 타입으로 static_cast하여 대입.
// 대입 하였기에 참조 카운트는 '2'
shared_ptr<Truck> pTruck = static_pointer_cast<Truck>(pCar);

// 위처럼 대입하지 않고 스스로 형변환만 하여도 상관없음.
// 참조 카운트는 당연히 변화가 없다.
static_pointer_cast<Car>(pCar);
  • 참조 객체 접근

명시적 방법

* shared_ptr::get()
    : 참조하고 있는 객체의 주소를 반환한다.

암시적 방법

* shared_ptr::operator*
    : 참조하고 있는 객체 자체를 반환한다.
    : 즉, *(get())의 의미
* shared_ptr::operator->
    : get()->의 의미와 같다.
shared_ptr<Car> spCar( new Truck() );

// spCar가 참조하는 객체의 주소를 반환
Car* pCar = spCar.get();

// spCar가 참조하는 객체의 메써드에 접근 #1
spCar.get()->MemberFunc();

// spCar가 참조하는 객체의 메써드에 접근 #2
*(spCar).MemberFunc();

// spCar가 참조하는 객체의 메써드에 접근 #3
spCar->MemberFunc();
  • 순환참조

대부분 그룹객체 - 소속 객체간 상호참조에서 발생하며 소속객체가 그룹객체를 weak_ptr로 들고 있으면 해결된다. (아래 weak_ptr 참고)

  • 멀티쓰레드 안정성

안전하지 않다. thread_safety는 동시에 읽을 경우에만 안전하고, 쓰기를 할 경우에는 안전하지 않다.

weak_ptr

앞에서 shared_ptr은 포인터가 가리키는 실제 메모리가 몇 번이나 복사되어 사용되는지 내부적으로 추적하기 위해 레퍼런스 카운팅 방식을 이용한다. 하지만, 이 레퍼런스 카운팅 방식의 잠재적인 위험 가운데 하나로 서로를 참조하는 순환참조 문제가 있다. A는 B를 가리키고, B는 다시 A를 가리키는 상황이 바로 순환참조 상황이다. 이런 상황에서는 순환참조에 참여하는 모든 인스턴스가 삭제될 수 없으며, 이는 곧장 메모리 누수로 이어진다. 이런 문제를 해결하는 포인터 타입이 weak_ptr 이다.

shared_ptr에서는 메모리를 참조하는 shared_ptr이 자신을 제외하고 하나라도 남아 있으면, 아무리 삭제 명령을 내려도 해당 메모리가 삭제되지 않는다. 그러나 해당 메모리를 가리키는 포인터 타입이 shared_ptr이 아닌, weak_ptr 이라면 해당 메모리는 삭제될수 있다. weak_ptr이 가리키는 메모리 공간은 shared_ptr이 메모리를 관리하려고 사용하는 레퍼런스 카운트에 포함되지 않기 때문디다. 즉, 순환참조가 일어날 수 없다.

사실 weak_ptr이 shared_ptr을 참조할 때 shared_ptr의 weak reference count는 증가시킨다. 객체의 생명 주기에 관여하는 strong reference count를 올리지 않는 것 뿐이다. (shared_ptr, weak_ptr 객체를 디버거로 살펴보면 strong/weak refCount가 따로 표시된다)

weak_ptr은 특정 메모리 번지를 참조하는 shared_ptr이 아직 존재하는지 여부를 확인해볼 때 사용 할 수 있다. weak_ptr이 shared_ptr을 가리키고 있을 때 shared_ptr이 해제되면 weak_ptr도 해제된다.

#include <memory> // shared_ptr
#include <iostream>

int main(int argc, char** argv) 
{
    std::shared_ptr<int> sp1(new int(5)); // create, ref = 1
    std::weak_ptr<int> wp1 = sp1;   // ref = 1
    {
        // sp1은 wp1으로 sp2를 복사
        std::shared_ptr<int> sp2 = wp1.lock();  // ref = 2

        if(sp2) {
            std::cout<<"sp2 has copy of sp1"<<std::endl; // ❹
        }
    } // sp2는 이곳에서 자동으로 파괴됨, ref = 1

    sp1.reset(); // sp1은 이곳에서 파괴됨, ref = 0, free
    std::shared_ptr<int> sp3 = wp1.lock(); // null
    if(sp3) {
        std::cout<<"it's impossible to be here"<<std::endl; // can't display
    }
    return 0;
}

weak_ptr은 shared_ptr의 참조자라고 표현하는 것이 맞을 듯 하다. 같은 weak_ptr 또는 shared_ptr로부터만 복사 생성/대입 연산이 가능하며, shared_ptr로만 convert가 가능하다.

따라서, weak_ptr<_Ty>는 _Ty 포인터에 대해 직접 access가 불가능하며, (shared_ptr의 get() 메쏘드 같은 녀석이 아예 없다) _Ty 포인터에 엑세스를 원하면 lock 메써드를 통해 shared_ptr로 convert 한 뒤, shared_ptr의 get 메쏘드를 사용해야 한다.

그리고 expired 함수를 통해 자신이 참조하고 있는 shared_ptr의 상태(즉, weak_ptr의 상태)를 체크할 수 있다.

shared_ptr<_Ty> lock() const
{      
    // convert to shared_ptr
    return (shared_ptr<_Elem>(*this, false));
}

bool expired() const
{      
    // return true if resource no longer exists
    return (this->_Expired());
}

순환참조 예제

  • 순환참조의 대부분은 그룹객체 - 소속객체간 상호참조에서 발생한다.
#include <memory>    // for shared_ptr
#include <vector>

using namespace std;

class User;
typedef shared_ptr<User> UserPtr;

class Party
{
public:
    Party() {}
    ~Party() { m_MemberList.clear(); }

public:
    void AddMember(const UserPtr& member)
    {
        m_MemberList.push_back(member);
    }

    void RemoveMember()
    {
        // 제거 코드
    }

private:
    typedef vector<UserPtr> MemberList;
    MemberList m_MemberList;
};
typedef shared_ptr<Party> PartyPtr;
typedef weak_ptr<Party> PartyWeakPtr;

class User
{
public:
    void SetParty(const PartyPtr& party)
    {
        m_Party = party;
    }

    void LeaveParty()
    {
        if (m_Party)
        {
            // shared_ptr로 convert 한 뒤, 파티에서 제거
            // 만약, Party 클래스의 RemoveMember가 이 User에 대해 먼저 수행되었으면,
            // m_Party는 expired 상태
            PartyPtr partyPtr = m_Party.lock();
            if (partyPtr)
            {
                partyPtr->RemoveMember();
            }
        }  
    }

private:
    // PartyPtr m_Party;
    PartyWeakPtr m_Party;    // weak_ptr을 사용함으로써, 상호 참조 회피
};


int _tmain(int argc, _TCHAR* argv[])
{
    // strong refCount = 1;
    PartyPtr party(new Party);

    for (int i = 0; i < 5; i++)
    {
        // 이 UserPtr user는 이 스코프 안에서 소멸되지만,
        // 아래 party->AddMember로 인해 이 스코프가 종료되어도 user의 refCount = 1
        UserPtr user(new User);

        party->AddMember(user);

        // weak_ptr로 참조하기에 party의 strong refCount = 1
        user->SetParty(party);
    }
    // for 루프 이후 strong refCount = 1, weak refCount = 5

    // 여기에서 party.reset을 수행하면, strong refCount = 0
    // 즉, 파티가 소멸되고 그 과정에서 m_MemberList가 clear -> user들의 strong RefCount = 0 -> user 소멸
    // party와 5개의 user 모두 정상적으로 소멸
    party.reset();

    return 0;
}

정리하자면,
weak_ptr은 다음과 같은 경우에 사용하면 유용하다.

  • 어떠한 객체를 참조하되, 객체의 수명에 영향을 주고 싶지 않은 경우
  • 매번 특정 객체의 ID로 컬렉션에서 검색하고 싶지 않을 경우
  • 그러면서 dangling pointer의 잠재 위험성을 없애고 싶을 때