본문 바로가기
C,C++/C++

[C++] 스마트 포인터에 대해 알아보자(1) | 유니크 포인터 | std::unique_ptr

by woohyeon 2020. 3. 19.
반응형

C++ 11에 특별한 포인터 클래스가 포함되었다. 스마트 포인터라고 불리며 포인터 사용 시 사용자의 실수에 의한 메모리 누수(memory leak)를  방지하고 안전한 사용을 위해 나온 3가지 클래스가 등장하였다.  

  • std::unique_ptr 
  • std::shared_ptr
  • std::weak_ptr

해당 포스팅에선 유니크 포인터 먼저 다루기로 한다. 혹시 RAII 또는 스택의 개념에 대해 모른다면 다음 포스팅을 한번 보고 오는걸 추천한다.
https://woo-dev.tistory.com/89?category=882879

 

[C++] 스택과 힙 메모리, "RAII"라는 패턴 및 기법에 대해 알아보자

RAII가 C++ 11의 스마트 포인터와 연관이 있어 해당 카테고리에 작성한다. 먼저 지역 변수와 매개변수가 저장되는 스택 메모리에 대해 알아보자. 스택 메모리 (Stack memory) 스택은 지역 변수와 매개변수가 저장..

woo-dev.tistory.com



[배경]

포인터가 강력하지만 위험한 이유는 내 소유가 아닌 메모리를 참조할 수 있다는 점도 있지만, 메모리 관리 측면으로도 볼 수 있다. 동적으로 할당한 메모리를 다 사용한 후엔 해제를 해야하는데, 몰라서 안하는 것이 아니라 사용자의 실수 또는 잘못된 설계에 의해 해제가 되지 않는 경우가 많다. 즉, 필요는 없는데 점유되고 있는 메모리로 인해 메모리 부족 현상이 나는 경우를 말한다. 이는 메모리를 할당을 한 사람이 책임지고 사용이 끝날 때 지워주면 문제가 되지 않는다. 하지만 포인터가 이리저리로 복사되고 이동되고 넘어가게 되면 소유자가 여럿이 생기고 메모리 누수가 발생하기 딱 좋은 상황이 생길 수도 있다. 이렇게 사람은 항상 실수할 여지가 있고, 안전하게 사용하는 것을 보장해주지 못한다. 

스마트 포인터는 이러한 이유에서 포인터 사용 시 실수 방지와 안전을 위해 등장한 개념이다. 일단 스마트 포인터를 이해하기 위해선 RAII라는 개념을 알아야 하고 RAII를 이해하기 위해선 stack의 특성을 알아야 한다. stack은 지역 변수 또는 매개 변수가 저장되는 메모리 공간이다. 스택 메모리에 저장되는 지역 객체같은 메모리들은 자신이 속한 scope를 벗어나면 자동으로 사라진다는 특성이 있다. 이는 사용자가 신경쓰지 않아도 메모리 누수가 발생하지 않는다는 뜻이다.  

이 특성을 힙 메모리에 적용한 것이 RAII라는 개념이다. 스택 메모리는 scope를 벗어나면 자동으로 사라지지만 힙 메모리는 그렇지 않다. RAII는 Resource Acquisition Is Initialization라는 관용어인데, 이는 "자원 획득은 초기화" 라는 의미이다. 자원은 통상 모든 메모리를 뜻하지만 여기선 힙 메모리라고 생각해도 된다. 즉  "메모리는 초기화 시점에 할당이 완료되어야 한다" 이는 한 줄의 코드에서 객체가 생성되고 메모리가 할당되어 사용할 준비가 완료되도록하라는 말이다. 이는 또한 다음 의미로 해석되기도 한다. "객체의 소멸은 자원의 반납이다"  즉 객체 생성 시 할당한 메모리를 객체 소멸 시 반납하라는 말이다. (참고)

클래스를 생각해보면 생성자와 소멸자라는 특별한 함수가 있고, 객체가 소멸되면 소멸자가 불린다. RAII는 스택 메모리의 동작 방식을 클래스에 적용시킨다. 생성자에서 메모리를 할당하면 소멸자에서 메모리를 해제한다. 그리고 해당 클래스 객체를 스택 메모리에 생성하면 객체가 scope를 벗어날 때 자동으로 소멸자를 호출하며 힙 메모리가 해제되도록 하는 것이 원칙이다.


[스마트 포인터]
스마트 포인터는 RAII 원칙이 적용된 좋은 예이다. 스마트 포인터는 포인터를 사용하되, 사용자가 메모리 해제에 대해 신경쓰지 않아도  소멸자가 불리면서 점유하고 있는 메모리를 해제해준다. 물론 이는 내부적으로 생성자에서 받은 포인터를 소멸자에서 delete 함으로써 이루어지는 동작일 것이다.


[unique_ptr]
스마트 포인터 중 가장 제한이 엄격한 것이 바로 유니크 포인터이다. 유니크 포인터는 다음과 같은 특징을 가진다. 원시 포인터에 대한 설명은 아래 초록색으로 표시해 놓았다.

  • 원시 포인터(소유 중인 포인터)의 소유자는 한 명이다.
  • 원시 포인터에 대한 소유권을 이전(move)할 순 있지만 복사(copy)나 대입(assign)과 같은 공유(share)를 불허한다.
  • 유니크 포인터 객체가 소멸될 때 원시 포인터도 소멸된다.


그리고 위 특징을 설명하기 전에 유니크 포인터 객체를 생성하는 예를 먼저 보자.
스마트 포인터에 관한 클래스는 <memory>에 정의 되어 있다.

#include <memory>

void UniquePointerDemo()
{
  std::unique_ptr<int> pInteger(new int(3));                 // (1) new 연산자 사용
  std::unique_ptr<int> pInteger2 = std::make_unique<int>(3); // (2) 함수 사용
}


스마트 포인터 객체 생성 시 인자로 포인터를 전달하면 인자로 들어온 포인터를 통해 실제 데이터에 접근하게 된다. 여기서 pInteger가 소유하게 되는 기존 C 스타일의 포인터를 원시(raw) 포인터라고 한다. 원시 포인터의 소유자는 pInteger에게 귀속되며 다른 유니크 포인터로의 복사 또는 대입과 같은 공유가 불가능하다. 따라서 다른 함수로의 값으로 전달하는 것도 불가능하다. 즉 유니크 포인터는 자원을 공유함으로써 발생하는 문제점을 막기 위해 소유자를 한명으로 제한한다. 하지만 공유가 아닌 원시 포인터에 대한 소유권을 포기하고 다른 유니크 포인터에게 넘기는 것은 가능하다. 아래 코드를 보면 복사 생성자와 대입 연산자가 delete 되어 있는 것을 볼 수 있다.



그리고 pInteger가 속한 scope를 벗어나면 내부적으로 자동으로 소멸자가 불리며 원시 포인터의 메모리를 해제한다. 따라서 new를 통해 힙 메모리를 할당했지만 delete 하지 않아도 pInteger가 소멸될 때 자동으로 해제가 된다.


[C++ 11 스타일의 unique_ptr 생성 방식]
위에서 유니크 포인터를 생성할 때 두가지 방법을 사용하였는데, new 연산자를 생성자 인자로 전달하는 (1)은 C++ 11의 초기 방식이다. 초기 방식은 아이러니하게도 원시 포인터의 공유가 가능했다. (1)은 다음과 같이 지역 변수의 주소를 받거나 new로 만들어져 있던 포인터를 받을 수가 있었다.

#include <memory>

void UniquePointerDemo()
{
   // C++ 11 unique_ptr 생성 방식
   int* ptr = new int(3);
   std::unique_ptr pInt1(ptr);
   std::unique_ptr pInt2(ptr);
}


new를 통해 미리 할당된 포인터를 두 개의 유니크 포인터로 만드는 것이 가능했다. 이는 유니크 포인터의 첫번째 두번째 원칙에 위배된다. 또한 이로 인해 세번째 특징 관련해서도 문제가 생긴다. pInt1이 함수 scope를 벗어나면서 자신이 소유한 원시 포인터의 메모리를 해제하는데, pInt2도 scope를 벗어나면서 자신이 소유한 원시 포인터의 메모리를 해제하려고 할 것이다. 하지만 pInt2가 해제하려는 메모리는 이미 해제된 메모리고 해제된 메모리를 또 해제하려고 하면 당연히 crash가 발생한다.



[C++ 14 스타일의 unique_ptr 생성 방식]
그래서 C++ 14에서 std::make_unique<T>라는 함수가 등장하였으며 유니크 포인터를 원칙에 위배되지 않게 생성하도록 도와준다.
이는 생성할 인스턴스에 대한 생성자라고 생각하면 된다. 실제로 make_unique는 자료형에 맞는 new 연산을 호출한다.

std::make_unique<>



아래는 Foo 클래스 타입에 대한 유니크 포인터를 만드는 예제이다.

class Foo
{
public:
   Foo(const char* name, unsigned int age) {}
   ...
};

void MakeUniqueExample()
{
   // std::make_unique 함수를 통한 unique_ptr 생성 
   std::unique_ptr<Foo> f = std::make_unique<Foo>("foo", 14);
}

 


포인터 선언 시 사용하는 *(star) 기호가 붙진 않지만 포인터로 취급한다. 내부적으로 -> 연산자와 역참조 연산자 *가 오버로딩되어 있기 때문에 -> 연산자를 통한 Foo 클래스의 멤버 변수 또는 함수에 접근이 가능하다. 물론 연산자 오버로딩을 덕분에 원시 포인터의 멤버에 접근이 가능한 것뿐이지, f 자체가 포인터인 것은 아니다. 따라서 유니크 포인터 자체의 멤버 함수는 . 을 통한 직접적인 접근을 허용한다.

class Foo
{
public:
   Foo(const char* name, unsigned int age) {}
   void DoSomething();
   ...
};

void MakeUniqueExample()
{
   // std::make_unique 함수를 통한 unique_ptr 생성 
   std::unique_ptr<Foo> f = std::make_unique<Foo>("foo", 14);
   
   f->DoSomthing(); // f가 소유한 원시 포인터의 멤버에 접근
   f.reset();       // 유니크 포인터의 멤버 함수 
}



다음은 int형 배열에 대한 유니크 포인터를 생성한다.

void MakeUniqueExample()
{
   // 크기가 10인 int형 배열을 가리키는 unique_ptr 
   auto f = std::make_unique<int[]>(10);
}



[원시 포인터 반환하기]


unique_ptr의 멤버 함수인 get 을 통해 다음과 같이 원시 포인터를 반환할 수 있다.

class Foo {};

void GetUniquePointer()
{
   auto p = std::make_unique<Foo>();
   //Foo* f = p.get();    // 위험할 수 있음
   const Foo* f = p.get(); // 그나마 안전, 하지만 여전히 delete 가능
   // delete f;
}


p가 소유하고 있는 원시 포인터를 반환한다. 원시 포인터의 소유권을 포기하고 반환하는 것이 아니라 소유하고 있는 채로 반환하는 것이기 때문에 위험한 결과를 초래할 수 있다. 예를 들어 반환된 포인터를 외부에서 마음대로 delete 할 수 있다. p는 함수 scope를  벗어나면서 자신이 지워질 때, 자기가 소유한 원시 포인터의 메모리를 해제하려고 할 것이다. 근데 해당 원시 포인터는 이미 외부에서 해제해버렸다. 해제된 메모리 주소를 다시 해제하는 것은 런타임 에러를 발생시킨다.
const 타입이 그나마 값을 변경하지 못하게 막을 순 있지만 이마저도 delete가 가능하기 때문에 get을 사용해야 하는 상황이면 "외부에서 절대 delete를 하지 않기"  또는"read only로만 사용"  등 사용 규칙을 정해서 사용하는 것이 좋다. 또는 다음에 포스팅 할 std::shared_ptr을 사용하는 것도 한 방법이다.
 


[원시 포인터 해제 및 재설정]
unique_ptr의 멤버 함수인 reset을 통해 현재 소유 중인 원시 포인터를 삭제 또는 다른 원시 포인터로 재설정할 수 있다.

다음은 reset을 통해 원시 포인터를 삭제하는 예이다.

class Foo {};

void GetUniquePointer()
{
   auto f = std::make_unique<Foo>();
   f.reset();     // 원시 포인터를 해제
   //f = nullptr; // 원시 포인터를 해제
}

소유하고 있던 원시 포인터의 메모리를 내부적으로 delete 한다. 참고로 reset이 아닌 nullptr를 대입하여도 원시 포인터가 해제된다.


다음 예제는 reset을 통해 소유하고 있던 원시 포인터를 삭제하고 인자로 들어온 원시 포인터를 소유한다.

class Foo {};

void GetUniquePointer()
{
   auto f = std::make_unique<Foo>();
   f.reset(new Foo()); // 기존 원시 포인터를 삭제 후 새 원시 포인터를 소유함 
}

 

[원시 포인터 해제없이 내보내기]
unique_ptr의 멤버 함수인 release를 통해 소유하던 원시 포인터를 해제하지 않고 외부로 내보낼 수 있다. 

다음 예제는 release를 통해 반환한 원시 포인터를 p가 사용한다.

class Foo 
{
public: 
   int age;
};

void GetUniquePointer()
{
   auto f = std::make_unique<Foo>();
   *f = 25;
   
   Foo* p = f.release();
  
   if(f) {assert(*f == 25);} // 실행되지 않음
   if(p) {assert(*p == 25);} // 실행 됨
}

유니크 포인터가 관리해주던 메모리를 C스타일의 포인터로 받기 때문에 책임지고 사용 종료 시 해제해주어야 한다.


[다른 유니크 포인터로 소유권 이전하기]
유니크 포인터가 소유하던 원시 포인터를 C스타일의 포인터가 아닌 다른 유니크 포인터에 넘겨줄 수 있다. 단, 원칙 상 공유는 불가능하기 때문에 자신은 nullptr이 된다. 그림으로 보면 다음과 같다.

https://docs.microsoft.com/ko-kr/cpp/cpp/how-to-create-and-use-unique-ptr-instances?view=vs-2019

 
원시 포인터를 복사 또는 공유 없이 한번에 옮기기 위해 std::move라는 이동 함수를 사용한다.  std::move는 간단하게 말하면 rvalue를 복사없이 옮기기 위해 등장한 함수인데, 다음 코드를 실행할 경우 원래라면 복사 생성자가 호출되어야 한다.

auto ptrB = ptrA;



하지만 유니크 포인터는 처음에 말했듯이 공유가 불가능하며 복사생성자와 대입연산자가 delete되어 있다고 했다. 하지만 복사가 아닌 자신이 소유하던 것을 포기하고 남에게 주는 것은 가능하다고 했다. 그것을 가능하게 하는 것이 std::move 함수이며, 이를 통해 lvalue인 ptrA가 내부적으로 rvalue로 캐스팅되며, 대상이 rvalue일 경우에만 호출되는 이동 생성자가 호출이 된다. (혹은 이동 대입 연산자) 복사 생성자가 내부적으로 멤버 변수를 모두 복사했다면 이동 생성자는 내부적으로 복사가 아닌 자신의 모든 멤버를 대상에게 move(이동)시킨다.

따라서 아래 코드는 ptrA가 nullptr이 되며 ptrB가 원시 포인터를 소유하게 된다.

auto ptrB = std::move(ptrA);

 

참고 사이트

https://docs.microsoft.com/ko-kr/cpp/cpp/smart-pointers-modern-cpp?view=vs-2019

https://docs.microsoft.com/ko-kr/cpp/cpp/how-to-create-and-use-unique-ptr-instances?view=vs-2019




댓글