Item18은 독점 소유 자원의 관리에는 std::unique_ptr를 사용해라 는 주제이다.
---
unique_ptr는 생 포인터(Raw Pointer)와 크기가 같다고 가정하는게 합리적이며, 역참조(dereferencing)를 포함한 대부분의 연산에서 정확히 동일한 명령어가 실행된다. 이는 메모리와 CPU 사이클이 부족한 상황에서도 이를 사용할 수 있음을 의미한다. 만약 생 포인터가 충분히 작고 빠르다면, unique_ptr 역시 그럴 것이다.
unique_ptr는 독점적 소유권 의미론(exclusive ownership semantics)을 구현한다.
null이 아닌 unique_ptr는 항상 그것이 가리키는 대상을 소유한다. unique_ptr를 이동(move)하는 것은 소유권을 원본 포인터에서 대상 포인터로 이전한다.(원본 포인터는 null로 설정된다.)
unique_ptr를 복사하는 것은 허용되지 않는데, 만약 개발자가 unique_ptr를 복사할 수 있다면 동일한 자원에 대해 두 개의 unique_ptr를 가지게 되고, 각각은 자신이 해당 자원을 소유하고 있다고 생각하게 된다.
따라서, unique_ptr는 이동전용(move-only)타입이다. 파괴 시, null이 아닌, unique_ptr는 자신의 자원을 파괴한다.
기본적으로 자원 파괴는 unique_ptr 내부의 생 포인터에 delete를 적용하는 것으로 수행된다.
---
unique_ptr의 일반적인 용도는, 계층 구조에 있는 객체들을 위한 팩토리 함수의 반환 타입이다.
아래에 Investment라는 이름의 베이스 클래스와 하위 클래스들이 있다고 가정하자.
class Investment { … };
class Stock: public Investment { … };
class Bond: public Investment { … };
class RealEstate: public Investment { … };
이러한 계층 구조를 위한 팩토리 함수는 일반적으로 힙(heap)에 객체를 할당하고 그에 대한 포인터를 반환하며, 호출자는 객체가 더 이상 필요하지 않을 때 그것을 삭제할 책임을 진다.
이는 unique_ptr에 완벽하게 들어맞는데, 호출자가 팩토리로부터 반환된 자원에 대한 책임(즉, 그것에 대한 독점적 소유권)을 획득하고, unique_ptr는 파괴될 때 자신이 가리키는 대상을 자동으로 삭제하기 때문이다.
Invest 계층 구조를 위한 팩토리 함수는 다음과 같이 선언될 수 있다.
template<typename... Ts> // 주어진 인자들로부터 생성된
std::unique_ptr<Investment> // 객체에 대한 std::unique_ptr를
makeInvestment(Ts&&... params); // 반환함
---
호출자는 다음과 같이 단일 스코프 내에서 반환된 unique_ptr를 사용할 수 있다.
{
…
auto pInvestment = // pInvestment의 타입은
makeInvestment( arguments ); // std::unique_ptr<Investment>
…
} // *pInvestment가 파괴됨
하지만 호출자는 소유권 이전(ownership-migration) 시나리오에서도 그것을 사용할 수 있다.
예를들어, 팩토리로부터 반환된 unique_ptr가 컨테이너로 이동되고, 그 컨테이너 요소가 이후에 어떤 객체의 데이터 멤버로 이동되며, 그 객체가 나중에 파괴되는 경우이다.
그런 일이 발생하면 객체의 unique_ptr 데이터 멤버 역시 파괴될 것이며, 그 파괴는 팩토리로부터 반환된 자원을 파괴하는 것이다.
만약 예외나 다른 비정상적인 제어 흐름(예 : 조기 함수 반환 혹은 루프에서의 break)으로 인해 소유권 체인이 중단된다면, 관리되는 자원을 소유하고 있는 unique_ptr의 소멸자가 결국 호출될 것이고, 그로 인해 그것이 관리하던 자원은 파괴된다.
---
기본적으로 이러한 파괴는 delete를 통해 일어나겠지만, 생성 도중에 unique_ptr객체들은 커스텀 삭제자를 사용하도록 설정될 수 있다. 이는 자원을 파괴할 때 호출될 임의의 함수(혹은 람다 표현식으로부터 발생하는 함수 객체를 포함한 함수 객체)들이다.
만약 makeInvestment 메서드에 의해 생성된 객체가 직접 삭제되지 않고, 대신 먼저 로그 항목이 작성되어야 한다면, makeInvestment 메서드는 다음과 같이 구현될 수 있다.
auto delInvmt = [](Investment* pInvestment) // 커스텀 삭제자
{ // (람다 표현식)
makeLogEntry(pInvestment);
delete pInvestment;
};
template<typename... Ts> // 수정된 반환 타입
std::unique_ptr<Investment, decltype(delInvmt)>
makeInvestment(Ts&&... params)
{
std::unique_ptr<Investment, decltype(delInvmt)> // 반환할 포인터
pInv(nullptr, delInvmt);
if ( /* Stock 객체가 생성되어야 하는 경우 */ )
{
pInv.reset(new Stock(std::forward<Ts>(params)...));
}
else if ( /* Bond 객체가 생성되어야 하는 경우 */ )
{
pInv.reset(new Bond(std::forward<Ts>(params)...));
}
else if ( /* RealEstate 객체가 생성되어야 하는 경우 */ )
{
pInv.reset(new RealEstate(std::forward<Ts>(params)...));
}
return pInv;
}
---
만약 개발자가 makeInvestment 호출결과를 auto 변수에 저장한다고 가정하면, 사용하던 자원이 삭제 중에 특별한 처리를 필요로 한다는 내용을 모르고 코드를 작성할 것이다.
unique_ptr를 사용한다는 것은, 자원이 언제 파괴되어야 하는지에 대해 신경 쓸 필요가 없음을 의미하며, 프로그램의 모든 경로에서 파괴가 정확히 한 번만 일어나는지에 대해서 보장할 필요도 없기 때문이다.
unique_ptr 가 그 모든것을 자동으로 처리해줄 것이다.
---
여기까지 요약하면,
성능 : unique_ptr은 생 포인터만큼 가볍고 빠르다
소유권 : 오직 하나만 가질 수 있고, 복사는 안되고, 이동만 가능하다.
안전성 : 어떤 실행 경로로 빠져나가든(예외 포함), 소멸자가 호출되면 자원은 확실하게 해제된다.
유연성 : delete 외에 사용자가 정의한 삭제 로직(커스텀 삭제자)을 쉽게 끼워넣을 수 있다.
---
이러한 구현은 다음 사항을 이해하면 꽤 훌륭하게 느껴질 것이다.
- delInvmt 메서드는 makeInvestment 메서드가 반환하는 객체를 위한 커스텀 삭제자이다.
모든 커스텀 삭제 함수는 파괴될 객체에 대한 생 포인터(Raw Pointer)를 매개변수로 받아, 해당 객체를 파괴하는 데 필요한 작업을 수행한다. 이 예제에선 makeLogEntry 를 호출한 뒤, delete를 적용하는 점이 그것이다.
람다 표현식을 사용해 delInvmt 메서드를 만드는 것은 편리할 뿐 아니라 일반적인 함수를 작성하는 것보다 효율적이다.
- 커스텀 삭제자를 이용할 때는 해당 삭제자의 타입을 unique_ptr의 두 번째 타입 인자로 명시해야 한다.
위 예제에선 delInvmt의 타입이 들어가며, 이것이 makeInvestment의 반환 타입이 unique<Investment, decltype(delInvestment)>인 이유이다.
- makeInvestment 메서드의 기본 전략은 다음과 같다.
우선 null상태의 unique_ptr를 생성하고, 그 것이 적절한 타입의 객체를 가리키도록 만든 뒤 반환하는 것이다. 커스텀 삭제자인 delInvmt 메서드를 pInv와 연결하기 위해 생성자의 두 번째 인자로 전달한다.
- 생 포인터(예 : new의 결과물)를 unique_ptr에 직접 대입하려는 시도는 컴파일 되지 않는다.
이는 생 포인터에서 스마트 포인터로의 암시적 변환(implicit conversion)이 되기 때문이다. 이러한 암시적 변환은 문제가 될 수 있기에 C++11의 스마트 포인터들은 이 것을 금지한다.
이것이 pInv가 new로 생성된 객체의 소유권을 갖게 하기 위해 reset을 사용하는 이유이다.
-> 개발자도 모르게 소유권을 넘겨버리는 실수를 막기 위함이다. 아래의 예시를 보자.
void processInvestment(std::unique_ptr<Investment> ptr) {
// ... 작업을 수행함
} // 여기서 ptr이 소멸하며 대상 객체를 delete 해버림!
Investment* pInv = new Stock();
// 만약 암시적 변환이 허용된다면?
processInvestment(pInv); // 오타 혹은 실수로 생 포인터를 넣음
// 대참사 발생:
// 1. pInv가 unique_ptr로 자동 변환되어 함수로 들어감.
// 2. 함수가 끝나자마자 pInv가 가리키던 Stock 객체가 delete 됨.
// 3. 함수 밖의 pInv는 이제 '쓰레기 값'을 가리키는 대상을 잃은 포인터(Dangling Pointer)가 됨.
위 경우, 함수 하나를 호출했을 뿐이지만 함수가 종료되자마자 내 포인터가 가리키던 메모리가 사라져버리는 상황이 발생하게 된다. 이런 의도치 않은 소유권 이전을 막기위해 반드시 명시적으로 생성자를 호출하거나 reset을 써야 한다.
- new를 사용할 때마다, forward를 사용해 makeInvestment 메서드에 전달된 인자들을 완벽하게 전달(perfect-forward)한다. (이는 Item 25의 내용을 참조)
이를 통해 호출자가 제공한 모든 정보를 생성될 객체의 생성자가 그대로 사용할 수 있게 된다.
- 커스텀 삭제자는 Investment* 타입의 매개변수를 받는다.
makeInvestment 메서드 내부에서 실제로 생성된 객체의 타입(Stock, Bond, RealEstate)에 관계없이, 결과적으로는 람다 표현식 내부에서는 Investment* 객체로서 삭제된다.
이는 우리가 베이스 클래스 포인터를 통해 파생 클래스 객체를 삭제하게 된다는 것을 의미한다. 이것이 올바르게 작동하려면 베이스 클래스인 Investment 객체에 반드시 가상 소멸자(virtual destructor)가 있어야 한다.
class Investment {
public:
… // 필수적인
virtual ~Investment(); // 설계
… // 구성 요소!
};
만약, unique_ptr<Investment>를 쓰면서 실제로는 Stock 타입을 담는다고 할 때, Investment 객체의 소멸자에 virtual이 없다면, 부분적 소멸(Partial Destruction)으로 인한 자원 누수가 발생한다. 즉, Stock 데이터는 메모리에 그대로 남아있는 문제가 발생한다.
C++14에서는 함수 반환 타입 추론(function return type deduction, Item3 참조) 덕분에 makeInvestment 메서드를 다음과 같이 더 간단하고 캡슐된 방식으로 구현할 수 있다.
template<typename... Ts>
auto makeInvestment(Ts&&... params) // C++14
{
auto delInvmt = [](Investment* pInvestment) // 이제 이 코드가
{ // makeInvestment
makeLogEntry(pInvestment); // 내부에
delete pInvestment; // 위치함
};
std::unique_ptr<Investment, decltype(delInvmt)> // 이전과
pInv(nullptr, delInvmt); // 동일
if ( … ) // 이전과 동일
{
pInv.reset(new Stock(std::forward<Ts>(params)...));
}
else if ( … ) // 이전과 동일
{
pInv.reset(new Bond(std::forward<Ts>(params)...));
}
else if ( … ) // 이전과 동일
{
pInv.reset(new RealEstate(std::forward<Ts>(params)...));
}
return pInv; // 이전과 동일
}
앞서, 기본 삭제자(delete)를 사용할 때, unique_ptr 객체의 크기가 생 포인터와 같다고 가정해도 무방하다고 했었다.
하지만 커스텀 삭제자가 등장하면 상황이 달라질 수 있다. 함수 포인터를 삭제자로 사용하면, 일반적으로 unique_ptr의 크기는 1word에서 2word로 늘어난다. -> 함수 포인터를 삭제자로 사용하면 포인터가 2개가 되기 때문이다.
함수 객체(function object)를 삭제자로 사용하는 경우, 크기 변화는 해당 함수 객체에 얼마나 많은 상태(state)가 저장되어 있느냐에 따라 달라진다.
상태가 없는 함수 객체(예 : 캡처가 없는 람다 표현식)는 크기 패널티가 전혀 없으며, 이는 커스텀 삭제자를 함수로 구현할 수도 있고 캡처 없는 람다로도 구현할 수 있다면 람다를 사용하는 것이 바람직하다는 뜻이다. -> 캡처 없는 람다를 삭제자로 사용하면 컴파일러가 최적화를 해줘서 포인터가 1개로 줄어들기에 권장된다.
---
여기까지 요약하면,
1. 캡처 없는 람다는 공짜이다.
함수 포인터를 쓰면 포인터 크기가 2배가 되지만, 캡처 없는 람다를 쓰면 생 포인터와 똑같은 크기를 유지한다.
2. 가상 소멸자는 필수이다.
unique_ptr<Base>에 Derived를 담을 거라면, 베이스 클래스 소멸자에 virtual 키워드를 붙이는 것을 잊지마라. 안 그러면 파생 클래스 부분이 안 지워진다.
3. 암시적으로 변환하지 마라.
unique_ptr<int> p = new int(10); 이런거 하지마라. 반드시 생성자나 reset()을 써야 한다.
---
삭제자 구현 방식에 따른 효율성을 비교하는 코드 예시는 아래와 같다
auto delInvmt1 = [](Investment* pInvestment) // 커스텀 삭제자
{ // 상태가 없는(stateless)
makeLogEntry(pInvestment); // 람다 표현식
delete pInvestment;
};
template<typename... Ts> // 반환 타입의 크기는
std::unique_ptr<Investment, decltype(delInvmt1)> // Investment*와 같음
makeInvestment(Ts&&... args);
void delInvmt2(Investment* pInvestment) // 함수로 구현된
{ // 커스텀 삭제자
makeLogEntry(pInvestment);
delete pInvestment;
}
template<typename... Ts> // 반환 타입의 크기는
std::unique_ptr<Investment, // Investment* 크기에
void (*)(Investment*)> // 함수 포인터 크기가 더해짐!
makeInvestment(Ts&&... params);
상태가 방대한 함수 객체 삭제자는 unique_ptr 객체의 크기를 상당히 커지게 만들 수 있다.
만약 커스텀 삭제자가 개발자의 unique_ptr를 받아들이기 힘들정도로 크게 만든다면, 디자인을 변경해야 할 수도 있다.
---
팩토리 함수가 unique_ptr의 유일한 일반적인 사레는 아니다.
이들은 Pimpl Idiom(Item 22)을 구현하는 메커니즘으로 훨씬 더 대중적이다. 해당 코드는 복잡하지 않지만, 어떤 경우에는 직관적이지 않을 수 있으므로 Item22를 참조해야 한다.
unique_ptr는 두가지 형태, 즉 개별 객체를 위한 형태(unique_ptr<T>)와 배열을 위한 형태(unique_ptr<T[]>) 로 제공된다.
결과적으로 unique_ptr가 어떤 종류의 엔티티를 가리키는지에 대한 모호함은 전혀 없다.
unique_ptr API는 개발자가 사용하는 형태에 맞게 설계되어 있다. 예를들어, 단일 객체 형태에선 인덱싱 연산자(operator[])가 없고, 배열 형태에는 역참조 연산자(operator* 및 operator->)가 없다.
배열을 위한 unique_ptr의 존재는 개발자에게 있어 지적인 흥미 정도만 있어야 하는데, 왜냐면 거의 모든 상황에서 array, vector, string이 생 배열(raw arrays)보다 더 나은 데이터 구조 선택이기 때문이다.
unique_ptr<T[]> 가 합리적인 거의 유일한 상황은, 개발자가 소유권을 획득하게 될 힙 배열의 생 포인터를 반환하는 C스타일의 API를 사용하고 있을 때 뿐이다.
가급적이면 vector를 사용해야 한다.
---
unique_ptr는 독점적 소유권을 표현하는 C++11의 방식이지만, 가장 매력적인 특징 중 하나는, 그것이 shared_ptr로 쉽고 효율적으로 변환된다는 점이다.
std::shared_ptr<Investment> sp = // std::unique_ptr를
makeInvestment( arguments ); // std::shared_ptr로 변환
이것이 왜 unique_ptr가 팩토리 함수의 반환 타입으로 매우 적합한지를 보여주는 핵심 부분이다.
팩터리 함수는 호출자가 반환된 객체에 대해 독점적 소유권 의미론을 사용하기를 원하는지, 아니면 공유 소유권(즉, shared_ptr)이 더 적절할지 알 수 없다.
unique_ptr를 반환함으로써 팩토리는 호출자에게 가장 효율적인 스마트포인터를 제공하면서도, 호출자가 그것을 더 유연한 형제(shared_ptr)로 교체하는 것을 방해하지 않는다.
shared_ptr에 대한 정보는 Item 19에 나온다.
---
기억해야 할 사항들.
- unique_ptr는 독점 소유권 의미론을 가진 자원을 관리하기 위한, 작고 빠르며 이동전용(move-only)인 스마트 포인터이다. 생 포인터와 똑같이 빠르다.
- 기본적으로 자원 파괴는 delete를 통해 이뤄지지만, 커스텀 삭제자를 지정할 수 있다. 상태를 가진 삭제자나 함수 포인터를 삭제자로 이용하면, unique_ptr 객체의 크기가 증가한다. 그렇기에 함수 삭제자보다는 람다 삭제자가 권장된다.
- unique_ptr를 shared_ptr로 변환하는 것은 쉽다. 그러니 기본적으로는 unique_ptr로 시작하는게 좋다.
'읽은 기록 > Effective Modern C++' 카테고리의 다른 글
| [CH4] Item20. Use std::weak_ptr for std::shared_ptr like pointers that can dangle. (0) | 2026.04.20 |
|---|---|
| [CH4] Item19. Use std::shared_ptr for shared-ownership resource management. (1) | 2026.04.12 |
| [CH.4] Smart Pointers (0) | 2026.04.05 |
| [CH3] Item17. Understand special member function generation. (0) | 2026.03.30 |
| [CH3] Item16. Make const member functions thread safe. (0) | 2026.03.28 |