Namespaces
Variants

PImpl

From cppreference.net
C++ language
General topics
Flow control
Conditional execution statements
Iteration statements (loops)
Jump statements
Functions
Function declaration
Lambda function expression
inline specifier
Dynamic exception specifications ( until C++17* )
noexcept specifier (C++11)
Exceptions
Namespaces
Types
Specifiers
constexpr (C++11)
consteval (C++20)
constinit (C++20)
Storage duration specifiers
Initialization
Expressions
Alternative representations
Literals
Boolean - Integer - Floating-point
Character - String - nullptr (C++11)
User-defined (C++11)
Utilities
Attributes (C++11)
Types
typedef declaration
Type alias declaration (C++11)
Casts
Memory allocation
Classes
Class-specific function properties
Special member functions
Templates
Miscellaneous

"Pointer to implementation" 또는 "pImpl"은 C++ 프로그래밍 기법 으로, 구현 세부 사항을 별도의 클래스에 배치하고 불투명 포인터를 통해 접근함으로써 클래스의 객체 표현에서 구현 세부 사항을 제거합니다:

// --------------------
// interface (widget.h)
struct widget
{
    // public members
private:
    struct impl; // 구현 클래스의 전방 선언
    // 하나의 구현 예시: 다른 설계 옵션과 장단점은 아래 참조
    std::experimental::propagate_const< // const 전달 포인터 래퍼
        std::unique_ptr<                // 독점 소유 불투명 포인터
            impl>> pImpl;               // 전방 선언된 구현 클래스에 대한
};
// ---------------------------
// implementation (widget.cpp)
struct widget::impl
{
    // implementation details
};

이 기법은 안정적인 ABI를 갖춘 C++ 라이브러리 인터페이스를 구성하고 컴파일 시간 의존성을 줄이기 위해 사용됩니다.

목차

설명

클래스의 private 데이터 멤버는 객체 표현에 참여하여 크기와 레이아웃에 영향을 미치고, 클래스의 private 멤버 함수는 overload resolution (멤버 접근 검사 이전에 발생함)에 참여하기 때문에, 이러한 구현 세부 사항을 변경할 때는 해당 클래스를 사용하는 모든 사용자의 재컴파일이 필요합니다.

pImpl은 이러한 컴파일 종속성을 제거합니다. 구현을 변경해도 재컴파일이 발생하지 않습니다. 결과적으로 라이브러리가 ABI에서 pImpl을 사용하는 경우, 라이브러리의 최신 버전은 이전 버전과 ABI 호환성을 유지하면서 구현을 변경할 수 있습니다.

트레이드오프

pImpl 관용구의 대안들은 다음과 같습니다

  • 인라인 구현: private 멤버와 public 멤버는 동일한 클래스의 멤버입니다.
  • 순수 추상 클래스 (OOP 팩토리): 사용자는 경량 또는 추상 베이스 클래스에 대한 고유 포인터를 획득하며, 구현 세부사항은 가상 멤버 함수들을 오버라이드하는 파생 클래스에 있습니다.

컴파일 방화벽

단순한 경우, pImpl과 팩토리 메서드 모두 클래스 인터페이스의 구현체와 사용자 간의 컴파일 타임 의존성을 제거합니다. 팩토리 메서드는 vtable에 대한 숨겨진 의존성을 생성하므로 가상 멤버 함수의 재정렬, 추가 또는 삭제는 ABI를 깨뜨립니다. pImpl 접근법에는 숨겨진 의존성이 없지만, 구현 클래스가 클래스 템플릿 특수화인 경우 컴파일 방화벽 이점이 사라집니다: 인터페이스 사용자는 올바른 특수화를 인스턴스화하기 위해 전체 템플릿 정의를 확인해야 합니다. 이러한 경우 일반적인 설계 접근법은 매개변수화를 피하는 방식으로 구현을 리팩토링하는 것이며, 이는 C++ 코어 가이드라인의 또 다른 사용 사례입니다:

예를 들어, 다음 클래스 템플릿은 private 멤버나 T 의 본문에서 push_back 타입을 사용하지 않습니다:

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

따라서 private 멤버들은 그대로 구현체로 이전될 수 있으며, push_back 는 인터페이스에서 T 를 사용하지 않는 구현체로 포워딩할 수 있습니다:

// ---------------------
// header (ptr_vector.hpp)
#include <memory>
class ptr_vector_base
{
    struct impl; // T에 의존하지 않음
    std::unique_ptr<impl> pImpl;
protected:
    void push_back_fwd(void*);
    void print() const;
    // ... 특수 멤버 함수들은 구현 섹션 참조
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';
    }
};
void ptr_vector_base::push_back_fwd(void* p) { pImpl->push_back(p); }
ptr_vector_base::ptr_vector_base() : pImpl{std::make_unique<impl>()} {}
ptr_vector_base::~ptr_vector_base() {}
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

런타임 오버헤드

  • 접근 오버헤드: pImpl에서는 private 멤버 함수에 대한 각 호출이 포인터를 통해 간접적으로 이루어집니다. private 멤버가 호출하는 public 멤버에 대한 각 접근은 또 다른 포인터를 통해 간접적으로 이루어집니다. 두 가지 간접 접근 모두 번역 단위 경계를 가로지르므로 링크 타임 최적화에 의해서만 최적화될 수 있습니다. OO 팩토리는 public 데이터와 구현 세부사항 모두에 접근하기 위해 번역 단위 간 간접 접근이 필요하며, 가상 디스패치로 인해 링크 타임 최적화 기회가 더 적습니다.
  • 공간 오버헤드: pImpl은 public 컴포넌트에 하나의 포인터를 추가하고, private 멤버가 public 멤버에 접근해야 하는 경우 구현 컴포넌트에 또 다른 포인터를 추가하거나 해당 접근이 필요한 private 멤버 호출마다 매개변수로 전달합니다. 상태를 가지는 사용자 정의 할당자를 지원하는 경우 할당자 인스턴스도 저장해야 합니다.
  • 수명 관리 오버헤드: pImpl(및 OO 팩토리)은 구현 객체를 힙에 배치하므로 생성 및 소멸 시 상당한 런타임 오버헤드가 발생합니다. pImpl(OO 팩토리는 제외)의 할당 크기는 컴파일 타임에 알려지므로 사용자 정의 할당자에 의해 부분적으로 상쇄될 수 있습니다.

반면에 pImpl 클래스는 이동에 친화적입니다. 큰 클래스를 이동 가능한 pImpl로 리팩토링하면 그러한 객체를 보유하는 컨테이너를 조작하는 알고리즘의 성능이 향상될 수 있지만, 이동 가능한 pImpl에는 런타임 오버헤드의 추가 원인이 있습니다: 이동된 객체에서 허용되고 비공개 구현에 액세스해야 하는 모든 public 멤버 함수는 null 포인터 검사를 발생시킵니다.

유지보수 오버헤드

pImpl의 사용은 전용 번역 단위를 필요로 하며(헤더 전용 라이브러리는 pImpl을 사용할 수 없음), 추가적인 클래스, 일련의 전달 함수를 도입하고, 할당자가 사용되는 경우 공개 인터페이스에서 할당자 사용의 구현 세부 사항을 노출합니다.

가상 멤버들은 pImpl의 인터페이스 구성 요소의 일부이므로, pImpl을 모킹한다는 것은 인터페이스 구성 요소만을 모킹하는 것을 의미합니다. 테스트 가능한 pImpl은 일반적으로 사용 가능한 인터페이스를 통해 완전한 테스트 커버리지를 허용하도록 설계됩니다.

구현

인터페이스 타입의 객체가 구현 타입 객체의 수명을 제어하기 때문에, 구현에 대한 포인터는 일반적으로 std::unique_ptr 입니다.

std::unique_ptr 는 삭제자가 인스턴스화되는 모든 컨텍스트에서 가리키는 타입이 완전한 타입이어야 하므로, 특수 멤버 함수는 사용자 선언되어야 하며 구현 클래스가 완전한 구현 파일에서 외부에 정의되어야 합니다.

const 멤버 함수가 비-const 멤버 포인터를 통해 함수를 호출할 때, 구현 함수의 비-const 오버로드가 호출되기 때문에, 포인터를 std::experimental::propagate_const 또는 이에 상응하는 것으로 래핑해야 합니다.

모든 private 데이터 멤버와 모든 private non-virtual 멤버 함수는 구현 클래스에 배치됩니다. 모든 public, protected, virtual 멤버들은 인터페이스 클래스에 유지됩니다(대안들에 대한 논의는 GOTW #100 참조).

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

기본이 아닌 할당자가 구현 객체의 할당을 지원하도록 의도된 경우, 할당자 템플릿 매개변수를 std::allocator 로 기본 설정하고 std::pmr::memory_resource* 타입의 생성자 인자를 포함하여 일반적인 할당자 인식 패턴 중 어느 것이든 활용할 수 있습니다.

참고 사항

예제

const 전파를 지원하는 pImpl을 보여줍니다: 역참조를 매개변수로 전달하고, 할당자 인식 없이, 런타임 검사 없이 이동 지원:

// ----------------------
// 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(const 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 관용구.
2. GotW #100 : 컴파일 방화벽.
3. Pimpl 패턴 - 알아야 할 사항.