Spec-Zone.ru › C++

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

Spec-Zone.ru

Настройки Оффлайн Что нового Помощь О нас
Spec-Zone .ru
спецификации, руководства, описания, API