Abseil Tip 187 std::unique_ptr Must Be Moved"

원문 게시: 2020년 11월 5일, 주간 팁 #187

작성자: Andy Soffer

최종 수정: 2020-11-05

빠른 링크: abseil.io/tips/187

“첫 번째 장에 std::unique_ptr가 벽에 걸려 있다면, 두 번째 또는 세 번째 장에서는 반드시 이동되어야 합니다. 이동하지 않을 거라면 처음부터 거기 있으면 안 됩니다.” ~ 안톤 체호프의 말에 약간의 변형을 가하여

std::unique_ptr는 소유권 전달을 표현하기 위한 도구입니다. 만약 소유권이 한 std::unique_ptr에서 다른 std::unique_ptr로 전달되지 않는다면, 이 추상화는 거의 필요하거나 적합하지 않습니다.

std::unique_ptr란 무엇인가요?

std::unique_ptr는 자신이 가리키는 대상을 자동으로 파괴하는 포인터입니다. 이는 소유권(리소스를 파괴할 책임)을 타입 시스템의 일부로 전달하기 위해 존재하며, C++11의 중요한 추가 기능 중 하나입니다1. 하지만, std::unique_ptr는 흔히 과도하게 사용됩니다. 좋은 기준은 다음과 같습니다: 만약 한 std::unique_ptr에서 다른 std::unique_ptrstd::move되지 않는다면, std::unique_ptr로 만들지 않는 것이 좋습니다. 소유권을 전달하지 않는다면, 거의 항상 std::unique_ptr보다 더 나은 방법으로 의도를 표현할 수 있습니다.

std::unique_ptr의 비용

소유권이 전달되지 않는 경우, std::unique_ptr를 피해야 하는 여러 이유가 있습니다:

  • std::unique_ptr는 전달 가능한 소유권을 표현하는데, 소유권이 전달되지 않는다면 이는 불필요합니다. 우리는 필요한 의미를 가장 정확하게 전달할 수 있는 타입을 사용하는 것을 목표로 삼아야 합니다.
  • std::unique_ptr는 null 상태가 될 수 있으므로, null 상태를 실제로 사용하지 않는다면 이는 독자가 추가로 고려해야 할 부담을 줍니다.
  • std::unique_ptr<T>는 힙에 할당된 T를 관리하며, 이는 힙 할당 자체와 힙에 분산된 데이터로 인해 CPU 캐시에 적합하지 않을 수 있다는 성능 문제를 야기합니다.

흔한 안티 패턴: &를 피하기

다음과 같은 예제는 흔히 볼 수 있습니다.

int ComputeValue() {
  auto data = std::make_unique<Data>();
  ModifiesData(data.get());
  return data->GetValue();
}

이 예제에서 data는 소유권이 전달되지 않기 때문에 std::unique_ptr일 필요가 없습니다. 데이터는 스택에서 Data 객체로 선언된 경우와 동일한 시점에서 생성되고 파괴됩니다. 따라서 팁 #123에서 논의된 것처럼, 더 나은 선택은 다음과 같습니다:

int ComputeValue() {
  Data data;
  ModifiesData(&data);
  return data.GetValue();
}

흔한 안티 패턴: 지연 초기화

std::unique_ptr는 기본 생성 시 null 상태가 되고, std::make_unique로 새로운 값을 할당할 수 있기 때문에 지연 초기화 메커니즘으로 자주 사용됩니다. GoogleTest에서 SetUp을 사용하여 객체를 초기화하는 패턴이 특히 흔합니다.

class MyTest : public testing::Test {
 public:
  void SetUp() override {
    thing_ = std::make_unique<Thing>(data_);
  }

 protected:
  Data data_;
  // `SetUp()`에서 초기화되므로, 지연 초기화 메커니즘으로 `std::unique_ptr`을 사용.
  std::unique_ptr<Thing> thing_;
};

여기서도 소유권이 다른 곳으로 전달되지 않으므로 std::unique_ptr을 사용할 필요가 없습니다. 위 코드는 기본 생성자에서 모든 초기화를 처리할 수 있었습니다. SetUp과 생성자에 대한 자세한 내용은 GoogleTest FAQ를 참고하세요.

class MyTest : public testing::Test {
 public:
  MyTest() : thing_(data_) {}

 private:
  Data data_;
  Thing thing_;
};

이 예제에서, data_는 이전과 같이 기본 생성됩니다. 이후 Thingdata_를 사용하여 생성됩니다. 클래스의 생성자는 필드를 선언된 순서대로 초기화하므로, 이 접근 방식은 이전과 동일한 순서로 객체를 초기화하지만 std::unique_ptr 없이 동작합니다.

지연 초기화가 정말로 중요하고 피할 수 없다면, emplace() 메서드를 사용하는 std::optional을 고려하세요. 팁 #123에서 지연 초기화에 대해 더 자세히 다룹니다.

class MyTest : public testing::Test {
 public:
  MyTest() {
    Initialize(&data_);
    thing_.emplace(data_);
  }

 private:
  Data data_;
  std::optional<Thing> thing_;
};

주의사항

이것이 C++이기 때문에, std::unique_ptr가 이동되지 않더라도 유용한 경우가 물론 존재합니다. 그러나 이러한 상황은 드뭅니다. 이러한 경우 코드는 반드시 해당 미묘함을 설명하는 주석과 함께 제공되어야 합니다. 두 가지 예를 들어보겠습니다:

크고 드물게 사용되는 객체

객체가 가끔만 필요하다면, std::optional이 기본적인 선택입니다. 하지만, std::optional은 객체가 실제로 생성되었는지와 상관없이 공간을 예약합니다. 이 공간이 중요한 경우, std::unique_ptr을 사용하여 필요한 경우에만 할당하는 것이 의미 있을 수 있습니다.

레거시 API

많은 레거시 API는 소유된 데이터를 가리키는 원시 포인터를 반환합니다. 이러한 API는 종종 C++ 표준 라이브러리에 std::unique_ptr이 추가되기 이전에 만들어졌으며, 이 패턴은 새로운 코드에서 복사해서는 안 됩니다. 하지만, 결과 객체가 이동되지 않더라도, 이러한 레거시 API 호출은 메모리가 누수되지 않도록 std::unique_ptr로 래핑해야 합니다.

Widget *CreateLegacyWidget() { return new Widget; }

int func() {
  Widget *w = CreateLegacyWidget();
  return w->num_gadgets();
}  // 메모리 누수 발생!

객체를 std::unique_ptr로 래핑하면 두 가지 문제를 모두 해결할 수 있습니다:

int func() {
  std::unique_ptr<Widget> w = absl::WrapUnique(CreateLegacyWidget());
  return w->num_gadgets();
}  // `w`가 제대로 파괴됩니다.
  1. std::unique_ptr 이름의 “unique”는 동일한 비-null 값을 보유하는 다른 std::unique_ptr이 없어야 한다는 아이디어를 나타내기 위해 선택되었습니다. 즉, 프로그램 실행 중 어느 시점에서든 비-null 상태의 std::unique_ptr 간에 보유된 주소는 고유합니다.