PImpl
"Указатель на реализацию" или "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:
- Встроенная реализация: закрытые члены и открытые члены являются членами одного и того же класса.
- Чисто абстрактный класс (фабрика ООП): пользователи получают уникальный указатель на лёгкий или абстрактный базовый класс, детали реализации находятся в производном классе, который переопределяет его виртуальные функции-члены.
Блокировка компиляции
В простых случаях и pImpl, и метод фабрики устраняют зависимость от компиляции между реализацией и пользователями интерфейса класса. Метод фабрики создаёт скрытую зависимость от vtable, и поэтому переупорядочение, добавление или удаление виртуальных функций-членов нарушает ABI. Подход pImpl не имеет скрытых зависимостей, однако, если класс реализации является специализацией шаблона класса, выгода от блокировки компиляции теряется: пользователи интерфейса должны наблюдать за всем определением шаблона, чтобы проинициализировать правильную специализацию. В этом случае распространённым подходом к проектированию является рефакторинг реализации таким образом, чтобы избежать параметризации, это ещё один пример использования C++ Core Guidelines:
- T.61 Не чрезмерно параметризуйте члены и
- T.84 Используйте нешаблонную основную реализацию для обеспечения ABI-стабильного интерфейса.
Например, следующий шаблон класса не использует тип 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 добавляет один указатель к общедоступному компоненту и, если какой-либо закрытый член нуждается в доступе к общедоступному члену, добавляется ещё один указатель к компоненту реализации или передаётся в качестве параметра для каждого вызова закрытого члена, который его требует. Если поддерживаются состоятельные пользовательские аллокаторы, также необходимо хранить экземпляр аллокатора.
- Накладные расходы на управление жизненным циклом: pImpl (а также фабрика ООП) помещает объект реализации в кучу, что накладывает значительные накладные расходы во время выполнения при создании и уничтожении. Это может быть частично компенсировано пользовательскими аллокаторами, поскольку размер выделения для pImpl (но не для фабрики ООП) известен на этапе компиляции.
С другой стороны, классы pImpl дружественны к перемещению; рефакторинг большого класса как перемещаемого pImpl может улучшить производительность алгоритмов, которые обрабатывают контейнеры, содержащие такие объекты, хотя у перемещаемого pImpl есть дополнительный источник накладных расходов во время выполнения: любая публичная функция-член, разрешённая для объекта, из которого выполнено перемещение, и которая нуждается в доступе к закрытой реализации, влечёт за собой проверку на нулевой указатель.
Накладные расходы на обслуживание
Использование pImpl требует отдельной трансляционной единицы (библиотека только с заголовками не может использовать pImpl), вводит дополнительный класс, набор функций-передач и, если используются аллокаторы, раскрывает детали реализации использования аллокатора в общедоступном интерфейсе.
Поскольку виртуальные члены являются частью компонента интерфейса pImpl, моделирование pImpl подразумевает моделирование только компонента интерфейса. Тестируемый pImpl обычно проектируется для обеспечения полного тестового покрытия с помощью доступного интерфейса.
Реализация
Так как объект типа интерфейса управляет жизненным циклом объекта типа реализации, указатель на реализацию обычно std::unique_ptr.
Поскольку std::unique_ptr требует, чтобы указываемый тип был полным типом в любом контексте, где инициализируется удалитель, специальные функции-члены должны быть объявлены и определены пользователем вне строки, в файле реализации, где класс реализации завершён.
Поскольку при вызове функции-члена const через неконстантный указатель на член вызывается неконстантный перегруз функции реализации, указатель должен быть заключён в 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; // 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 : Быстрый шаблон pimpl. |
| 2. | GotW #100: Блокировки компиляции. |
| 3. | Шаблон pimpl — что вам следует знать. |
© cppreference.com
Licensed under the Creative Commons Attribution-ShareAlike Unported License v3.0.
https://en.cppreference.com/w/cpp/language/pimpl