Пространства имён
Варианты
Действия

PImpl

Материал из cppreference.com
 
 
Язык С++
Общие темы
Управление потоком
Операторы условного выполнения
if
Операторы итерации (циклы)
Операторы перехода
Функции
Объявление функции
Выражение лямбда-функции
Спецификатор inline
Спецификации динамических исключений (до C++17*)
Спецификатор noexcept (C++11)
Исключения
Пространства имён
Типы
Спецификаторы
decltype (C++11)
auto (C++11)
alignas (C++11)
Спецификаторы длительности хранения
Инициализация
Выражения
Альтернативные представления
Литералы
Логические - Целочисленные - С плавающей запятой
Символьные - Строковые - nullptr (C++11)
Определяемые пользователем (C++11)
Утилиты
Атрибуты (C++11)
Types
Объявление typedef
Объявление псевдонима типа (C++11)
Casts
Неявные преобразования - Явные преобразования
static_cast - dynamic_cast
const_cast - reinterpret_cast
Выделение памяти
Классы
Свойства функции класса
explicit (C++11)
static
Специальные функции-элементы
Шаблоны
Разное
 

"Указатель на реализацию" или "pImpl" это метод программирования на C++, который удаляет детали реализации класса из его объектного представления, помещением их в отдельный класс, доступ к которому осуществляется через непрозрачный указатель:

// --------------------
// интерфейс (widget.h)
struct widget
{
    // открытые элементы
private:
    struct impl; // предварительное объявление класса реализации
    // Один пример реализации: смотрите ниже другие варианты дизайна и компромиссы
    std::experimental::propagate_const< // константная оболочка указателя переадресации
        std::unique_ptr<                // непрозрачный указатель уникального владения
            impl>> pImpl;               // на предобъявленный класс реализации
};

// ---------------------------
// реализация (widget.cpp)
struct widget::impl
{
    // детали реализации
};

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

Объяснение

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

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

Компромиссы

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

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

Брандмауэр компиляции

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

Например, следующий шаблон класса не использует тип 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; // не зависит от Т
    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(); }
};

// -----------------------
// исходник (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(); }

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

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

Накладные расходы на обслуживание

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

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

Реализация

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

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

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

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

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

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

Примечание

Пример

Демонстрирует pImpl с распространением const, с обратной ссылкой, переданной в качестве параметра, без учёта распределителя и с доступным перемещением без проверок во время выполнения:

// ----------------------
// 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; // открытый API, который будет передан в реализацию
    void draw();
    bool shown() const { return true; } // открытый API, который реализация должна вызывать
    
    widget(); // даже конструктор по умолчанию должен быть определён в файле реализации
              // Примечание: вызов draw() для созданного по умолчанию объекта является
              // неопределённым поведением
    explicit widget(int);
    ~widget(); // определён в файле реализации, где impl это полный тип
    widget(widget&&); // определён в файле реализации
                      // Примечание: вызов draw() для перемещённого объекта является 
                      // неопределённым поведением
    widget(const widget&) = delete;
    widget& operator=(widget&&); // определено в файле реализации
    widget& operator=(const widget&) = delete;
};

// ---------------------------
// реализация (widget.cpp)
// #include "widget.hpp"

class widget::impl
{
    int n; // частные данные
public:
    void draw(const widget& w) const
    {
        if(w.shown()) // этот вызов открытой функции-элемента требует обратной ссылки 
            std::cout << "рисование константного виджета " << n << '\n';
    }
    
    void draw(const widget& w)
    {
        if(w.shown())
            std::cout << "рисование неконстантного виджета " << 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;

// ---------------
// пользователь (main.cpp)
// #include "widget.hpp"

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

Вывод:

рисование неконстантного виджета 7
рисование константного виджета 8

Внешние ссылки

1.  GotW #28 : Идиома Fast Pimpl.
2.  GotW #100: Компиляция Брандмауэров.
3.  Паттерн Pimpl - что вам следует знать.