- std::unique_ptr는 독점 소유권 의미론을 가진 자원의 관리를 위한, 작고 빠른 이동 전용 스마트 포인터이다.
std::unique_ptr는 기본적으로 생 포인터와 같은 크기라고 가정할 수 있으며, 대부분의 연산(역참조를 비롯해서)에서 생 포인터와 정확히 동일한 명령들을 실행한다.
std::unique_ptr는 독점적 소유권(exclusive ownership) 의미론을 재현하고 있다. 널이 아닌 std::unique_ptr는 항상 자신이 가리키는 객체를 소유한다. std::unique_ptr를 이동하면 소유권이 원본 포인터에서 대상 포인터로 옮겨진다(원본 포인터는 널로 설정된다).
std::unique_ptr의 복사는 허용되지 않는다. 만일 복사가 허용되면, 두 std::unique_ptr가 같은 자원을 가리킬 뿐만 아니라 두 포인터 모두 자신이 그 자원을 소유하고 있다고(따라서 그 자원을 파괴해야 한다고) 생각하는 상황이 벌어질 것이다.
널이 아닌 std::unique_ptr는 소멸 시 자신이 가리키는 자원을 파괴한다. 기본적으로, 자원 파괴는 std::unique_ptr 안에 있는 생 포인터에 delete를 적용함으로써 수행된다.
std::unique_ptr의 흔한 용도 하나는, 계통구조(hierarchy) 안의 객체를 생성하는 펙터리 함수의 반환 타입으로 쓰이는 것이다.
주식(stock), 채권(bond), 부동산(real estate) 같은 여러 종류의 투자 대상들을 대표하는 Investment를 기반 클래스로 삼은 다음과 같은 계통 구조가 있다고 하자.
class Investment {
};
class Stock :
public Investment {
};
class Bond :
public Investment {
};
class RealEstate :
public Investment {
};
팩터리 함수는 다음과 같이 만들 수 있을 것이다.
// 주어진 인수들로 생성한 객체를 가리키는 std::unique_ptr를 돌려줌
template <typename... Ts>
std::unique_ptr<Investment> makeInvestment(Ts&&... params);
// 다음은 이 팩터리 함수를 사용하는 예이다.
{
// pInvestment의 타입은 std::unique_ptr<Investment> *pInvestment가 파괴된다.
auto pInvestment = makeInvestment(인수들);
}
std::unique_ptr를 소유권 이전 시나리오에서 사용할 수도 있다.
예를 들어 팩터리 함수가 돌려준 std::unique_ptr를 어떤 컨테이너 안으로 이동하고, 이후 그 컨테이너 요소를 어떤 객체의 한 데이터 멤버로 이동하고, 그 후 어느 시점에서 그 객체가 파괴된다고 하자.
예외나 기타 비전형적 제어 흐름(이를테면 때 이른 함수 반환이나 루프로부터의 break 등)
때문에 그러한 일련의 소유권 이동이 중도에 가로채이면, 결국에는 관리되는 자원을 소유하고 있던 std::unique_ptr의 소멸자가 호출되며, 결과적으로 관리되는 자원이 파괴된다.
이 규칙에는 몇 가지 예외가 있다. 예외가 스레드의 주 함수(예를 들어 프로그램의 초기 스레드의 경우에는 main) 바깥으로 전파되거나 noexcept 명세가 위반되면 지역 객체들이 파괴되지 않을 수 있으며, std::abort나 어떤 종료 함수 즉 std::_Exit나 std::exit, std::quick_exit)가 호출되면 지역 객체들은 절대로 파괴되지 않는다.
unique_ptr의 흔한 용도는 팩터리 함수만이 아니다.
std::unique_ptr는 Pimpl 관용구의 구현 메커니즘으로 더 인기 있다. 이에 관한 예제는 항목 22를 찾아 보자.
std::unique_ptr는 두 가지 형태인데, 하나는 개별 객체를 위한 것(std::unique_ptr<T>)이고 또 하나는 배열을 위한 것(std::unique_ptr<T[]>)이다.
이 때문에 생 포인터와 달리 std::unique_ptr는, 어떤 종류의 개채(배열인지 개별 객체인지)를 가리키는지에 관련된 애매함이 절대 발생하지 않는다.
std::unique_ptr API는 사용 대상에 잘 맞는 형태로 설계되어 있다. 예를 들어 개별 객체 버전은 색인 적용 연산자(operator[])를 제공하지 않으며, 배열 버전은 역참조 연산자들(operator*와 operator->)을 제공하지 않는다.
하지만, 내장 배열보다는 std::array나 std::vector, std::string이 거의 항상 더 나은 선택이다. 그러므로 std::unique_ptr의 배열 버전은, 힙에 생성된 배열을 가리키는 생 포인터를 돌려주는 C 스타일 API를 다루어야 하는 경우를 제외하면 마땅히 쓸 이유를 찾기 힘들다.
- 기본적으로 자원 파괴는 delete를 통해 일어나나, 커스텀 삭제자를 지정할 수도 있다. 상태 있는 삭제자나 함수 포인터를 사용하면 std::unique_ptr 객체의 크기가 커진다.
자원의 파괴는 기본적으로 delete를 통해서 일어나지만, std::unique_ptr 객체를 생성할 때 커스텀 삭제자:custom deleter를 사용하도록 지정하는 것도 가능하다.
커스텀 삭제자는 해당 자원의 파괴 시점에서 호출되는 임의의 함수(또는 람다 표현식으로부터 산출되는 것들을 포함한 함수 객체)이다.
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;
}
1. delInvmt는 makeInvestment가 돌려준 객체에 대한 커스텀 삭제자이다.
모든 커스텀 삭제 함수는 파괴할 객체를 가리키는 포인터 하나를 받으며, 그 객체를 파괴하는 데 필요한 일들을 수행한다.
람다 표현식을 이용해서 delInvmt를 생성하는 것은 편리할 뿐만 아니라, 잠시 후에 보겠지만 통상적인 함수를 작성하는 것보다 더 효율적이기도 하다.
2. 커스텀 삭제자를 사용할 때에는 그 타입을 std::unique_ptr의 둘째 타입 인수로 지정해야 한다.
지금 예에서 삭제자의 타입은 delInvmt의 타입이며, makeInvestment의 반환 타입이 std::unique_ptr<Investment,
decltype(delInvmt)>인 것은 바로 그 때문이다(decltype에 관해서는 항목 3을 보라).
3. makeInvestment의 기본 전략은 널 std::unique_ptr를 만들어서 적절한 타입의 객체를 가리키게 한 후 돌려주는 것이다.
커스텀 삭제자 delInvmt를 pInv에 연관시키기 위해, pInv 생성 시 delInvmt를 둘째 타입으로 지정한다.
4. 생 포인터(이를테면 new로 얻은 포인터)를 std::unique_ptr에 대입하는 문장은 컴파일되지 않는다.
그런 문장을 허용한다면, 생 포인터에서 스마트 포인터로의 암묵적 변환이 성립하기 때문이다.
그런 암묵적 변환에는 문제가 있으므로, C++11의 스마트 포인터들은 그런 변환을 금지한다.
이 때문에, new로 생성한 객체의 소유권을 pInv에 부여하기 위해 reset을 호출했다.
5. 각 new 호출에서는 makeInvestment 함수에 전달된 인수들을 new에 완벽하게 전달하기 위해 std::forward를 사용했다(항목 25 참고).
이렇게 하면 호출자가 함수에 제공한 모든 정보를 함수 안에서 생성할 객체의 생성자에게 손실 없이 넘겨줄 수 있다.
6. 커스텀 삭제자는 Investment* 타입의 매개변수를 받는다.
makeInvestment 안에서 생성하는 객체의 실제 타입이 무엇이든, 그 객체는 람다 표현식 안에서 Investment* 객체로서 delete된다.
즉, 기반 클래스 포인터를 통해서 파생 클래스의 객체를 삭제하는 것이다.
이것이 제대로 작동하려면 기반 클래스, 즉 Investment의 소멸자가 가상 소멸자이어야 한다.
C++14는 함수 반환 타입의 추론을 지원하므로 (항목 3 참고), makeInvestment를 다음과 같이 좀 더 간결하고 캡슐화된 방식으로 구현할 수 있다.
template <typename... Ts>
auto makeInvestment(Ts&&... params)
{
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)를 사용할 때에는 std::unique_ptr객체의 크기는 생 포인터의 크기와 같다고 가정하는 것이 합당하다.
그러나 커스텀 삭제자를 사용하면 상황이 달라진다.
일반적으로, 함수 포인터를 삭제자로 지정한 경우에는 std::unique_ptr의 크기가 1 워드에서 2 워드로 증가한다.
삭제자가 함수 객체일 때에는 std::unique_ptr의 크기가 그 함수 객체에 저장된 상태의 크기만큼 증가한다.
상태 없는 함수 객체(이를테면 갈무리 없는 람다 표현식이 산출한)의 경우에는 크기 변화가 없으며, 따라서 가능하면 람다 표현식을 선호하는 것이 바람직하다.
auto delInvmt1 = [](Investment* pInvestment) // 상태 없는 람다 형태의 삭제자
{
makeLogEntry(pInvestment);
delete pInvestment;
}
template <typename... Ts> // 반환 타입은 Investment*와 같은 크기
std::unique_ptr<Investment, decltype(delInvmt1)> makeInvestment(Ts&&... args);
void delInvmt2(Investment* pInvestment) // 함수 형태의 삭제자
{
makeLogEntry(pInvestment);
delete pInvestment;
}
template <typename... Ts> // 반환 타입의 크기는 Investment*의 크기에 적어도 함수 포인터의 크기를 더한 것임!
std::unique_ptr<Investment, void (*)(Investment*)> makeInvestment(Ts&&... params);
상태가 많은 함수 객체를 사용한다면 std::unique_ptr의 크기가 상당히 커질 수 있다.
- std::unique_ptr를 std::shared_ptr로 손쉽게 변환할 수 있다.
팩터리 함수는 자신이 돌려준 객체를 호출자가 독점적으로 소유하려 하는지, 아니면 소유권을 공유하고자 하는지(std::shared_ptr에 해당) 미리 알 수 없다. 팩터리 함수가 std::unique_ptr를 반환한다면 호출자는 가장 효율적인 스마트 포인터를 얻게 되며, 상황에 따라 std::shared_ptr로 변환하여 쓸 수도 있다.
다음은 makeInvestment가 반환한 std::unique_ptr를 std::shared_ptr로 변환하는 코드이다.
std::shared_ptr<Investment> sp = makeInvestment( 인수들 );
하지만 반대로 std::shared_ptr을 std::unique_ptr로 변환하는 것은 불가능하다.
'PROGRAMMING > Effective Modern C++' 카테고리의 다른 글
20. std::shared_ptr 처럼 작동하되 대상을 잃을 수 도 있는 포인터가 필요하면 std::weak_ptr을 사용하라. (0) | 2021.03.11 |
---|---|
19. 소유권 공유 자원의 관리에는 std::shared_ptr을 사용하라. (0) | 2021.03.09 |
17. 특수 멤버 함수들의 자동 작성 조건을 숙지하라. (0) | 2021.03.09 |
16. const 멤버 함수를 스레드에 안전하게 작성하라. (0) | 2021.03.09 |
15. 가능하면 항상 constexpr을 사용하라. (0) | 2020.12.27 |
댓글