pImpl

pImpl (Pointer to implementation)

클래스의 구현 세부를 객체 표현에서 떼어내고 싶을 때가 있어요. pImpl은 구현 클래스를 별도로 두고 불투명한 포인터로 접근하는 C++ 프로그래밍 기법이에요. 인터페이스는 그대로 두면서 구현만 바꿔도 ABI를 깨지 않고, 컴파일 의존성도 줄일 수 있어서 라이브러리 인터페이스를 설계할 때 자주 써요.

출처: cppreference

본문

"Pointer to implementation"(줄여서 pImpl)은 클래스의 구현 세부를 그 객체 표현에서 제거해, 별도의 클래스에 두고 불투명한 포인터(opaque pointer)로 접근하게 하는 C++ 프로그래밍 기법이에요.

// --------------------
// interface (widget.h)
struct widget
{
    // public members
private:
    struct impl; // forward declaration of the implementation class
    // One implementation example: see below for other design options and trade-offs
    std::experimental::propagate_const<std::unique_ptr<struct impl>> pImpl;               // to the forward-declared implementation class
};

// ---------------------------
// implementation (widget.cpp)
struct widget::impl
{
    // implementation details
};

이 기법은 ABI가 안정적인 C++ 라이브러리 인터페이스를 만들고, 컴파일 타임 의존성을 줄이는 데 쓰여요.

설명 (Explanation)

클래스의 비공개(private) 데이터 멤버는 그 객체 표현에 참여해서 크기와 레이아웃에 영향을 줘요. 또 비공개 멤버 함수는 (멤버 접근 검사보다 먼저 일어나는) 오버로드 결정에 참여해요. 그래서 이런 구현 세부를 바꾸려면 그 클래스를 쓰는 모든 사용자가 다시 컴파일되어야 해요.

pImpl은 이 컴파일 의존성을 제거해요. 즉 구현이 바뀌어도 재컴파일이 필요 없어요. 결과적으로 라이브러리가 ABI에 pImpl을 쓰면, 새 버전에서 구현을 바꿔도 이전 버전과 ABI 호환을 유지할 수 있어요.

트레이드오프 (Trade-offs)

pImpl 관용구의 대안으로는 다음이 있어요.

  • 인라인 구현: 비공개 멤버와 공개 멤버가 같은 클래스의 멤버로 존재.
  • 순수 추상 클래스(OOP 팩토리): 사용자가 가벼운/추상 베이스 클래스에 대한 유니크 포인터를 얻고, 구현 세부는 가상 멤버 함수를 오버라이드하는 파생 클래스에 둠.

컴파일 방화벽 (Compilation firewall)

단순한 경우, pImpl과 팩토리 메서드 모두 구현과 클래스 인터페이스 사용자 사이의 컴파일 타임 의존성을 제거해요. 하지만 팩토리 메서드는 vtable에 대한 숨은 의존성을 만들기 때문에, 가상 멤버 함수를 재배열·추가·제거하면 ABI가 깨져요. pImpl은 숨은 의존성이 없어요. 다만 구현 클래스가 클래스 템플릿의 특수화라면 컴파일 방화벽의 이점이 사라져요. 인터페이스의 사용자가 올바른 특수화를 인스턴스화하려면 템플릿 정의 전체를 봐야 하기 때문이에요. 이런 경우 흔한 설계 접근은 매개변수화를 피하도록 구현을 리팩터링하는 거예요. 이는 C++ Core Guidelines의 또 다른 사용 사례예요.

  • T.61 Do not over-parametrize members
  • T.84 Use a non-template core implementation to provide an ABI-stable interface.

예를 들어, 다음 클래스 템플릿은 비공개 멤버나 push_back의 본문에서 타입 T를 쓰지 않아요.

template<class T>
class ptr_vector
{
    std::vector<void*> vp;
public:
    void push_back(T* p)
    {
        vp.push_back(p);
    }
};

따라서 비공개 멤버는 그대로 구현으로 옮길 수 있고, push_back도 인터페이스에서 T를 쓰지 않는 구현으로 전달(forward)할 수 있어요.

// ---------------------
// header (ptr_vector.hpp)
#include <memory>

class ptr_vector_base
{
    struct impl; // does not depend on T
    std::unique_ptr<impl> pImpl;
protected:
    void push_back_fwd(void*);
    void print() const;
    // ... see implementation section for special member functions
public:
    ptr_vector_base();
    ~ptr_vector_base();
};

template<class T>
class ptr_vector : private ptr_vector_base
{
public:
    void push_back(T* p) { push_back_fwd(p); }
    void print() const { ptr_vector_base::print(); }
};

// -----------------------
// source (ptr_vector.cpp)
// #include "ptr_vector.hpp"
#include <iostream>
#include <vector>

struct ptr_vector_base::impl
{
    std::vector<void*> vp;

    void push_back(void* p)
    {
        vp.push_back(p);
    }

    void print() const
    {
        for (void const * const p: vp) std::cout << p << '\n';
    }
};

// the special member functions, which require a complete type
ptr_vector_base::ptr_vector_base() : pImpl{std::make_unique<impl>()} {}
ptr_vector_base::~ptr_vector_base() {}
void ptr_vector_base::push_back_fwd(void* p) { pImpl->push_back(p); }
void ptr_vector_base::print() const { pImpl->print(); }

// ---------------
// user (main.cpp)
// #include "ptr_vector.hpp"

int main()
{
    int x{}, y{}, z{};
    ptr_vector<int> v;
    v.push_back(&x);
    v.push_back(&y);
    v.push_back(&z);
    v.print();
}

가능한 출력:

0x7ffd6200a42c
0x7ffd6200a430
0x7ffd6200a434

런타임 오버헤드 (Runtime overhead)

  • 접근 오버헤드: pImpl에서 비공개 멤버 함수를 호출할 때마다 포인터를 한 번 거쳐요. 비공개 멤버가 공개 멤버에 접근할 때도 또 한 번 포인터를 거치죠. 두 간접 참조 모두 번역 단위 경계를 넘으므로 링크 타임 최적화(link-time optimization)로만 최적화할 수 있어요. OO 팩토리는 공개 데이터와 구현 세부 모두에 접근할 때 번역 단위 간 간접 참조가 필요하고, 가상 디스패치 때문에 링크 타임 최적화 기회가 더 적어요.
  • 공간 오버헤드: pImpl은 공개 구성 요소에 포인터 하나를 더하고, 비공개 멤버가 공개 멤버에 접근해야 하면 구현 구성 요소에 포인터를 하나 더하거나, 필요한 각 비공개 멤버 호출마다 매개변수로 전달해요. 상태를 가진 사용자 정의 할당자(stateful custom allocator)를 지원하면 할당자 인스턴스도 저장해야 해요.
  • 수명 관리 오버헤드: pImpl(그리고 OO 팩토리)은 구현 객체를 힙에 두므로 생성·파괴 시 상당한 런타임 오버헤드가 있어요. 사용자 정의 할당자로 일부 상쇄할 수 있는데, pImpl(하지만 OO 팩토리는 아님)의 할당 크기는 컴파일 타임에 알 수 있기 때문이에요.

반면 pImpl 클래스는 이동에 친숙(move-friendly)해요. 큰 클래스를 이동 가능한 pImpl로 리팩터링하면 그런 객체를 담은 컨테이너를 다루는 알고리즘 성능이 좋아질 수 있어요. 다만 이동 가능한 pImpl에는 런타임 오버헤드가 하나 더 있는데, 이동된(moved-from) 객체에서 허용되고 비공개 구현에 접근해야 하는 공개 멤버 함수마다 널 포인터 검사가 들어가요.

유지보수 오버헤드 (Maintenance overhead)

pImpl을 쓰려면 전용 번역 단위가 필요해요(헤더 전용 라이브러리는 pImpl을 쓸 수 없어요). 추가 클래스 하나, 전달 함수 한 묶음이 필요하고, 할당자를 쓰면 공개 인터페이스에 할당자 사용이라는 구현 세부가 드러나요.

가상 멤버는 pImpl의 인터페이스 구성 요소에 속하므로, pImpl을 mock하려면 인터페이스 구성 요소만 mock하면 돼요. 테스트 가능한 pImpl은 보통 사용 가능한 인터페이스를 통해 전체 테스트 커버리지를 허용하도록 설계돼요.

구현 (Implementation)

인터페이스 타입의 객체가 구현 타입의 객체의 수명을 제어하므로, 구현을 가리키는 포인터는 보통 std::unique_ptr이에요.

std::unique_ptr은 deleter가 인스턴스화되는 어느 문맥에서든 가리키는 타입이 완전 타입(complete type)이기를 요구해요. 그래서 특수 멤버 함수는 사용자 선언(user-declared)하고, 구현 클래스가 완전한 구현 파일에서 out-of-line으로 정의해야 해요.

const 멤버 함수가 비-const 멤버 포인터를 통해 함수를 호출하면 구현 함수의 비-const 오버로드가 호출되므로, 포인터는 std::experimental::propagate_const나 이에 상당하는 것으로 감싸야 해요.

모든 비공개 데이터 멤버와 모든 비공개 비가상 멤버 함수는 구현 클래스에 두어요. 공개·protected·가상 멤버는 모두 인터페이스 클래스에 남아요(GOTW #100에서 대안을 논의).

비공개 멤버가 공개 또는 protected 멤버에 접근해야 하면, 인터페이스에 대한 참조나 포인터를 비공개 함수에 매개변수로 전달할 수 있어요. 또는 역참조(back-reference)를 구현 클래스의 일부로 유지할 수도 있어요.

구현 객체 할당에 비기본 할당자를 지원하려면, std::allocator로 기본값이 정해지는 할당자 템플릿 매개변수, std::pmr::memory_resource* 타입의 생성자 인자 등 일반적인 할당자 인지(allocator awareness) 패턴을 쓸 수 있어요.

예제 (Example)

const 전파가 있는 pImpl, 역참조를 매개변수로 전달, 할당자 인지 없음, 런타임 검사 없는 이동(move) 활성화를 보여줘요.

// ----------------------
// interface (widget.hpp)
#include <experimental/propagate_const>
#include <iostream>
#include <memory>

class widget
{
    class impl;
    std::experimental::propagate_const<std::unique_ptr<impl>> pImpl;
public:
    void draw() const; // public API that will be forwarded to the implementation
    void draw();
    bool shown() const { return true; } // public API that implementation has to call
    
    widget(); // even the default ctor needs to be defined in the implementation file
              // Note: calling draw() on default constructed object is UB
    explicit widget(int);
    ~widget(); // defined in the implementation file, where impl is a complete type
    widget(widget&&); // defined in the implementation file
                      // Note: calling draw() on moved-from object is UB
    widget(const widget&) = delete;
    widget& operator=(widget&&); // defined in the implementation file
    widget& operator=(const widget&) = delete;
};

// ---------------------------
// implementation (widget.cpp)
// #include "widget.hpp"

class widget::impl
{
    int n; // private data
public:
    void draw(const widget& w) const
    {
        if (w.shown()) // this call to public member function requires the back-reference 
            std::cout << "drawing a const widget " << n << '\n';
    }

    void draw(widget& w)
    {
        if (w.shown())
            std::cout << "drawing a non-const widget " << n << '\n';
    }

    impl(int n) : n(n) {}
};

void widget::draw() const { pImpl->draw(*this); }
void widget::draw() { pImpl->draw(*this); }
widget::widget() = default;
widget::widget(int n) : pImpl{std::make_unique<impl>(n)} {}
widget::widget(widget&&) = default;
widget::~widget() = default;
widget& widget::operator=(widget&&) = default;

// ---------------
// user (main.cpp)
// #include "widget.hpp"

int main()
{
    widget w(7);
    const widget w2(8);
    w.draw();
    w2.draw();
}

출력:

drawing a non-const widget 7
drawing a const widget 8
  1. GotW #28: The Fast Pimpl Idiom.
  2. GotW #100: Compilation Firewalls.
  3. The Pimpl Pattern - what you should know.