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 требует, чтобы тип, на который указывает указатель, был полным в любом контексте инстанцирования удалителя, специальные функции-члены должны быть объявлены пользователем и определены вне класса, в файле реализации, где класс реализации является полным типом.

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

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

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

Если для выделения памяти под объект реализации предполагается поддерживать аллокаторы, отличные от стандартного, можно использовать любой из обычных подходов к поддержке аллокаторов, включая параметр шаблона аллокатора со значением по умолчанию 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 : Идиома быстрого PImpl.
2. GotW #100: Барьеры компиляции.
3. Идиома Pimpl — что нужно знать.

© cppreference.com
Licensed under the Creative Commons Attribution-ShareAlike Unported License v3.0.
https://en.cppreference.com/cpp/language/pimpl

Spec-Zone.ru

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