Abseil Tip 134 make_unique와 private 생성자

주간 팁 #134: make_uniqueprivate 생성자


원래 게시일: 2017년 5월 10일 (TotW #134)
작성자: Yitzhak Mandelbaum, Google 엔지니어
최종 업데이트: 2020년 4월 6일
빠른 링크: abseil.io/tips/134


개요

팁 #126을 읽고 이제 new를 사용하지 않으려고 결심했습니다.
모든 것이 잘 진행되고 있었지만, std::make_unique를 사용해 private 생성자를 가진 객체를 생성하려고 할 때 컴파일 오류가 발생했습니다.
이 문제의 구체적인 예를 살펴보고 원인을 이해한 다음 해결 방법을 논의해 보겠습니다.


예제: 위젯 생성

위젯을 나타내는 클래스를 정의한다고 가정합니다. 각 위젯에는 고유 식별자가 있으며, 이러한 식별자는 특정 제약 조건을 따라야 합니다.
이 제약 조건을 항상 보장하기 위해, Widget 클래스의 생성자를 private로 선언하고 사용자에게 적절한 식별자로 위젯을 생성할 수 있는 팩토리 함수 Make를 제공합니다. (팁 #42에서 팩토리 함수가 초기화 메서드보다 선호되는 이유를 확인하세요.)

class Widget {
 public:
  static std::unique_ptr<Widget> Make() {
    return std::make_unique<Widget>(GenerateId());
  }

 private:
  Widget(int id) : id_(id) {}
  static int GenerateId();

  int id_;
};

하지만 컴파일 시 다음과 같은 오류가 발생합니다:

error: calling a private constructor of class 'Widget'
    { return unique_ptr<_Tp>(new _Tp(std::forward<_Args>(__args)...)); }
                                 ^
note: in instantiation of function template specialization
'std::make_unique<Widget, int>' requested here
    return std::make_unique<Widget>(GenerateId());
                ^
note: declared private here
  Widget(int id) : id_(id) {}
  ^

이유는 Make 함수는 private 생성자에 접근할 수 있지만, std::make_unique는 접근할 수 없기 때문입니다.
이 문제는 친구(friend) 관계에서도 발생할 수 있습니다. 예를 들어, Widget 클래스의 친구가 std::make_unique를 사용하여 Widget을 생성하려고 해도 동일한 문제가 발생합니다.


권장 사항

다음 대안을 고려하세요:

1. newabsl::WrapUnique 사용 (이유 설명 포함)

// private 생성자에 접근하기 위해 `new`를 사용합니다.
return absl::WrapUnique(new Widget(...));

2. 생성자를 공개(public)로 변경

생성자가 공개되어도 안전하다면, 이를 공개로 변경하고 언제 직접 생성자를 사용할 수 있는지 명확히 문서화하세요.

많은 경우에 생성자를 private으로 설정하는 것은 과도한 설계일 수 있습니다.
이 경우 생성자를 공개로 선언하고 적절히 문서화하는 것이 가장 좋은 해결책입니다.
그러나 클래스 불변성을 보장해야 하므로 생성자를 private으로 유지해야 하는 경우, newWrapUnique를 사용하는 것이 적합합니다.


std::make_unique를 친구로 선언할 수 없을까?

std::make_unique (또는 absl::make_unique)를 친구로 선언하여 private 생성자에 접근할 수 있도록 하는 방법도 생각할 수 있습니다.
하지만 이는 좋은 방법이 아닙니다. 이유는 다음과 같습니다:

  1. “장거리 친구 선언”을 피해야 합니다.
    친구 선언은 관리 비용이 늘어나며, 스타일 가이드에서도 이를 지양할 것을 권장합니다. (Google C++ 스타일 가이드를 참고하세요.)

  2. 구현 세부 사항에 의존하게 됩니다.
    예를 들어, make_unique가 내부적으로 new를 호출한다는 가정에 의존합니다.
    나중에 구현이 변경되거나 다른 방식으로 new를 호출하게 된다면 이 가정은 깨질 수 있습니다.

  3. 접근 권한이 널리 퍼집니다.
    make_unique를 친구로 선언하면 모든 사용자가 이를 통해 객체를 생성할 수 있습니다.
    그렇다면 차라리 생성자를 공개로 선언하는 것이 더 나은 선택일 수 있습니다.


std::shared_ptr의 경우는?

std::shared_ptr에서는 상황이 다릅니다. absl::WrapShared와 같은 대체제가 없으며, std::shared_ptr<T>(new T(...))를 사용하는 경우 두 번의 메모리 할당이 발생합니다.
반면, std::make_shared는 한 번의 메모리 할당으로 처리됩니다.

이 차이가 중요하다면, 패스키(passkey) 패턴을 고려하세요.
이 패턴은 생성자가 특별한 토큰을 받도록 설계하며, 특정 코드에서만 이 토큰을 생성할 수 있습니다.

예제:

class Widget {
  class Token {
   private:
    explicit Token() = default;
    friend Widget;
  };

 public:
  static std::shared_ptr<Widget> Make() {
    return std::make_shared<Widget>(Token{}, GenerateId());
  }

  Widget(Token, int id) : id_(id) {}

 private:
  static int GenerateId();

  int id_;
};

위 코드에서 explicit 키워드를 사용하여 기본 생성자를 명시적으로 선언했습니다.
이는 C++17 이전에서는 Token 클래스가 집계형(aggregate)이 되어 {}를 사용한 초기화가 가능해지는 문제를 방지하기 위함입니다.


패스키 패턴에 대한 자세한 내용은 아래 자료를 참고하세요: