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

"Указатель на реализацию" или "pImpl" — это 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< // const-forwarding pointer wrapper
        std::unique_ptr<                // unique-ownership opaque pointer
            impl>> pImpl;               // to the forward-declared implementation class
};
// ---------------------------
// implementation (widget.cpp)
struct widget::impl
{
    // implementation details
};

Этот метод используется для создания интерфейсов библиотек C++ со стабильным ABI и для уменьшения зависимостей времени компиляции.

Содержание

Объяснение

Поскольку приватные члены-данные класса участвуют в представлении его объекта, влияя на размер и расположение, и поскольку приватные члены-функции класса участвуют в разрешении перегрузки (которое происходит до проверки доступа к членам), любое изменение этих деталей реализации требует перекомпиляции всех пользователей класса.

pImpl устраняет эту компиляционную зависимость; изменения в реализации не вызывают перекомпиляции. Следовательно, если библиотека использует pImpl в своем ABI, новые версии библиотеки могут изменять реализацию, оставаясь ABI-совместимыми со старыми версиями.

Компромиссы

Альтернативы идиоме pImpl:

  • inline реализация: приватные и публичные члены являются членами одного класса.
  • чисто абстрактный класс (OOP фабрика): пользователи получают уникальный указатель на легковесный или абстрактный базовый класс, детали реализации находятся в производном классе, который переопределяет его виртуальные функции-члены.

Компиляционный файрволл

В простых случаях и pImpl, и фабричный метод устраняют компиляционную зависимость между реализацией и пользователями интерфейса класса. Фабричный метод создает скрытую зависимость от vtable, поэтому переупорядочивание, добавление или удаление виртуальных функций-членов нарушает ABI. Подход pImpl не имеет скрытых зависимостей, однако если класс реализации является специализацией шаблона класса, преимущество компиляционного файрвола теряется: пользователи интерфейса должны видеть полное определение шаблона для инстанцирования корректной специализации. Распространенный подход проектирования в таком случае — рефакторинг реализации таким образом, чтобы избежать параметризации, это еще один вариант применения C++ Core Guidelines:

Например, следующий шаблон класса не использует тип T в своих приватных членах или в теле функции push_back :

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

Следовательно, приватные члены могут быть перенесены в реализацию как есть, и push_back может перенаправлять в реализацию, которая также не использует T в интерфейсе:

// ---------------------
// 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';
    }
};
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 каждый вызов приватной функции-члена осуществляется через указатель. Каждый доступ к публичному члену из приватного члена также осуществляется через другой указатель. Оба обращения пересекают границы единиц трансляции и могут быть оптимизированы только при помощи межмодульной оптимизации. Отметим, что ОО-фабрика требует обращения через границы единиц трансляции для доступа как к публичным данным, так и к деталям реализации, и предоставляет еще меньше возможностей для оптимизации на этапе линковки из-за виртуальных вызовов.
  • Накладные расходы памяти: pImpl добавляет один указатель к публичной компоненте и, если какой-либо приватный член требует доступа к публичному члену, либо добавляется другой указатель к компоненте реализации, либо передается как параметр для каждого вызова приватного члена, который в нем нуждается. Если поддерживаются stateful пользовательские аллокаторы, экземпляр аллокатора также должен храниться.
  • Накладные расходы управления временем жизни: pImpl (как и ОО-фабрика) размещают объект реализации в куче, что накладывает значительные runtime накладные расходы при создании и уничтожении. Это может быть частично компенсировано пользовательскими аллокаторами, поскольку размер выделения памяти для pImpl (но не для ОО-фабрики) известен на этапе компиляции.

С другой стороны, классы pImpl хорошо совместимы с семантикой перемещения; рефакторинг большого класса в перемещаемый pImpl может повысить производительность алгоритмов, работающих с контейнерами, содержащими такие объекты, хотя перемещаемый pImpl имеет дополнительный источник накладных расходов времени выполнения: любая открытая функция-член, которая разрешена для объекта после перемещения и требует доступа к закрытой реализации, влечет проверку нулевого указателя.

Затраты на обслуживание

Использование pImpl требует выделенного модуля трансляции (библиотека, состоящая только из заголовков, не может использовать pImpl), вводит дополнительный класс, набор переадресующих функций и, если используются аллокаторы, раскрывает деталь реализации использования аллокаторов в публичном интерфейсе.

Поскольку виртуальные члены являются частью интерфейсного компонента pImpl, мокирование pImpl подразумевает мокирование только интерфейсного компонента. Тестируемый pImpl обычно разрабатывается так, чтобы обеспечить полное покрытие тестами через доступный интерфейс.

Реализация

Поскольку объект типа интерфейса контролирует время жизни объекта типа реализации, указатель на реализацию обычно представляет собой std::unique_ptr .

Поскольку std::unique_ptr требует, чтобы указываемый тип был полным в любом контексте, где инстанцируется удалитель, специальные функции-члены должны быть объявлены пользователем и определены вне класса, в файле реализации, где класс реализации является полным.

Потому что когда константная функция-член вызывает функцию через указатель на неконстантный член, вызывается неконстантная перегрузка реализующей функции, поэтому указатель должен быть обёрнут в std::experimental::propagate_const или эквивалент.

Все приватные члены данных и все приватные невиртуальные функции-члены размещаются в классе реализации. Все публичные, защищённые и виртуальные члены остаются в интерфейсном классе (см. GOTW #100 для обсуждения альтернативных вариантов).

Если любому из приватных членов требуется доступ к public или protected члену, ссылка или указатель на интерфейс может быть передан приватной функции в качестве параметра. Альтернативно, обратная ссылка может поддерживаться как часть класса реализации.

Если предполагается поддержка нестандартных аллокаторов для выделения памяти под объект реализации, могут быть использованы любые из стандартных паттернов работы с аллокаторами, включая параметр шаблона аллокатора по умолчанию std::allocator и аргумент конструктора типа std::pmr::memory_resource* .

Примечания

Пример

Демонстрирует 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 : Идиома Fast Pimpl.
2. GotW #100 : Компиляционные файрволлы.
3. Паттерн Pimpl - что нужно знать.