원문 게시: 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_ptr
로 std::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_
는 이전과 같이 기본 생성됩니다. 이후 Thing
은 data_
를 사용하여 생성됩니다. 클래스의 생성자는 필드를 선언된 순서대로 초기화하므로, 이 접근 방식은 이전과 동일한 순서로 객체를 초기화하지만 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`가 제대로 파괴됩니다.
-
std::unique_ptr
이름의 “unique”는 동일한 비-null 값을 보유하는 다른std::unique_ptr
이 없어야 한다는 아이디어를 나타내기 위해 선택되었습니다. 즉, 프로그램 실행 중 어느 시점에서든 비-null 상태의std::unique_ptr
간에 보유된 주소는 고유합니다. ↩