스토리텔링 개발자

[Effective Modern C++] 22. unique_ptr를 사용한 pImpl 관용구의 특수 멤버 함수 본문

Effective C++/Effective Modern C++

[Effective Modern C++] 22. unique_ptr를 사용한 pImpl 관용구의 특수 멤버 함수

김디트 2025. 3. 7. 11:19
728x90

항목 22. pImpl 관용구를 사용할 때에는 특수 멤버 함수들을 cpp 파일에서 정의하라

 

 

 

pImpl 관용구
  • 구현부를 클래스 포인터로 대체하고, 그 포인터를 통해 자료 멤버들에 간접적으로 접근하는 기법
  • 각종 타입들을 헤더에서 포함시켜야 하므로 컴파일이 느려진다.
  • 또한 타입이 추가될 때마다 재컴파일이 필요하다.
  • 이를 해결하기 위해서 pImpl 관용구를 사용한다.

 

 

 

pImpl 구현
// 기존 구현
class Widget
{
public:
    Widget();
    ...
private:
    std::string name;
    std::vector<double> data;
    Gadget g1, g2, g3;
};
  • C++98에서는 아래와 같이 할 수 있었다.
// 헤더 파일
class Widget
{
public:
    Widget();
    ~Widget();
    ...
private:
    strcut Impl; // 선언만 하고 정의는 하지 않는다.(불완전 타입)
    Impl *pImpl; // raw 포인터 사용
};


// cpp 파일
struct Widget::Impl // 구현부 구조체
{
    std::string name;
    std::vector<double> data;
    Gadget g1, g2, g3;
};
Widget::Widget() : pImpl(new Impl) {}
Widget::~Widget() { delete pImpl; } // 해제를 위해 반드시 소멸자가 필요하다.
  • 모던 C++에서는 스마트 포인터를 사용할 수 있다.
// 헤더 파일
class Widget
{
public:
    Widget();
    ...
private:
    strcut Impl; // 선언만 하고 정의는 하지 않는다.(불완전 타입)
    std::unique_ptr<Impl> pImpl; // unique_ptr 사용
};


// cpp 파일
struct Widget::Impl // 구현부 구조체
{
    std::string name;
    std::vector<double> data;
    Gadget g1, g2, g3;
};
Widget::Widget() : pImpl(std::make_unique<Impl>()) {}
// 소멸자는 필요 없다.
  • 헌데 위 코드는 막상 Widget을 사용하려 하면 문제가 발생한다.
    • #include "widget.h"
      
      Widget w; // 컴파일 에러!!!
    • 컴파일러는 불완전 형식에 sizeof나 delete를 사용한다는 불평을 내뱉는다.
    • 이 문제는, w가 파괴될 때 컴파일러가 작성하는 코드에서 기인한다.

 

 

 

pImpl 파괴 시 코드 문제 상황
  • w의 소멸자를 호출한다.
  • std::unique_ptr을 이용하는 Widget 클래스에는 소멸자가 따로 선언되어 있지 않다.
    • 소멸자에서 할 일이 없으므로.
  • 컴파일러가 생성하는 특수 멤버 함수 규칙으로 인해 컴파일러가 대신 소멸자를 생성한다. (항목 17 참조)
  • 컴파일러는 그 소멸자 안에 Widget의 자료 멤버 pImpl의 소멸자를 호출하는 코드를 삽입한다.
  • pImpl은 std::unique_ptr<Widget::Impl> 이므로, unique_ptr의 기본 삭제자를 사용한다.
  • 그 기본 삭제자는 std::unique_ptr 안의 raw 포인터에 delete를 적용한다.
  • 근데 대부분의 표준 라이브러리 구현들에서 그 삭제자 함수는 delete를 적용하기 전에 적용할 대상이 불완전 타입인지를 static_assert를 이용해서 점검한다.
  • 헌데 불완전 타입이므로 컴파일 에러가 발생한다.
    • w가 파괴되는 지점 쪽을 지정하며 에러가 발생하는데, 특수 멤버 함수는 암묵적으로 inline이기 때문이다.
  • 즉, 파괴 시점에 Widget::Impl이 완전한 타입이 되게 하면 문제는 해결된다.

 

 

 

해결 방법
// 헤더 파일
class Widget
{
public:
    Widget();
    ~Widget(); // 선언만 한다.
    ...
private:
    strcut Impl; // 선언만 하고 정의는 하지 않는다.(불완전 타입)
    std::unique_ptr<Impl> pImpl; // unique_ptr 사용
};


// cpp 파일
struct Widget::Impl // 구현부 구조체
{
    std::string name;
    std::vector<double> data;
    Gadget g1, g2, g3;
};
Widget::Widget() : pImpl(std::make_unique<Impl>()) {}
Widget::~Widget() {} // ~Widget의 정의
  • 이렇게 Widget의 소멸자를 헤더에 선언, cpp에 정의하면 된다.
  • 물론 컴파일러가 자동으로 만든 소멸자가 문제 없다는 걸 명시하고 싶다면 아래처럼 해도 된다.
Widget::~Widget() = default;

 

 

 

pImpl과 이동 연산
  • 이동 연산자들은 소멸자가 선언되어 있으면 자동으로 작성되지 않는다.(항목 17 참조)
  • 컴파일러가 작성하는 이동 연산들이 적합하다고 가정하면, 기본 구현을 지정하면 되지 않을까?
// 헤더 파일
class Widget
{
public:
    Widget();
    ~Widget();
    
    // 이 역시 이동 연산을 처리할 때 컴파일 에러가 발생하는 코드!
    Widget(Widget&& rhs) = default;
    Widget& operator=(Widget&& rhs) = default;
    ...
private:
    strcut Impl;
    std::unique_ptr<Impl> pImpl;
};
  • 이동 할당 연산 시 pImpl을 재배정하기 전에 pImpl이 가리키는 객체를 파괴해야 하는데 그 시점에 불완전 타입이다!
  • 이동 생성자는 예외 발생 시 pImpl을 파괴하기 위한 코드를 작성하는데 파괴하려면 완전 타입이어야 한다.
  • 해결책은 동일하다.
// 헤더 파일
class Widget
{
public:
    Widget();
    ~Widget();
    
    Widget(Widget&& rhs);
    Widget& operator=(Widget&& rhs);
    ...
private:
    strcut Impl;
    std::unique_ptr<Impl> pImpl;
};

// cpp 파일
struct Widget::Impl // 구현부 구조체
{
    std::string name;
    std::vector<double> data;
    Gadget g1, g2, g3;
};
Widget::Widget() : pImpl(std::make_unique<Impl>()) {}
Widget::~Widget() = default;

// 구현부에 정의
Widget::Widget(Widget&& rhs) = default;
Widget& Widget::operator=(Widget&& rhs) = default;
  • 만약 커스텀 깊은 복사를 만들고 싶다면, 역시 같은 룰을 적용하여 구현부를 cpp에 구현해주면 된다.

 

 

 

shared_ptr을 사용한다면?
  • 이 항목의 조언이 더 이상 적용되지 않는다.
  • 소멸자를 선언할 필요가 없다.
    • 그러므로 이동 연산들은 컴파일러가 알아서 만들어준다.
// 헤더 파일
class Widget
{
public:
    Widget();
    ...
private:
    struct Impl;
    std::shared_ptr<Impl> pImpl;
};

// cpp 파일
// 아래는 모두 성공한다.
Widget w1;
auto w2(std::move(w1));
w1 = std::move(w2);
  • unique_ptr
    • 커스텀 삭제자가 타입에 반영된다.
    • 컴파일러는 더 작은 런타임 자료구조와 더 빠른 런타임 코드를 만들어낼 수 있다.
    • 하지만 특수 멤버 함수가 쓰이는 시점에서 대상 타입들이 완전한 타입이어야 한다는 제약이 있다.
  • shared_ptr
    • 커스텀 삭제자가 타입에 반영되지 않는다.
    • unique_ptr보다 다소 느리고 무겁다.
    • 하지만 대상 타입들이 완전한 타입이어야 한다는 제약이 사라진다.
  • pImpl의 경우 독점적 관계이므로 unique_ptr을 쓰는 게 가장 적합한 것은 확실하지만
    • 위 차이를 알아두는 건 의미가 있을 것이다.
728x90
Comments