궁금한게 많은 개발자 노트

[ C++ ] 스마트 포인터 - auto_ptr, unique_ptr, shared_ptr, weak_ptr 본문

Language

[ C++ ] 스마트 포인터 - auto_ptr, unique_ptr, shared_ptr, weak_ptr

궁금한게 많은 개발자 2020. 6. 16. 11:21

C++에서의 메모리 관리를 할 때 빼놓고 말할 수 없는 스마트포인터에 대해 알아보려한다!
Java의 경우에는 GC(Garbage collector)를 통해 손쉽게 메모리관리를 해주는데 반해,
C++에서는 스스로 new로 할당한 메모리에 대한 관리를 delete로 해제를 시켜줘야 올바른 메모리관리를 할 수 있다.

사용자가 직접 코드를 작성하기 때문에 메모리누수, 즉 할당한 메모리를 제대로 해제해주지 않는 경우가 빈번하게 발생한다.
이때, GC의 도움을 받는 것처럼, 스마트포인터를 사용하면 메모리를 자동으로 해제시켜 준다.

스마트 포인터는 <memory> header에 정의되어 있다. [ http://www.cplusplus.com/reference/memory/ ]

 

- C++ Reference

 

www.cplusplus.com

스마트 포인터 종류로는 auto_ptr, unique_ptr, shard_ptr, weak_ptr이 존재하고, auto_ptr은 C++17부터제거 되었다고 한다.

 

① auto_ptr
스마트 포인터의 시초라고 할 수 있으며,
기본적으로 동적으로 할당된 객체를 자동 소멸시켜 메모리 누수에 대한 걱정을 덜어주는 기능 수행한다.
하지만, 유일 소유권의 개념을 가지고 있어 복사, 대입 연산을 수행하는 경우 원래의 auto_ptr은 바로 nullptr을 가리키게 된다.

class AAA;

std::auto_ptr<AAA> AAAObject(new AAA());

// 복사와 대입의 연산이 일어나는 순간 nullptr대입
std::auto_ptr<AAA> BBBObject(AAAObject);	// AAAObject = nullptr;
AAAObject = BBBObject;				// BBBObject = nullptr;

이러한 불편한점 뿐만 아니라 배열을 사용하는 객체에 대한 동적할당에서 사용할 수 없다.

~auto_ptr() {
    delete _ptr;
}

이유는 auto_ptr의 소멸동작 방식에서 배열내부의 각 객체에 대한 delete를 수행하지 않고,
배열0번 객체의 주소값, 즉 배열이름에 대한 객체의 해제만 수행하는 치명적인 단점을 가지고 있어서 제거된것 같다.

 

② unique_ptr
auto_ptr의 위와 같은 문제점에 의해 unique_ptr이 생겨나게 되었을 것 같다. 위의 두가지 문제점 중에
배열에 대한 객체생성을 못하는 부분을 해결해주는게 unit_ptr이다. auto_ptr과 동일하게 유일소유권의 특징은 가지고 있다.

유일 소유권의 특징은 동일하게 가지고 있지만, 대입으로 인한 소유권 이전은 금지되어 있다.
move()함수를 통해 새로운 unique_ptr에게 소유권을 이전시키는 방법을 사용하거나, 
reset()함수를 통해 이전 소유권을 소멸시키고, 새로운 소유권을 생성시켜주는 방식을 이용해야 한다.

// auto_ptr은 p2에게 p의 소유권을 대입을 통해 넘겨줄수 있지만
std::auto_ptr<int> p(new int);
std::auto_ptr<int> p2 = p; 

// unipue_ptr에게 대입을 통해 소유권을 전달한다면 Error발생
// move함수를 통하여 소유권을 이전하거나 reset으로 기존 소유권 소멸 후, 새로운 소유권을 생성
std::unique_ptr<int> p(new int);
std::unique_ptr<int> p2 = std::move(p);

p2.reset(new int);

 

 

③ shared_ptr
auto_ptr과 unique_ptr의 단점을 해결하기 위해 나온 즉, 유일 소유권의 개념을 극복한 소유권을 공유할 수 있는 pointer
reference count라는 개념을 사용하여 객체를 참조하고 있는 smart pointer의 개수를 세고 있으며,
참조 횟수는 특정 객체에 새로운 shared_ptr이 추가될 때마다 1씩 증가하며, 수명이 다할 때마다 1씩 감소합니다.
따라서 마지막 shared_ptr의 수명이 다하여, 참조 횟수가 0이 되면 delete 키워드를 사용하여 메모리를 자동으로 해제합니다.

int main()
{
        // 최초 생성시 초기 참조 카운트는 당연히 '1'
        std::shared_ptr<Car> Car1( new Car() );
        // 같은 표현으로 : std::shared_ptr<Car> Car1 = std::make_shared<Car>();
        
        // 복사 -> 참조 카운트 '2'
        std::shared_ptr<Car> Car2(Car1);
        // 대입 -> 참조 카운트 '3'
        std::shared_ptr<Car> Car3 = Car1;
 
        // function( std::shared_ptr<Car> _car ), 값에 의한 전달, 복사에 의한 임시객체 생성
        // 이로 인한 참조 카운트 증가 -> '4'
        function( Car3 );
        // 함수 호출 후엔 임시객체 소멸되므로 참조 카운트 감소 -> '3'
        // std::cout << Car1.use_count()을 사용하여 reference count확인 가능
 
        // reset 함수는 shared_ptr이 참조하는 객체를 새로운 녀석으로 바꿀 수 있는 함수이다.
        // 내부적으로 shared_ptr::swap 함수가 사용됨
        // http://msdn.microsoft.com/ko-kr/library/bb982757.aspx
        // 인자를 주지 않으면 참조 포기가 되는 것이다. 따라서 참조 카운트 감소 -> '2'
        Car3.reset();
        ...
        return 0;
        // 함수 반환시 남아있던 shared_ptr 모두 소멸 -> 참조 카운트 '0'
        // 이제 shared_ptr이 참조하고 있던 Car * 에 대해 delete가 호출됨.
}

또 하나의 예시로 reference count가 각각 1인 shared_ptr변수가 다른 객체를 참조하게 되면 0으로 reference count가 줄게 되면서 기존 객체는 delete가 호출되어 사라지고, 다른 객체의 참조수가 증가하게 된다.

class Car {...};
 
// 최초 생성시 초기 참조 카운트는 당연히 '1'
std::shared_ptr<Car> Car1( new Car() );
// 최초 생성시 초기 참조 카운트는 당연히 '1'
std::shared_ptr<Car> Car2( new Car() );
 
// Car1 shared_ptr은 이제 Car2의 객체를 참조한다.
// Car1이 참조하던 Car* 는 더 이상 참조자가 존재하지 않아, delete가 호출된다.
// 대신 Car2가 참조하던 객체를 이제 Car1 shared_ptr도 참조하므로 참조 카운트는 '2'
Car1 = Car2;

주의해야할 점은 delete키워드만 사용한다는 점입니다 (delete [] 키워드 사용X -> 배열 참조 해제에는 문제)
std::shared_ptr<int> spi( new int[1024] ); 처럼 생성한 다음 reference count가 0이 되면 배열의 첫번째 인자의 주소값에 해당되는 메모리만 사라지게 된다

이러한 경우에는 해결책으로 shared_ptr 생성시 두번째 인자로 deleter함수를 넣어주면 넘겨준 함수에 맞게 메모리가 해제되어 위와같은 경우의 문제점을 해결할 수 있다. 
함수가 호출되는 시점은 마찬가지로 reference count가 0이 될때 호출된다.

// 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>() );

 

 

④ weak_ptr
 위와 같이 배열에 대한 포인터 해제 문제뿐만 아니라 circular references에 대한 문제가 존재한다.
즉 shared_ptr이 서로에 대한 참조를 하고 있다면 각각의 pointer는 reference count가 영원히 0이 될 수 없는 dead lock상황이 발생하게 된다. 다음 예제를 통해 알아보자

#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);
    }
 
private:
    typedef vector<UserPtr> MemberList;
    MemberList m_MemberList;
};
typedef shared_ptr<Party> PartyPtr;
 
class User
{
public:
    void SetParty(const PartyPtr& party)
    {
        m_Party = party;
    }
 
private:
    PartyPtr m_Party;
};
 
 
int _tmain(int argc, _TCHAR* argv[])
{
    PartyPtr party(new Party);
 
    for (int i = 0; i < 5; i++)
    {
        // 이 user는 이 스코프 안에서 소멸되지만,
        // 아래 party->AddMember로 인해 이 스코프가 종료되어도 user의 refCount = 1
        UserPtr user(new User);
 
        // 아래 과정에서 순환 참조가 발생한다.
        party->AddMember(user);
        user->SetParty(party);
    }
 
    // 여기에서 party.reset을 수행해 보지만,
    // 5명의 파티원이 party 객체를 물고 있어 아직 refCount = 5 의 상태
    // 따라서, party가 소멸되지 못하고, party의 vector에 저장된 user 객체들 역시 소멸되지 못한다.
    party.reset();
 
    return 0;
}

위와 같은 형태로 shared_ptr이 서로를 참조하고 있는 것은 circular reference라고 한다.
위 예제처럼, 그룹 객체 - 소속 객체간 상호 참조는 실무에서도 흔히 볼 수 있는 패턴이며, 

보통은 위 예제처럼 직접 참조 형식이 아니라, User는 PartyID를 들고 있고, Party 객체에 접근 필요시 PartyManger(컬렉션)에 질의해서 유효한 Party 객체 포인터를 얻어오는 방식을 사용한다.

그렇다고, PartyManager에 일일히 ID로 검색하는 비용을 줄이고자, Party 포인터를 직접 들고 있으면, 들고 있던 포인터가 dangling pointer가 될 수 있는 위험이 있다. 이럴 때, User 객체가 Party 객체를 shared_ptr가 아닌 weak_ptr을 사용하여 들고 있다면, 검색 비용 회피와 dangling pointer의 위험에서 모두 벗어날 수 있다.

std::weak_ptr은 shared_ptr로부터 생성될 수 있고, shared_ptr이 가리키고 있는 객체를 똑같이 참조하지만, 참조만 할 뿐 reference counting은 하지 않아 위 예제의 목적에 가장 바람직한 대안이 될 수 있다.

Comments