본문 바로가기
PROGRAMMING/Effective Modern C++

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

by La-KanTo 2021. 3. 13.

- Pimpl 관용구는 클래스 구현과 클래스 클라이언트 사이의 컴파일 의존성을 줄임으로써 빌드 시간을 감소한다.

예를 들어 다음과 같은 모습의 Widget 클래스가 있다고 하자.

// "Widget.h" 헤더 파일 안에서
class Widget {                        
public:
    Widget(void);

private:
    std::string name;
    std::vector<double> data;
    Gadget g1, g2, g3; // Gadget은 어떤 사용자 정의 타입
};

Widget의 데이터 멤버들이 std::string, std::vector, Gadget 타입이므로, Widget을 컴파일하려면 그 타입들의 헤더가 있어야 한다.

즉, Widget의 클라이언트는 반드시 #include를 이용해서 <string>, <vector>, gadget.h 를 포함해야 한다.
이 헤더들 때문에 컴파일 시간이 증가하며, 한 헤더의 내용이 변하면 Widget 클라이언트도 반드시 다시 컴파일해야 한다.
표준 헤더인 <string>과 <vector>는 자주 바뀌지 않지만, gadget.h는 자주 바뀔 수 있다.
C++98에서는 Pimpl 관용구를 Widget에 다음과 같이 적용했을 것이다.

// 여전히 "Widget.h" 헤더 안
class Widget {                        
public:
    Widget(void);
    ~Widget(void);

private:
    struct Impl; // 구현용 구조체와 그것을
    Impl* pImpl; // 가리키는 포인터를 선언
};

이제는 Widget이 std::string이나 std::vector, Gadget 타입을 언급하지 않으므로, Widget의 클라이언트는 그 타입들의 헤더를 #include로 포함시킬 필요가 없다.
이 덕분에 위에서 언급한 문제들이 해소된다.
선언만 하고 정의는 하지 않은 Widget::Impl과 같은 타입을 불완전 타입(incomplete type)이라고 부르기도 한다.
불완전 타입을 가리키는 포인터를 선언하는 것은 불완전 타입으로 할 수 있는 몇 안 되는 일 중 하나이고, Pimpl 관용구는 바로 그러한 능력을 활용한다.

둘째 단계는 원래의 클래스에서 사용하던 데이터 멤버들을 담는 객체를 동적으로 할당, 해제하는 코드를 추가하는 것이다.
이런 코드들은 클래스를 구현하는 소스 코드 파일에 둔다.

// 구현 파일 "widget.cpp" 안에서
#include "widget.h"                    
#include "gadget.h"
#include <string>
#include <vector>

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

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

Widget::~Widget(void) // 이 객체를 위한 데이터
{
    delete pImpl;
}

Widget이 파괴될 때 Widget::Impl객체도 파괴되어야 하므로 Widget의 소멸자가 꼭 필요하다.
하지만 이는 C++98용 코드이며, 지난 세기의 악취 나는 유산이 그대로 남아 있다.

 

이러한 상황에서 스마트 포인터를 사용하는 것이다. 지금 상황에서 가장 필요한 수단은 std::unique_ptr (항목 18 참고)임이 분명해 보인다.
다음은 위의 예제를 std::unique_ptr을 사용하도록 고친 예이다.

 

// 헤더 "widget.h" 안에서
class Widget { 
public:
    Widget(void);

private:
    struct Impl;
    std::unique_ptr<Impl> pImpl;    // 생 포인터 대신 스마트 포인터를 사용한다.
};

// 다음은 이에 맞게 수정된 구현 파일이다.
#include "widget.h"                    // "widget.cpp" 파일 안에서
#include "gadget.h"
#include <string>
#include <vector>

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

Widget::Widget(void)
    : pImpl(std::make_unique<Impl>())    
{}                                    

이제는 Widget 클래스에 소멸자가 없다.
pImpl의 파괴를 std::unique_ptr의 소멸자가 대신 해주기 때문이다.
문제는 이 코드 자체는 잘 컴파일되지만, 다음과 같이 자명한 클라이언트 쪽 용법은 컴파일되지 않는다.

#include "widget.h"
Widget w; // 오류!

 

- std::unique_ptr 타입의 Pimpl 포인터를 사용할 때에는 특수 멤버 함수들을 클래스 헤더에 선언하고 구현 파일에서 구현 해야 한다. 컴파일러가 기본적으로 작성하는 함수 구현들이 사용하기에 적합한 경우에도 그렇게 해야 한다.

Pimpl 관용구 예제 코드에서, std::unique_ptr을 이용하여 Pimpl 관용구를 구현한 Widget 객체를 클라이언트 쪽에서 생성하려고 하면 컴파일이 되지 않는다는 것을 보았을 것이다. 지금부터 그 이유를 알아보도록 하자.

구체적인 컴파일 오류 메시지는 컴파일러마다 다르겠지만, 대부분 불완전한 타입에 sizeof나 delete를 적용하는 것과 관련된 불평이 메시지에 포함되어 있을 것이다. 그런 연산들은 불완전한 타입으로 할 수 있는 몇 안 되는 일에 포함되지 않는다.

이 문제는 클라이언트 쪽에서 선언한 Widget 객체 w가 파괴되는 시점에 대해 컴파일러가 작성하는 코드에서 기인한다.

그 지점에서 w의 소멸자가 호출되는데, std::unique_ptr를 이용하는 Widget 클래스에 따로 소멸자를 선언하지 않았다. 이 경우, 컴파일러가 대신 소멸자를 작성해 준다(항목 17 참고).

컴파일러는 그 소멸자 안에 Widget의 데이터 멤버 pImpl의 소멸자를 호출하는 코드를 삽입한다. pImpl은 std::unique_ptr<Widget::Impl>, 즉 기본 삭제자를 사용하는 std::unique_ptr이고, 그 기본 삭제자는 std::unique_ptr안에 있는 생 포인터에 대해 delete를 적용하는 함수이다.

그런데 대부분의 표준 라이브러리 구현들에서 그 삭제자 함수는 delete를 적용하기 전에, 혹시 생 포인터가 불완전한 타입을 가리키지 않는지를 C++11의 static_assert를 이용해서 점검한다.

컴파일러가 w의 파괴를 위한 코드를 산출하는 과정에서 일반적으로 그 static_assert가 참이 아닌 것으로 판명되며, 그러면 앞에서 언급한 오류 메시지가 나타난다.

그 메시지는 w가 파괴되는 시점과 연관되는데, 이는 컴파일러가 자동 작성하는 다른 모든 특수 멤버 함수처럼 Widget의 소멸자는 암묵적으로 inline이기 때문이다.

오류 메시지 자체는 소스 코드에서 w가 생성되는 행 번호를 가리키는 경우가 많다. 이후의 암묵적 파괴로 이어지는 객체를 명시적으로 생성하는 부분이 바로 그 행이기 때문이다.

std::unique_ptr<Widget::Impl>을 파괴하는 코드가 만들어지는 지점에서 Widget::Impl이 완전한 타입이 되게 하면 문제가 바로 해결된다. 컴파일러는 타입의 정의를 보게 되면 그 타입을 완전한 타입으로 간주한다. 그리고 Widget::Impl의 정의는 widget.cpp에 있다.

따라서, Widget::Impl의 정의 이후에 컴파일러가 그 소스 파일에만 있는 Widget의 소멸자의 본문(즉, 컴파일러가 std::unique_ptr 데이터 멤버를 파괴하는 코드를 작성하는 곳)을 보게 한다면 클라이언트 코드가 문제없이 컴파일된다.

항목 17에 나오듯이, Widget에 소멸자를 선언하면 컴파일러는 이동 연산들을 작성하지 않는다. 따라서 이동 연산들을 지원하려면 해당 이동 연산들을 직접 선언해야 한다. 그러나 이동 연산들 역시 소멸자와 마찬가지 이유로, 이동 연산들의 정의가 구현 파일에 있어야 한다.

복사 연산이 필요하다면 복사 연산도 직접 작성해야 한다.

왜냐하면 (1) std::unique_ptr 같은 이동 전용 타입이 있는 클래스에 대해서는 컴파일러가 복사 연산들을 작성해 주지 않으며, (2) 작성한다고 해도, 작성된 함수들은 std::unique_ptr 자체만 복사하는 얕은 복사(shallow copy)를 수행하기 때문이다.


std::unique_ptr을 사용해서 Pimpl을 구현한다면 특수 멤버 함수들을 직접, 그리고 구현 파일에 작성해야 한다.
앞에서 보았던 예제를 이에 맞게 고쳐 보았다.

// 이전처럼 "widget.h" 안에서
class Widget { 
public:
    Widget(void);
    ~Widget(void); // 선언만 해준다.

private:
    struct Impl;
    std::unique_ptr<Impl> pImpl;
};

Widget.cpp 에서는 Widget::Impl 정의 다음에서 Widget의 소멸자를 정의한다.

// 이전처럼 "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(void)
    : pImpl(std::make_unique<Impl>())
{}

Widget::~Widget(void) // ~Widget의 정의
{}

컴파일러가 자동 작성한 소멸자 자체에 딱히 문제가 있는 것이 아니라는 점을 강조하고 싶다면, 다음과 같이 소멸자를 정의할 수 있다.

Widget::~Widget(void) = default; // 앞에서와 같은 효과

컴파일러가 자동으로 작성하는 이동 연산들은 std::unique_ptr의 이동 연산들을 호출해 준다.
하지만 소멸자를 명시적으로 선언하면, 컴파일러는 이동 연산들을 작성하지 않는다.
따라서 이동을 지원하려면 해당 이동 연산들을 직접 선언해야 한다.

class Widget { // 여전히 "widget.h"
public: 
    Widget(void);
    ~Widget(void);

    Widget(Widget&& rhs) = default;               // 발상은 좋았지만
    Widget& operator=(Widget&& rhs) = default;    // 코드는 틀렸음!

private: 
    struct Impl;
    std::unique_ptr<Impl> pImpl;
};

 

그러나 소멸자에서 발생했던 문제가 동일하게 이동 연산들에서도 발생한다.
이동 생성자에서는 상황이 좀 다르다.
이 경우 문제는, 일반적으로 컴파일러는 이동 생성자 안에서 예외가 발생했을 때 pImpl을 파괴하기 위한 코드를 작성하는데, pImpl을 파괴하려면 Impl이 완전한 타입이어야 한다는 것이다.

문제가 이전과 동일하므로 그 해결책도 동일하다. 즉 이동 연산들의 정의를 구현 파일로 옮기면 된다.

// 여전히 "widget.h"
class Widget { 
public:
    Widget(void);
    ~Widget(void);

    Widget(Widget&& rhs); // 선언만 하고
    Widget& operator=(Widget&& rhs); // 정의는 하지 않는다.

private:
    struct Impl;
    std::unique_ptr<Impl> pImpl;
};
struct Widget::Impl { };

Widget::Widget(void)
    : pImpl(std::make_unique<Impl>())
{}

Widget::~Widget(void) = default;

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

 

복사 생성자도 필요하다면 마찬가지로, 헤더 파일에는 선언만 하고 구현 파일에서 구현한다.

// "widget.h"
class Widget {
public:
    Widget(const Widget& rhs); // 이 함수들은
    Widget& operator=(const Widget& rhs); // 선언만 해둔다.

private:                                // 이전과 동일
    struct Impl;
    std::unique_ptr<Impl> pImpl;
};

// "widget.cpp"
struct Widget::Impl {};

Widget::~Widget(void) = 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;
}

pImpl이 이미 이동되어서 널인지 점검하는 부분만 주의하면 구현이 그리 어렵지는 않을 것이다.

 

- 위의 조언은 std::unique_ptr에 적용될 뿐, std::shared_ptr에는 적용되지 않는다.
std::unique_ptr과 달리 std::shared_ptr은, 특수 멤버 함수들을 구현 파일에 구현하지 않아도 전혀 문제가 되지 않는다. 컴파일러가 알아서 만들어 주는 특수 멤버 함수만으로도 충분하다. 이 차이는 커스텀 삭제자를 지원하는 방식의 차이에서 비롯된 것이다.

std::unique_ptr에서 삭제자의 타입은 해당 스마트 포인터 타입의 일부이며, 이 덕분에 컴파일러는 더 작은 실행시점 자료구조와 더 빠른 실행시점 코드를 만들어 낼 수 있다. 그 대신 컴파일러가 작성한 특수 멤버함수가 쓰이는 시점에서 피지칭 타입들이 완전한 타입들이어야 한다.

반면 std::shared_ptr에서는 삭제자의 타입이 스마트 포인터 타입의 일부가 아니다. 이 때문에 실행시점 자료구조가 더 커지고 실행 코드가 다소 더 느려지지만, 컴파일러가 작성한 특수 멤버 함수들이 쓰이는 시점에서 피지칭 타입들이 완전한 타입이어야 한다는 요구 조건이 사라진다.

 

std::unique_ptr과는 달리 std::shared_ptr의 경우에는 특수 멤버 함수들을 반드시 구현 파일에서 구현하지 않아도 된다.

// "widget.h"
class Widget {                        
public:
    Widget(void);
    // 소멸자나 이동 연산들의 선언이 전혀 없음
private:
    struct Impl;
    std::shared_ptr<Impl> pImpl;    // std::unique_ptr 대신 std::shared_ptr를 사용
};                                    

// cpp 파일

Widget w1;

auto w2(std::move(w1));                // w2를 이동 생성
w1 = std::move(w2);                    // w1을 이동 대입

모든 것이 제대로 컴파일되고, 우리가 기대한 대로 실행된다.
w1은 기본 생성되고, 그 값이 w2로 이동하고, 그 값이 다시 w1로 이동하고, w1과 w2 둘다 파괴된다 (따라서 피지칭 Widget::Impl 객체도 파괴된다).

 

댓글