[C++] unique_ptr에 대한 설명과 사용법

unique_ptr 이란

unique_ptr은 할당된 메모리를 자동으로 관리할 목적으로 C++ 11에 도입된 유틸리티 클래스입니다.

 

참고로, 이와 비슷한 스마트 포인터( smart pointer ) 클래스로 auto_ptr이 있었습니다.

그러나, 이 클래스는 C++17에서 삭제되었습니다.

 

이 클래스를 사용하기 위해선 다음의 헤더 파일을 포함해야 합니다.

#include <memory>

 

다음은 unique_ptr의 기본 사용법입니다.

#include <iostream>
#include <memory>
using namespace std;

#define SIZE    100

// 데이터를 할당하는 클래스
class ResourceObj{
    int* m_pData;
public:
    ResourceObj(){
        m_pData = new int[SIZE];
        cout << "Resource Created\n";
    };
    ~ResourceObj(){
        delete [] m_pData;
        cout << "Resource Destory\n";
    };
};

int main(){
    // unique_ptr의 기본 사용법
    unique_ptr<ResourceObj> pRes { new ResourceObj() };

    return 0;
}

unique_ptr 객체가 하는 일은 메모리에 생성한 ResourceObj 객체의 주소를 보관합니다.

그리고, unique_ptr 객체가 파괴될 때, 보관하고 있는 메모리 객체를 자동으로 반환하는 것입니다.

 

또한, unique_ptr을 통해 배열을 관리할 수도 있습니다.

#define SIZE 10

int main(){

    unique_ptr<int[]> ptr{ new int[SIZE] };	// 배열 객체 관리
    
    for( int i = 0; i < SIZE; i++){
        ptr[i] = 3;
    }
    
    return 0;
}

 

이러한 스마트 포인터를 사용하는 주된 이유가 무엇일까요?

우선, 할당된 메모리를 크게 신경 쓰지 않고도 반환할 수 있습니다.

그리고, 아래에 나오는 메모리에 관한 문제를 해결할 수 있기 때문입니다.

 

할당된 메모리에 관한 이슈는 크게,

메모리 누수( memory leak )삭제된 메모리를 다시 삭제하는 문제( double free )로 나눠 볼 수 있습니다.

이러한 문제들을 unique_ptr을 사용함으로써 어떻게 처리하는지를 살펴보겠습니다.

 

메모리 누수( memory leak )

다음 코드는 일반적인 메모리 사용법입니다.

void DoSomething(){
    
    ResourceObj* pRes = new ResourceObj();
        
    delete pRes;	// 메모리 해제
}

int main(){
    
    DoSomething();
    
    return 0;
}

▼출력

Resource Created
Resource Destory

위 코드를 실행하면 ResourceObj 객체가 할당한 메모리가 제대로 반환되는 것을 볼 수 있습니다.

그러나, 다음과 같은 경우는 메모리가 제대로 반환되지 않습니다.

void DoSomething(){
    
    ResourceObj* pRes = new ResourceObj();

    throw(1);   // Exception    
    
    delete pRes;	// 이 경우 실행되지 않음
}

int main(){
    
    try{
        // Do something and Throw exception
        DoSomething();
    }
    catch(int e){
        cout << "Exception occured\n";
    }

    return 0;
}

▼출력

Resource Created
Exception occured

DoSomething 함수에서 예외가 발생한 경우입니다.

이 경우, 실행 제어는 예외가 발생한 직후, 함수를 벗어나 main함수의 예외 처리를 수행합니다.

따라서, 할당한 ResourceObj의 메모리는 누수됩니다.

 

하지만, 이런 경우에 unique_ptr를 사용하면 어떻게 될까요?

void DoSomething(){
    
    unique_ptr<ResourceObj> pRes { new ResourceObj() };

    throw(1);   // Exception    
}

int main(){
    
    try{
        // Do something and Throw exception
        DoSomething();
    }
    catch(int e){
        cout << "Exception occured\n";
    }

    return 0;
}

▼출력

Resource Created
Resource Destory
Exception occured

이 경우는 unique_ptr 객체가 먼저 파괴되고, 그다음 예외 처리가 실행됩니다.

따라서, 할당된 ResourceObj의 메모리도 같이 반환됩니다.

 

이렇게 되는 이유는,

함수가 호출될 때, 호출한 함수의 호출 주소 및 로컬 변수 등의 정보가 스택에 저장됩니다.

이것을 스택 와인딩( stack winding )이라고 합니다.

그런데, 만일 예외 발생 시 그냥 함수를 벗어나면, 이 스택에 들어있는 정보들이 쌓이게 되는 문제가 발생합니다.

그래서, 함수를 벗어나기 전에 스택을 비우게 되는데, 이 과정을 스택 언와인딩( stack unwinding )이라고 합니다.

이 과정에서 지역 변수인 unique_ptr이 파괴되는 것입니다.

 

이렇게 해서, 메모리 누수 문제는 해결됩니다.

 

삭제된 메모리 삭제( double free )

이 문제는 먼저 변수의 소유권의 개념을 이해해야 합니다.

변수의 소유권이란, 변수가 생성되고 파괴될 때를 결정하는 권한을 말합니다.

 

예를 들면, 함수의 지역 변수는 함수가 시작될 때 생성되고, 함수가 파괴될 때 같이 파괴됩니다. 

따라서, 함수가 지역 변수의 소유권을 갖고 있습니다.

또, if 구문의 { } 안의 변수의 소유권은 if 코드 블록이 갖고 있습니다.

그리고, 위의 ResourceObj의 m_pData의 소유권은 ResourceObj 객체가 갖고 있습니다.

 

마찬가지로, 아래 unique_ptr 은 할당된 ResourceObj 객체의 소유권을 갖고 있습니다.

unique_ptr<ResourceObj> pRes { new ResourceObj() };

 

그런데, 이 소유권을 복사할 수 있다면 어떻게 될까요?

unique_ptr<ResourceObj> pRes { new ResourceObj() };

unique_ptr<ResourceObj> pCopy = pRes;   // unique_ptr 복사?

그러면, pCopy가 보관하고 있는 ResourceObj가 삭제되고, pRes가 보관하고 있는 같은 ResourceObj를 다시 삭제하려고 시도할 것입니다. double-free죠.

그렇지만, 이러한 일은 발생하지 않습니다.

 

unique_prt 클래스는 복사 생성자( copy construct )와 복사 대입 연산자( copy assignment )를 삭제했기 때문입니다.

만약, 복사 생성자를 호출하면 이런 메시지를 만나게 될 것입니다.

unique_ptr(const unique_ptr&) = delete;

위의 delete는 C++11에 도입된 기능으로, 명시적으로 생성자를 없앤다는 뜻입니다.

복사 생성자와 복사 대입 연산자는 개발자가 구현하지 않으면 컴파일러가 자동으로 만들기 때문에,

소유권을 복사하지 못하도록 명시적으로 삭제하는 과정이 필요합니다.

 

이 과정을 통해, unique_ptr은 소유권을 유일하게 갖게 되고, 이름조차 그렇게 명시하고 있습니다.

 

이렇게 해서, 삭제된 메모리를 다시 삭제하는 문제도 해결했습니다.

 

참고로, 소유권의 복사는 되지 않지만, 양도는 가능합니다.

그렇지만, 양도한 객체는 당연히 소유권을 잃어버리게 됩니다.

int main(){
    
    unique_ptr<ResourceObj> pRes { new ResourceObj() };

    unique_ptr<ResourceObj> pReceiver = move(pRes);	// 소유권 양도

    if ( pRes){
        cout << "pRes has previlege\n";
    }

    if ( pReceiver){
        cout << "pReceiver has previlege\n";
    }
    
    return 0;
}

▼출력

Resource Created
pReceiver has previlege
Resource Destory

 

unique_ptr에 의해 관리되는 자원에 접근

unique_ptr에 의해 관리되는 자원에 접근하기 위해 연산자들을 사용할 수 있습니다.

-> 연산자는 자원의 포인터를 반환하고, * 연산자는 자원의 참조를 반환합니다.

 

또한, bool 연산자는 관리하는 자원이 있는 경우 true를, 관리하는 데이터가 없는 경우 false를 반환합니다.

 

다음 예제를 통해, 관리하는 자원에 접근하는 방법을 볼 수 있습니다.

#define SIZE    100

class ResourceObj{
    int* m_pData;
public:
    ResourceObj(){
        m_pData = new int[SIZE];
        cout << "Resource Created\n";
    };
    ~ResourceObj(){
        delete [] m_pData;
        cout << "Resource Destory\n";
    };

    bool SetData(int idx, int val){	// 데이터의 값 변경
        if ( m_pData == nullptr || idx >= SIZE ) return false;
        m_pData[idx] = val;
        return false;
    }
};

int main(){
    
    unique_ptr<ResourceObj> pRes { new ResourceObj() };

    if ( pRes){	// 관리하고 있는 데이터가 있으면 true
    
        pRes->SetData( 10, 3);	// -> 연산자를 통해 관리하는 데이터에 접근
    }
    
    return 0;
}

 

std::make_unique 함수

make_unique 함수는 C++14에 도입된, unique_prt 객체를 사용하기 쉽도록 만들어 주는 함수입니다.

이 함수는 객체를 할당할 뿐만 아니라, 객체를 구성하기 위한 매개 변수 생성자를 통해 객체를 초기화할 수도 있습니다.

 

사용법을 설명하기 위해서, 위에서 사용한 ResourceObj 클래스를 조금 수정했습니다.

ResourceObj가 가지고 있는 데이터의 크기와 값을 설정하는 기능이 추가되었습니다.

#define SIZE    100

class ResourceObj{
    int* m_pData;
    size_t m_len;
public:
    ResourceObj(){
        m_pData = new int[SIZE];
        m_len = SIZE;
        cout << "Resource Created\n";
    };
    ResourceObj(size_t len, int val){   // ResourceObj의 데이터 크기와 값을 설정
        if ( len == 0) len = SIZE;
        m_len = len;
        m_pData = new int[m_len];
        for( size_t i = 0; i < m_len; i++)
            m_pData[i] = val;
        cout << "Resource Created\n";
    };
    ~ResourceObj(){
        delete [] m_pData;
        cout << "Resource Destory\n";
    };

    bool SetData(int idx, int val){
        if ( m_pData == nullptr || idx >= m_len ) return false;
        m_pData[idx] = val;
        return false;
    }

    bool GetData(int idx, int& val){    // 데이터의 값을 반환
        if ( m_pData == nullptr || idx >= m_len ) return false;
        val = m_pData[idx];
        return true;
    }
};

 

위의 클래스 객체를 관리하는 unique_ptr 객체는 다음과 같이 생성할 수 있습니다.

int main(){
        
    //unique_ptr<ResourceObj> pRes { new ResourceObj(50, 17) }; 대신
    
    // make_unique 사용 가능
    unique_ptr<ResourceObj> pRes = make_unique<ResourceObj>(50, 17);
    
    int val = 0;
    if (pRes->GetData( 3, val))
        cout << "GetData: " << val << endl;
    
    return 0;
}

 

이 방식은 객체를 직접 할당하지 않기 때문에, 다음과 같은 실수를 막을 수 있습니다.

int main(){
    
    ResourceObj* pData = new ResourceObj(50, 17);
    unique_ptr<ResourceObj> pRes1(pData);
    unique_ptr<ResourceObj> pRes2(pData);   // 컴파일러에서 오류를 잡아내지 못함
    
    // Do Something
    // ...

    delete pData;	// 할당된 데이터를 직접 삭제. 
    
    return 0;
}

위의 두 가지 실수는 컴파일러에서 경고를 해주지 못합니다.

그러나, make_unique 함수를 사용하면 이런 종류의 오류는 발생할 수 없습니다.

 

그리고, 객체의 배열을 관리하는 unique_ptr을 만들 수도 있습니다.

int main(){

    auto ptr = make_unique<ResourceObj[]>(5);	// 크기가 5인 배열 할당

    for( int i = 0; i < 5; i++){
        ptr[i].SetData(i, 20);
    }

    return 0;
}

그렇지만, 이때는 각각의 객체를 초기화할 수는 없습니다.

 

STL 컨테이너에 unique_ptr 저장

위에서 설명했듯이, unique_ptr은 복사 생성자와 복사 대입 연산자를 삭제한 클래스입니다.

그렇기 때문에, 복사 생성자나 복사 대입 연산자를 호출하는 STL 컨테이너 함수를 사용해서 unique_ptr를 저장할 수 없습니다.

 

따라서, move 함수를 사용하거나, make_unique 함수를 사용하여 이동 연산을 해야 합니다.

int main(){

    vector< unique_ptr<ResourceObj> > vec;
    
    // unique 객체를 rvalue로 저장
    vec.push_back( unique_ptr<ResourceObj>(new ResourceObj(30, 7)) );

    // move 함수를 이용한 rvalue로 저장
    unique_ptr<ResourceObj> ptr{ new ResourceObj(20, 4)};
    vec.push_back( move(ptr) );

    // make_unique 함수를 이용한 rvalue로 저장
    vec.push_back( make_unique<ResourceObj>(50, 5) );

    for( const auto& x : vec){	// 벡터에 있는 unique_ptr 순환
        int val;
        if (x->GetData( 3, val)){
            cout << val << " ";
        }
    }
    cout << endl;
    
    return 0;
}

▼ 출력

Resource Created
Resource Created
Resource Created
7 4 5
Resource Destory
Resource Destory
Resource Destory

위의 세 가지 방법 모두 사용이 가능하며,

두 번째의 ptr 객체는 데이터를 이동했으므로, 재사용하면 안 된다는 것에 주의해야 합니다.

 

참고로, emplace 함수를 사용하여, 내부에서 직접 unique_ptr 객체를 생성할 수도 있습니다.

emplace 함수에 관한 내용은 여기에서 볼 수 있습니다.

 

[C++] STL emplace 함수 설명 및 사용법

emplace 함수std::emplace 함수는 STL 컨테이너( vector, list, set, map, deque 등 )에서 사용가능한, 새로운 원소를 삽입하는 함수입니다. 이와 비슷한 함수로  vector의 emplace_back, list의 emplace_front, emplace_back, m

codingembers.tistory.com

 

emplace_back 함수를 사용하면 다음과 같이 생성할 수 있습니다.

int main(){

    vector< unique_ptr<ResourceObj> > vec;
    
    // emplace를 이용한 내부 객체 생성
    vec.emplace_back( new ResourceObj(40, 11) );

    for( const auto& x : vec){
        int val;
        if (x->GetData( 3, val)){
            cout << val << " ";
        }
    }
    cout << endl;
    
    return 0;
}

▼ 출력

Resource Created
11
Resource Destory

 

 

  • 네이버 블로그 공유
  • 네이버 밴드 공유
  • 페이스북 공유
  • 카카오스토리 공유