*해당 글은 POCU 아카데미의 수업 ‘COMP3200: C++ 언매니지드 프로그래밍’을 듣고 개인적으로 정리한 내용입니다. 틀린 점이 있다면 지적해주시면 감사하겠습니다!
스마트 포인터?
C언어에서 포인터는 사용자가 직접 메모리 할당을 제어할 수 있게 해주었지만, 잘못된 사용으로 인해 여러 가지 위험한 상황이 발생하기 쉬웠다. 할당된 메모리를 까먹고 해제하지 않거나 세그먼트 폴트 등의 오류를 만드는 게 대표적인 예다.
이 때문에 자바, C# 같은 언어에서는 아예 포인터를 없애고 가비지 컬렉터(GC; Garbage collector)를 도입했다. 그러나 메모리를 직접 제어하지 않는 만큼 성능상 손해를 보기 쉬웠다.
C++은 반대로 포인터를 편하게 사용할 수 있게 개선했다. 그 시도 중 하나가 auto 포인터(auto_ptr)인데 위험성이 커서 C++11 이후 스마트 포인터로 대체되었다.
스마트 포인터의 종류
스마트 포인터의 종류는 세 가지가 있다.
- unique_ptr
- shared_ptr
- weak_ptr
이 중에서 이번 글의 주제인 unique_ptr은 포인터를 아주 효과적으로 대체할 수 있어 유용하다. 그 다음으로 shared_ptr를 많이 사용한다고 한다. weak_ptr의 경우 위험성이 없지 않으므로, 주의할 필요가 있다.
RAII: Resource Acquisition Is Initialization
스마트 포인터를 생각하기 이전에, C++에서 객체와 메모리를 관리하는 원칙으로 사용되는 RAII를 알 필요가 있다.
포인터는 메모리를 할당했다면 반드시 해제해주어야 한다. C++에서는 new와 delete로 할당 및 삭제를 수행한다.
그런데 이 메모리 해제란 의외로 깜빡하기 쉬운 작업이다. 특히 예외가 발생하여 try-catch를 거치는 바람에 본의아니게 메모리 해제를 실패하면, 메모리 누수가 발생한다. C++에서 이를 해결하기 위한 방법으로, RAII라는 원칙이 도입되었다.
RAII는 ‘Resource Acquisition Is Initiailization’의 약자로 번역하자면 ‘자원 획득은 초기화이다’는 뜻이다. C++를 개발한 덴마크의 컴퓨터 과학자 비야네 스트롭스트룹이 제시한 개념이다. RAII는 ‘지역 객체에 의해 자원이 생성되었다면 지역 객체가 파괴될 때 그 자원도 파괴되어야 한다’는 발상에서부터 시작되었다.
비야네 스트롭스트룹의 말에 따르면, C++은 자원을 생성한 객체가 소멸될 때 스스로 그 자원을 파괴하도록 해야 한다.
자원을 할당한 범위(Scope)를 벗어나면 그 자원도 해제되어야 한다. 즉, 함수 루틴 중에 자원을 할당했다면 그 함수가 끝날 때 자원도 해제해줘야 한다.
함수를 빠져나올 땐 해당 범위(Scope)에서 정의된 모든 객체가 소멸자가 호출되니, 객체의 소멸자에서 자기가 할당한 자원을 해제해준다면 메모리 누수 없이 안전하게 자원을 사용할 수 있지 않을까?
왜 스마트 포인터가 나왔는가?
스마트 포인터는 RAII에서 말하는, 소멸자가 호출될 때 포인터가 가리키는 데이터를 삭제해주는 작업을 자동으로 수행해준다.
따라서 포인터가 가리키는 메모리를 깜빡하고 해제하지 않는 불상사를 예방할 수 있다!
unique_ptr(C++11)
std::unique_ptr
is a smart pointer that owns and manages another object through a pointer and disposes of that object when theunique_ptr
goes out of scope.
unique_ptr는 소유자가 단 하나밖에 없는 포인터라고 볼 수 있다.
정확히는 unique_ptr이란 포인터를 통해 다른 객체를 소유하는 스마트 포인터이다. unique_ptr의 특징은 유니크 포인터가 범위(scope)를 벗어나 소멸될 때, 유니크 포인터가 가리키는 객체도 함께 삭제(delete)해준다.
unique_ptr은 원시 포인터(naked pointer, 기존 C의 포인터를 말함)를 단독으로 소유한다.
원시 포인터를 누구하고도 공유하지 않기 때문에, 복사나 대입을 할 수 없다.
생성자
unique_ptr를 포함하여, 스마트 포인터를 사용할 땐 헤더파일로 를 포함해야 한다.
#include <memory>
...
std::unique_ptr<T> ptr(new T());
// T* ptr = new T(); 와 같다.
unique_ptr은 위와 같이 생성자의 매개변수에 새로 생성한 객체를 넣어서 생성한다. T 클래스를 생성하려면 매개변수로 T 클래스의 객체를 넣는다.
별도로 삭제를 할 필요는 없으며, 소멸자가 호출될 때 자동으로 객체도 delete된다.
복사와 대입을 할 수 없다
std::unique_ptr<MyClass> p(new MyClass());
// 아래 코드는 컴파일 에러가 발생한다.
// std::unique_ptr<MyClass> copy1 = p;
// std::unique_ptr<MyClass> copy2(p);
unique_ptr는 다른 unique_ptr에 복사하거나 값을 대입하는 것이 불가능하다.
이는 복사 생성자가 없기 때문이다.
unique_ptr의 사용 방법
unique_ptr는 소유자가 하나이고 객체가 사라질 때 자동으로 참조하는 객체도 제거되므로, 다음 세 경우에 적합하다.
- 클래스의 멤버 변수
- 지역변수
- 벡터와 같은 STL 자료구조에 저장
클래스의 멤버 변수
class Human
{
private:
std::unique_ptr<MyClass> mMyClass;
};
클래스의 멤버변수로 사용하면 별도로 소멸자에서 포인터를 삭제하지 않아도 된다.
지역 변수
#include <memory>
int main()
{
std::unique_ptr<MyClass> ptr(new MyClass);
// 기존 포인터와 달리 아래와 같이 삭제할 필요가 없다.
// delete ptr;
}
크기가 클 경우에는 스택에 데이터를 할당할 수 없을 때가 있다. 이때에는 지역 변수이지만 힙에 데이터를 할당해야 할 때가 있다. 이때에도 unique_ptr를 쓰면 삭제하지 않아도 되어 편리하다.
벡터와 같은 STL 자료구조에 저장
만약 게임에서 아이템을 관리한다고 할 때, 아이템을 unique_ptr를 이용하여 vector와 같은 STL 자료구조에 저장할 수 있겠다.
// 기존에는 vector에 들어간 요소는 하나씩 순회하며 삭제해줘야 했다.
#include <memory>
int main()
{
std::vector<Item*> inventory;
// 아이템 삽입
inventory.push_back(new Item("Master Sword"));
inventory.push_back(new Item("Apple"));
// 바로 clear를 호출하면 메모리 누수가 발생한다...
for (int i = 0; i < inventory.size(); i++)
{
delete inventory[i];
}
// 각 요소를 삭제한 후에야 vector를 비울 수 있다
inventory.clear();
return 0;
}
기존에는 위와 같이 STL에 들어 있는 모든 요소를 삭제하고 싶어도 STL를 순회하며 각 요소에 대해 메모리를 해제하는 작업이 필수적이었다.
#include <memory>
int main()
{
std::vector<unique_ptr<Item>> inventory;
// 아이템 삽입
inventory.push_back(new unique_ptr<Item>("Master Sword"));
inventory.push_back(new unique_ptr<Item>("Apple"));
// 별 다른 작업 없이 바로 clear해도 메모리 누수가 발생하지 않는다!
inventory.clear();
return 0;
}
unique_ptr를 사용하면 삭제에 대한 고민을 하지 않아도 된다.
std::vector::clear를 호출하거나 std::vector가 소멸할 때 모든 요소에 대한 소멸자가 호출된다. 따라서 std::vector에 들어있던 unique_ptr도 소멸자가 호출되어 모든 포인터를 삭제한다.
원시 포인터 공유 문제와 해결 방법
그런데 지금처럼 스마트 포인터를 생성하면 문제점이 있다.
바로 하나의 객체를 여러 unique_ptr가 소유할 수 있다는 점이다.
A* pa = new A();
std::unique_ptr<A> uniquePtr1(pa);
std::unique_ptr<A> uniquePtr2(pa);
위와 같은 식으로 같은 객체를 두 개의 unique_ptr가 가리킬 수 있다.
이렇게 했을 때 문제점은 이 상태에서 둘 중 하나가 포인터를 해제하면 나머지 하나도 해제될 수 있다는 것이다. uniquePtr2 = nullPtr;
를 실행하면 uniquePtr2가 가리키고 있던 pa의 메모리가 해제된다. 그런데 uniquePtr1은 자신이 포인터를 할당했으므로 해제되지 않았다고 생각한다. 따라서 uniquePtr1은 소멸자가 호출될 때 메모리를 해제한다. 즉, uniquePtr1이 이미 해제된 메모리를 재해제할지도 모르는 것이다...
std::make_unique()
그래서 C++14 이후에 std::make_unique<T>()
를 통해 unique_ptr를 생성하는 방법이 추가되었다. 기능은 기본 생성자와 동일하며, std::make_unique<T>()
를 사용하면 하나의 원시 포인터가 반드시 하나의 unique_ptr에 의해서만 소유될 수 있게 된다.
// Human.h
#include <string>
class Human
{
public:
Human(std::string mName);
private:
std::string mName;
};
// main.cpp
#include <memory>
int main()
{
// 기존생성 방법
std::unique_ptr<Human> human(new Human("철수"));
// make_unique를 이용한 생성 방법
std::unique_ptr<Human> newHuman = std::make_unique<Human>("영희");
return 0;
}
std::make_unique() 꼴로 쓰면 되는데, 이때 함수의 매개변수로 생성 인자를 넣어주면 된다.
// 기존의 unique_ptr 배열 생성 방법
std::unique_ptr<Human[]> humans(new Human[10]);
// 새로운 unique_ptr 배열 생성 방법
std::unique_ptr<Human[]> humans = std::make_unique<Human[]>(10);
다만 배열을 생성할 땐 매개변수로 배열의 크기를 넣어줘야 한다.
해당 함수를 사용하면 둘 이상의 unique_ptr가 원시 포인터를 공유하는 문제를 해결할 수 있으므로, 무조건 std::make_unique를 통해 unique_ptr를 생성하는 것이 좋다!
unique_ptr 관련 함수
기존 객체 제거하기: unique_ptr은 nullptr을 대입하여 포인터를 비우고 기존 객체를 제거할 수 있다.
std::unique_ptr<A> ptr = std::make_unique<A>();
ptr = nullptr; // 포인터 초기화
ptr.reset(); // 위와 동일함
혹은, reset() 함수를 쓸 수 있다.
포인터 재설정: 다른 포인터로 재설정하려면 reset의 매개변수에 새로운 포인터를 전달해줄 수 있다. 재설정시, 기존의 원시 포인터는 자동 소멸된다.
std::unique_ptr<A> ptr = std::make_unique<A>();
ptr.reset(new A());
get() 함수: unique_ptr의 원시 포인터를 획득할 수 있다. 불가피하게 원시 포인터를 사용해야만 하는 경우에 쓸 수 있다.
예를 들어, 함수의 인자로 포인터를 전달해야 하는 경우에 사용할 수 있다.
이때 get() 함수로 원시 포인터를 획득하여 매개변수로 전달할 수 있다.
함수의 인자로 unique_ptr를 전달하게 되면 매개변수로 전달하는 과정에서 복사가 발생하여 컴파일 오류가 발생한다. 이후 나올 std::move를 사용해도 되지만, 포인터의 소유권을 바꾸고 싶지 않을 때 get 함수를 사용할 수 있다.
void function(A* a)
{
std::cout << "function(a)" << std::endl;
}
int main()
{
std::unique_ptr<A> pa = make_unique<A>();
function(pa.get()); // pa.get()을 통해 pa의 원시 포인터를 가져온다.
}
다만 get으로 얻어온 원시 포인터를 제거해버리는 경우가 생길 수 있다.
위에서 function에서 A* a를 해제하지 않는다는 보장이 없다. 만약 function 내에서 A* a를 해제할 경우, 메모리 해제를 두 번 해버리는 불상사가 발생할 수 있다.
release(): 포인터를 풀어줄 수 있다. 단, 풀어준 포인터는 제거되지 않으므로 이 포인터를 나중에 별도로 해제해야 한다.
소유권 이전하기: std::move() 함수를 통해 포인터의 소유권을 바꿀 수 있다.
예를 들어 unique_ptr에서 ptrA에서 ptrB로 포인터를 옮기고 싶다면, std::move()를 사용해야 한다.
기존에는 ptrB=ptrA;
라고 쓸 경우 ptrB가 ptrA를 복사한다는 뜻이 되어 복사 생성자가 삭제된 unique_ptr의 경우 이동이 불가능했다. 소유권을 전달하려면 C++14에서 추가된 std::move() 함수를 사용할 수 있다.
안전하게 공유하기: get() VS std::move()
unique_ptr는 다른 스마트 포인터인 shared_ptr와 weak_ptr에 비해 공유에 적합하지 않다. 포인터에 대한 소유권자가 단 하나이기 때문이다.
shared_ptr은 레퍼런스 카운트를 사용하기 때문에 여러 shared_ptr가 하나의 객체를 공유할 수 있다. 그러나 unique_ptr는 하나의 객체가 반드시 하나의 포인터에 의해서만 소유된다.
그러나, unique_ptr를 아예 공유하지 않을 수는 없다. 함수 인자를 통해 넘길 때도 있고, 연결 리스트에서 unique_ptr로 연결한다면 다음 객체를 획득하기 위해 unique_ptr를 공유할 수밖에 없다.
unique_ptr에서 포인터를 공유해야 한다면, 사용할 수 있는 방법은 (1) get()
함수로 원시 포인터를 넘기거나 (2) std::move()
를 통해 아예 포인터의 소유권을 넘기는 방법밖에 없다.
그러나 get()
함수로 원시 포인터를 넘겨주면 원시 포인터를 받은 함수나 객체 측에서 언제 해제할지 모른다는 불안감을 감수해야 한다. 그렇다고 std::move
로 넘겨주는 것은 성능에는 문제가 없지만, 소유권을 전달해야 된다는 명확한 근거가 없다면 unique_ptr를 주고받는 것 자체가 위험하고 불필요하다.
피치못한 경우, 어떻게 해야 안전하게 unique_ptr를 공유할 수 있을까?
std::unique_ptr
provides unique ownership semantics safely. However that doesn't rule out the need for non-owning pointers.std::shared_ptr
has a non-owning counterpart,std::weak_ptr
. Raw pointers operate asstd::unique_ptr
's non-owning counterpart.c++ - what's the point of std::unique_ptr::get - Stack Overflow 답변 발췌
위 글은 스택 오버플로우에 올라온 글로, unique_ptr에서 get
이 필요한지 묻는 글에 대한 답변이다(번역이 이상하다면 죄송합니다).
위 글에 따르면, unique_ptr는 포인터를 유일하게 소유할 수 있게 해주기는 하지만, 소유하지 않는 포인터를 아예 배제하지 않는다고 한다. 즉, get
함수 자체는 필요하다는 것이다.
그렇다면 어떻게 get
함수를 사용해야 안전하게 쓸 수 있을까?
RAII 원칙에 따르면 C++에서는 포인터를 소유자가 제거하도록 되어 있다. 따라서 C++ 개발자 간에 코딩 표준을 세워서, 원시 포인터를 얻어올 때 포인터를 삭제하거나 변경하지 않도록 제한을 두어야 한다.
실제로 unique_ptr를 사용하는 코드베이스에서는 raw 포인터(기존 포인터)를 함수의 매개변수로 전달하면, raw 포인터를 보고 따로 메모리를 관리할 필요가 없다는 의미로 볼 수 있다.
좀 더 안전하게 하기 위해 함수의 매개변수로 포인터를 넘길 때, 변하지 않는 인자를 넘길 땐 레퍼런스(const&) 형태로 넘길 수 있다.
더 자세한 사항
더 자세하게 공부하려면, 모두의 코드님이 작성한 강의를 읽어보는 걸 추천한다. 깔끔하고 자세하게 적어놓으셔서 처음 배울 때 많은 도움이 되었다.
씹어먹는 C ++ - <13 - 1. 객체의 유일한 소유권 - unique_ptr> (modoocode.com)
shared_ptr과 weak_ptr에 대한 글은 작성할 수도 있고 안 할 수도 있다…
참고 링크
Stroustrup: C++ Style and Technique FAQ
std::unique_ptr - cppreference.com
씹어먹는 C ++ - <13 - 1. 객체의 유일한 소유권 - unique_ptr> (modoocode.com)
방법: unique_ptr 인스턴스 만들기 및 사용 | Microsoft Learn
c++ - what's the point of std::unique_ptr::get - Stack Overflow
'프로그래밍 언어 > C, C++' 카테고리의 다른 글
[C++] 레퍼런스, const 레퍼런스 반환 / 레퍼런스와 임시 개체의 수명 (1) | 2024.02.05 |
---|---|
[C/C++] 메모리 초기화하기: ZeroMemory 매크로 (0) | 2023.08.06 |
[C/C++] 함수 호출 규약 __stdcall과 __cdecl에 관하여 (0) | 2023.08.03 |
[C++] 왜 string은 반환이 될까? 객체는 반환할 때 복사된다(+구조체) (0) | 2022.01.20 |
C언어에서의 16진수, signed int와 unsigned int (0) | 2021.05.03 |
댓글