KoreanFoodie's Study

Effective Modern C++ | 항목 18 : 소유권 독점 자원의 관리에는 std::unique_ptr 를 사용하라 본문

Tutorials/C++ : Advanced

Effective Modern C++ | 항목 18 : 소유권 독점 자원의 관리에는 std::unique_ptr 를 사용하라

GoldGiver 2022. 10. 26. 09:56

C++ 프로그래머의 필독서이자 바이블인, 스콧 마이어스의 Modern Effective C++ 를 읽고 기억할 내용을 요약하고 있습니다. 꼭 읽어보시길 추천드립니다!

항목 18 : 소유권 독점 자원의 관리에는 std::unique_ptr 를 사용하라

핵심 :

1. std::unique_ptr 는 독점 소유권 의미론을 가진 자원의 관리를 위한, 작고 빠른 이동 전용 똑똑한 포인터이다.
2. 기본적으로 자원 파괴는 delete 를 통해 일어나나, 커스텀 삭제자를 지정할 수 있다. 상태 있는 삭제자나 함수 포인터를 사용하면 std::unique_ptr 객체의 크기가 커진다.
3. std::unique_ptr 를 std::shared_ptr 로 손쉽게 변환할 수 있다.


C++ 에서 생 포인터(raw pointer)를 사용할 때, 우리는 종종 직관적으로 파악이 안 되거나 실수를 유발할 수 있는 위험요소들을 맞닥뜨리게 된다. 예를 들면 다음과 같다.
선언만 봐서는 객체를 가리키는지 배열을 가리키는지 구분이 안된다. 따라서 삭제도 어떻게 해야 하는지(delete vs delete []) 알 수 없다.

  1. 포인터가 가리키는 객체를 파괴해야 하는지, 구체적으로 어떻게 파괴해야 하는지 알 수 없다.
  2. 포인터가 가리키는 객체의 메모리 해제가 정확히 한 번 일어나는지 보장하기 어렵다.
  3. 포인터가 피지칭 객체를 잃었는지 알기 어렵다(dangling pointer).

이러한 문제점들을 해결하기 위해, 스마트 포인터가 등장했다.

스마트 포인터를 고를 때 가장 먼저 고려해야 할 것은 std::unique_ptr 로, std::unique_ptr 는 독점적 소유권(exclusive ownership) 의미론을 체현하고 있다. 널이 아닌 std::unique_ptr 는 항상 자신이 가리키는 객체를 소유하며, 이동 시 소유권이 원본 포인터에서 대상 포인터로 옮겨진다(원본 포인터는 널로 설정됨). 복사는 허용되지 않는다. 그런 의미에서, std::unique_ptr 는 이동 전용 형식(move-only type) 이다. 널이 아닌 std::unique_ptr 는 소멸 시 자신이 가리키는 자원을 파괴한다(기본적으로 생 포인터에 delete 적용).
std::unique_ptr 의 흔한 용도 중 하나인, 계통구조(hierarchy) 안의 객체를 생성하는 팩터리 함수의 반환 형식으로 쓰이는 예시를 보자.

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);

...

{
  auto pInvestment = makeInvestment( 인수들 );

  // pInvestment 파괴
}

std::unique_ptr 가 관리하는 객체를 파괴할 때는 기본적으로 delete 가 호출되지만, std::unique_ptr 객체를 생성할 때 커스텀 삭제자(custom deleter) 를 사용하도록 지정할 수도 있다. 예를 들어, 위의 Investment 객체가 파괴될 때 로깅을 하는 기능을 추가한다고 해 보자.

auto delInvmt = [](Investment* pInvestment)
{
  makeLogEntry(pInvestment);
  delete pInvestment;
};

// C++14 에서는 반환 타입을 auto 지정할 수도 있다
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;
}

삭제자의 타입은 auto 인데, 이는 std::unique_ptr 가 객체의 파괴가 정확히 한 번 일어남이 보장되므로, 자원의 파괴 방식에 대해 신경 쓸 필요가 전혀 없음을 방증한다.
위의 코드에서 알 수 있는 것들을 적어보자.

  • 삭제자를 사용할 때는 그 형식을 std::unique_ptr 의 둘째 인수로 지정해야 한다.
  • 생 포인터에서 스마트 포인터로의 암묵적 변환은 지원하지 않기 때문에, reset 을 사용했다.
  • 각 new 호출에서 인수들을 완벽하게 전달하기 위해 std::forward 를 사용했다.
  • 커스텀 삭제자는 Investment* 형식의 매개변수를 받는데, 이 객체는 람다 표현식 안에서 Investment* 객체로서 delete 된다. 즉, 기반 클래스 포인터를 통해 파생 클래스 객체를 삭제하고 있으므로, 이것이 제대로 작동하려면 기반 클래스의 소멸자가 가상 소멸자이어야 한다(virtual ~Investment();).


기본 삭제자(delete)를 사용할 때는 std::unique_ptr 객체의 크기가 생 포인터의 크기와 같으리라고 가정하는 것이 합당하다. 그러나 일반적으로 함수 포인터를 삭제자로 사용하면, std::unique_ptr 의 크기가 1 워드에서 2 워드로 증가한다. 삭제자가 함수 객체일 때는 그 크기가 함수 객체에 저장된 상태의 크기만큼 증가한다. 상태 없는 함수 객체(e.g. 갈무리 없는 람다 표현식이 산출한)의 경우에는 크기 변화가 없으며, 따라서 삭제자를 보통의 함수로 구현할 수도 있고, 갈무리 없는 람다 표현식으로 구현할 수 있는 경우라면 람다 쪽을 선호하는 것이 바람직하다.
아래의 경우처럼, 함수 형태의 삭제자는 반환 형식의 크기가 함수 포인터의 크기만큼 증가한다!

void delInvmt(Investment* pInvestment)
{
  makeLogEntry(pInvestment);
  delete pInvestment;
}

template<typename... Ts>
std::unique_ptr<Investment, void (*)(Investment*)>
makeInvestment(Ts&&... params);

상태가 많은 함수 객체 삭제자 사용으로 std::unique_ptr 객체의 크기가 커지면 설계 자체를 변경해야 할 수도 있다.

std::unique 는 두 가지 형태인데, 하나는 개별 배열의 위한 것(std::unique_ptr<T>) 이고, 하나는 배열을 위한 것(std::unique_ptr<T[]>) 이다. std::unique_ptr API 는 사용 대상에 잘 맞는 형태로 설계되어 있다.
배열용 std::unique_ptr 을 쓸 경우는 거의 없으므로 그냥 지적인 흥미 정도로 알아두자.

std::unique_ptr 는 std::shared_ptr 로의 변환이 쉽고 효율적인 특성을 갖고 있다.

std::shared_ptr<Investment> sp =
makeInvestment( 인수들 );

이는 std::unique_ptr 가 팩터리 함수의 반환 형식으로 아주 적합한 이유의 핵심적인 한 부분이다!

Comments