[Modern C++] 항목 22: Pimpl 관용구를 사용할 때에는 특수 멤버 함수들을 구현 파일에서 정의하라

게시:     수정

카테고리:

태그: ,

이 글은 아래의 책을 정리하였습니다. 이펙티브 모던 C++, 스콧 마이어스 저자, 류광 번역

가독성이 떨어지는 직역들을 수정하며 정리하였습니다. e.g. 연역 → 추론, 중복적재 → 오버로딩

📦 4. 스마트 포인터

👉🏻 항목 22: Pimpl 관용구를 사용할 때에는 특수 멤버 함수들을 구현 파일에서 정의하라

🔍 Pimpl 관용구 개요

  • Pimpl 관용구(pointer to implementation idiom): 클래스의 자료 멤버들을 구현 클래스(또는 구조체)를 가리키는 포인터로 대체하고, 원래 쓰이던 자료 멤버들을 그 구현 클래스로 옮긴 뒤, 포인터를 통해 간접적으로 접근하는 기법
  • 빌드 시간을 줄이는 데 유용하다.
class Widget {                 // "widget.h" 헤더 파일 안에서
public:
  Widget();
  ...
private:
  std::string name;
  std::vector<double> data;
  Gadget g1, g2, g3;           // Gadget은 사용자 정의 타입
};
  • Widget의 자료 멤버가 std::string, std::vector, Gadget 타입이므로, 클라이언트는 <string>, <vector>, gadget.h를 반드시 포함해야 한다.
  • 이 헤더들 때문에 클라이언트의 컴파일 시간이 증가하고, 헤더 내용이 바뀌면 클라이언트도 다시 컴파일해야 한다.

🏷️ C++98 방식: raw 포인터 사용

class Widget {                 // 여전히 "widget.h" 헤더 안
public:
  Widget();
  ~Widget();                   // 소멸자가 필요함 (본문 설명 참고)
  ...
private:
  struct Impl;                 // 구현용 구조체와
  Impl *pImpl;                 // 그것을 가리키는 포인터를 선언
};
  • 선언만 하고 정의는 하지 않은 타입을 불완전 타입(incomplete type)이라고 한다. Widget::Impl이 바로 그런 타입.
  • 불완전 타입을 가리키는 포인터를 선언하는 것은 불완전 타입으로 할 수 있는 몇 안 되는 일 중 하나이며, Pimpl 관용구는 이 능력을 활용한다.
// "widget.cpp" 구현 파일 안에서
#include "widget.h"
#include "gadget.h"
#include <string>
#include <vector>

struct Widget::Impl {          // 전에 Widget에 있던 자료 멤버들을
  std::string name;            // 담은 Widget::Impl의 정의
  std::vector<double> data;
  Gadget g1, g2, g3;
};

Widget::Widget()                // 이 Widget 객체를 위한
  : pImpl(new Impl)             // 자료 멤버들을 할당
{}

Widget::~Widget()                // 이 객체를 위한 자료 멤버들을
{ delete pImpl; }                // 파괴
  • 이제 widget.hstd::string, std::vector, Gadget을 언급하지 않으므로 클라이언트는 관련 헤더를 포함할 필요가 없다 → 컴파일 속도 향상, 헤더 변경에 대한 영향 차단
  • 다만 이 코드는 raw 포인터와 new/delete를 직접 사용한다는 점에서 원시적이다.

✅ std::unique_ptr로 대체하기

class Widget {                       // 헤더 "widget.h" 안에서
public:
  Widget();
  ...
private:
  struct Impl;
  std::unique_ptr<Impl> pImpl;       // raw 포인터 대신 스마트 포인터를 사용한다
};
// "widget.cpp" 파일 안에서
#include "widget.h"
#include "gadget.h"
#include <string>
#include <vector>

struct Widget::Impl {                // 이전과 동일
  std::string name;
  std::vector<double> data;
  Gadget g1, g2, g3;
};

Widget::Widget()                      // 항목 21의 조언을 따라,
  : pImpl(std::make_unique<Impl>())   // std::make_unique를 이용해서
{}                                    // std::unique_ptr를 만든다
  • std::unique_ptr가 파괴될 때 자신이 가리키는 객체를 자동으로 삭제하므로, 소멸자를 따로 선언할 필요가 없다.
  • 그러나 이 코드는 다음과 같은 클라이언트 코드에서 컴파일 오류가 발생한다.
#include "widget.h"
Widget w;   // 오류!

⚠️ 오류의 원인: 불완전 타입과 컴파일러 생성 함수

  • w가 파괴되는 지점(범위를 벗어나는 지점)에서 w의 소멸자가 호출되는데, Widget에는 소멸자가 선언되어 있지 않으므로 컴파일러가 대신 소멸자를 작성한다.
  • 컴파일러가 작성한 소멸자 안에는 pImpl의 소멸자를 호출하는 코드가 들어간다. pImpl은 기본 삭제자를 사용하는 std::unique_ptr<Widget::Impl>이며, 그 삭제자는 delete를 적용하기 전에 C++11의 static_assert로 대상이 불완전 타입이 아닌지 점검한다.
  • Widget 객체 w의 파괴 코드가 생성되는 시점에는 Widget::Impl이 아직 불완전 타입이므로 static_assert가 실패하고 오류 메시지가 발생한다.
    • 컴파일러가 자동 작성하는 특수 멤버 함수는 암묵적으로 inline이기 때문에, 오류 메시지는 보통 w생성되는 행을 가리킨다.

🛠️ 해결책: 소멸자를 헤더에서 선언만, 구현 파일에서 정의

class Widget {                 // 이전처럼 "widget.h" 안에서
public:
  Widget();
  ~Widget();                   // 선언만 해둔다
  ...
private:
  struct Impl;
  std::unique_ptr<Impl> pImpl;
};
// 이전처럼 "widget.cpp" 안에서
#include "widget.h"
#include "gadget.h"
#include <string>
#include <vector>

struct Widget::Impl {          // Widget::Impl의 정의 (이전과 동일)
  std::string name;
  std::vector<double> data;
  Gadget g1, g2, g3;
};

Widget::Widget()                // 이전과 동일
  : pImpl(std::make_unique<Impl>())
{}

Widget::~Widget()                // ~Widget의 정의
{}
  • 소멸자의 정의가 Widget::Impl이 완전한 타입이 된 이후(구현 파일 안)에 만들어지므로 static_assert가 통과한다.
  • 컴파일러가 자동 작성한 소멸자 자체에는 문제가 없었으며, 단지 소멸자가 정의되는 위치가 문제였다는 점이 핵심이다.
  • 굳이 소멸자 본문에 넣을 코드가 없다면 다음과 같이 = default로 정의해도 동일한 효과를 낸다.
Widget::~Widget() = default;   // 앞에서와 같은 효과

🚚 이동 연산 지원하기

  • Pimpl 관용구를 사용하는 클래스는 바탕 std::unique_ptr에 대한 이동만 수행하면 되므로 이동 연산을 지원하기에 자연스러운 후보다.
  • 그러나 소멸자를 선언하면 컴파일러는 이동 연산들을 작성해 주지 않으므로, 이동을 지원하려면 직접 선언해야 한다.
class Widget {                                     // 여전히 "widget.h"
public:
  Widget();
  ~Widget();

  Widget(Widget&& rhs);                             // 선언만 하고
  Widget& operator=(Widget&& rhs);                  // 정의는 하지 않는다
  ...
private:
  struct Impl;
  std::unique_ptr<Impl> pImpl;
};
Widget::Widget()                                    // 이전과 동일
  : pImpl(std::make_unique<Impl>())
{}

Widget::~Widget() = default;                        // 이전과 동일

Widget::Widget(Widget&& rhs) = default;             // 여기서
Widget& Widget::operator=(Widget&& rhs) = default;  // 정의

클래스 정의 바깥에서 = default로 정의된 이동 연산들은 암묵적으로 noexcept로 선언된다. std::unique_ptr처럼 Widget도 예외를 방출하지 않게 만들고 싶다면, 이동 연산들을 선언과 정의 모두에서 명시적으로 noexcept로 선언하면 된다.

  • 이동 대입 연산자가 재대입 전에 pImpl이 가리키는 객체를 파괴해야 하고, 이동 생성자에서도 예외 발생 시 pImpl을 파괴하는 코드가 만들어지는데, 두 경우 모두 Impl이 완전한 타입이어야 한다. 소멸자와 마찬가지로 정의를 구현 파일로 옮기면 해결된다.

🧬 복사 연산 지원하기

  • Pimpl 관용구를 사용해도 클래스가 나타내는 개념 자체가 바뀌는 것은 아니다. 원래 Widget이 복사 가능한 타입들(std::string, std::vector, 복사 가능한 Gadget)로 이루어졌다면, Widget 역시 복사를 지원하는 것이 합당하다.
  • 복사 연산은 직접 정의해야 한다. 이유는 다음 두 가지다.
    • std::unique_ptr 같은 이동 전용 타입이 있는 클래스에 대해서는 컴파일러가 복사 연산을 작성해 주지 않는다.
    • 설령 작성한다 해도, 그 함수들은 std::unique_ptr 자체만 복사하는 얕은 복사(shallow copy)를 수행한다. 우리가 원하는 것은 포인터가 가리키는 대상까지 복사하는 깊은 복사(deep copy)다.
class Widget {                                     // 여전히 "widget.h" 안에서
public:
  ...                                               // 다른 함수들은 이전과 동일
  Widget(const Widget& rhs);                        // 이 함수들은
  Widget& operator=(const Widget& rhs);             // 선언만 해둔다
private:
  struct Impl;
  std::unique_ptr<Impl> pImpl;
};
// "widget.cpp" 안에서
#include "widget.h"
...
struct Widget::Impl { ... };                        // 이전과 동일

Widget::~Widget() = default;                        // 다른 함수들은 이전과 동일

Widget::Widget(const Widget& rhs)                   // 복사 생성자
  : pImpl(nullptr)
{ if (rhs.pImpl) pImpl = std::make_unique<Impl>(*rhs.pImpl); }

Widget& Widget::operator=(const Widget& rhs)        // 복사 대입 연산자
{
  if (!rhs.pImpl) pImpl.reset();
  else if (!pImpl) pImpl = std::make_unique<Impl>(*rhs.pImpl);
  else *pImpl = *rhs.pImpl;
  return *this;
}
  • 매개변수 rhs(복사 대입 연산자에서는 *this)가 이미 이동되어서 pImpl이 널일 수 있는 경우를 처리해 주어야 한다는 점만 빼면 구현은 간단하다.
  • 두 함수 모두 컴파일러가 Impl에 대해 작성해 준 복사 연산을 그대로 활용해서 각 필드를 자동으로 복사한다. 또한 두 함수 모두 new를 직접 사용하는 대신 std::make_unique를 사용하라는 항목 21의 조언을 따랐다.

🔀 std::unique_ptr 대신 std::shared_ptr를 쓴다면?

  • Widget 안의 pImpl 포인터가 Widget::Impl 객체를 독점적으로 소유하므로, 여기에 적합한 스마트 포인터는 std::unique_ptr다.
  • 흥미롭게도, std::shared_ptr를 사용해서 여러 WidgetImpl 구조체를 공유하게 만든다면 이 항목의 조언이 더 이상 적용되지 않는다.
    • 이 경우 Widget에서 소멸자를 선언할 필요가 없고, 사용자 선언 소멸자가 없으므로 컴파일러는 기꺼이 이동 연산들을 작성하며, 그 이동 연산들이 우리가 원하는 방식대로 작동한다.
class Widget {                       // "widget.h" 안에서
public:
  Widget();
  ...                                // 소멸자나 이동 연산의 선언이 전혀 없음
private:
  struct Impl;
  std::shared_ptr<Impl> pImpl;       // std::unique_ptr 대신 std::shared_ptr를 사용
};
Widget w1;
auto w2(std::move(w1));              // w2를 이동 생성
w1 = std::move(w2);                  // w1을 이동 대입
  • 모든 것이 문제없이 컴파일되고 기대한 대로 실행된다.
  • 이러한 차이는 두 스마트 포인터가 커스텀 삭제자를 지원하는 방식의 차이에서 비롯된다.
    • std::unique_ptr: 삭제자의 타입이 포인터 타입 자체의 일부 → 더 작은 자료구조와 더 빠른 코드를 낸다. 대신 컴파일러 작성 특수 멤버 함수가 쓰이는 시점에 가리키는 타입이 반드시 완전한 타입이어야 한다.
    • std::shared_ptr: 삭제자의 타입이 포인터 타입의 일부가 아님 → 자료구조는 더 크고 코드는 다소 느리지만, 완전한 타입 요구조건이 사라진다.
  • Pimpl 관용구의 경우 WidgetWidget::Impl 사이의 관계는 독점적 소유 관계이므로 std::unique_ptr가 적합한 수단임이 확실하다. 다만 소유권이 공유되는 다른 상황(std::shared_ptr가 더 적합한 경우)에서는 번거로운 멤버 함수 정의 과정을 겪지 않아도 된다는 점을 알아둘 만하다.

🧐 정리

  • Pimpl 관용구는 클래스 구현과 클래스 클라이언트 사이의 컴파일 의존성을 줄임으로써 빌드 시간을 감소한다.
  • std::unique_ptr 타입의 pImpl 포인터를 사용할 때에는 특수 멤버 함수들을 클래스 헤더에 선언하고 구현 파일에서 정의해야 한다. 컴파일러가 기본으로 작성하는 함수 구현이 사용하기에 적합한 경우에도 그렇게 해야 한다.
  • 위의 조언은 std::unique_ptr에 적용될 뿐, std::shared_ptr에는 적용되지 않는다.

Cpp 카테고리 내 다른 글 보러가기

댓글남기기