Pull to refresh

Перевод статьи «Pimp My Pimpl», часть 1

Reading time7 min
Views26K
В первой части статьи рассматривается классическая идиома Pimpl (pointer-to-implementation, указатель на реализацию), показываются её преимущества и рассматривается дальнейшее развитие идиом на её основе. Вторая часть будет сосредоточена на том, как уменьшить недостатки, которые неизбежно возникают при использовании Pimpl.

Ссылки на оригинал


Это перевод первой части статьи с сайта Heise Developer. Оригиналы обеих частей находятся здесь: часть 1, часть 2
Перевод был сделан с английского перевода отсюда.

Аннотация


Многое было написано об идиоме со смешным названием Pimpl (прим. пер.: созвучно с англ. pimple — прыщ), также известной как d-указатель, фаерволл компилятора и Чеширский кот. Heise Developer освещает некоторые стороны этой практической конструкции, которая выходит за рамки классической техники.

Классическая идиома


Каждый программист на C++ наверное сталкивался с описанием класса наподобие этого:

class Class
{
    // ...
private:
    class Private; // предварительное объявление
    Private *d;    // сокрытие деталей реализации
};

Здесь поля данных класса Class перенесены во вложенный класс Class::Private. Экземпляры класса Class будут содержать только указатель d на объекты Class::Private.

Чтобы понять, почему автор класса использовал такое сокрытие, нужно вернуться назад и взглянуть на систему модулей C++. В отличие от многих других языков, C++, наследник языка C, не имеет встроенной поддержки модулей (эта поддержка была предложена для C++0x, но её не включили в окончательный стандарт). Взамен используется подход, при котором объявление функций модуля (но обычно не их описание) помещается в заголовочные файлы, которые становятся доступными для других модулей с помощью директивы препроцессора #include. Такой подход наделяет заголовочные файлы двойной ролью: с одной стороны, они являются интерфейсом модуля. С другой стороны — местом объявления возможных деталей внутренней реализации.

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

struct Class;                         // предварительное объявление
typedef struct Class * Class_t;       // -> только приватные данные
void Class_new(Class_t *cls);         // Class::Class()
void Class_release(Class_t cls);      // Class::~Class()
int Class_f(Class_t cls, double num); // int Class::f(double)
// ...

К сожалению, это не работает в C++. Методы должны быть объявлены внутри класса. Классы без методов были бы бесполезны, поэтому в заголовочных файлах C++ обычно присутствуют описания классов. Так как тело класса, в отличие от пространства имен, не может быть повторно открыто, заголовочный файл должен содержать все объявления (полей данных и методов):

class Class
{
public:
    // ... публичные методы ... ok

private:
    // ... приватные данные и методы ... не хотелось бы, чтобы они были здесь
};

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

// --- class.h ---
class Class
{
public:
    Class();
    ~Class();

    // ... публичные методы ...

    void f(double n);

private:
    class Private;
    Private *d;
};

// -- class.cpp --
#include "class.h"

class Class::Private
{
public:
    // ... приватные данные и методы ...
    bool canAcceptN(double num) const { return num != 0 ; }
    double n;
};

Class::Class()
    : d(new Private) {}

Class::~Class()
{
    delete d;
}

void Class::f(double n)
{
    if (d->canAcceptN(n))
        d->n = n;
}

Поскольку Class::Private используется только при объявлении переменной-указателя, т.е. «только по имени» (Lakos), а не «по размеру», достаточно предварительного объявления, как и в случае с чистым C. Все методы класса Class теперь будут обращаться к приватным методам и полям данных класса Class::Private только через поле d.

Таким образом, мы получаем удобства системы полностью инкапсулируемых модулей в C++. Из-за применения промежуточной переменной, за полученные преимущества приходится платить накладным выделением памяти (new Class::Private), косвенными обращениями к полям данных, а также полным отказом (по крайней мере в секции public) от inline методов. Как будет показано во второй части статьи, семантика константных методов также изменяется.

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

Преимущества идиомы Pimpl


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

Например, отдел Nokia «Qt Development Frameworks» (ранее Trolltech) по крайней мере дважды за время разработки библиотеки классов «Qt 4» вносил глубокие изменения в рендеринг виджетов без необходимости перекомпоновки (relink) приложений, использующих Qt 4.

Не стоит недооценивать значительное ускорение сборки при использовании идиомы Pimpl, особенно в больших проектах. Ускорение сборки происходит из-за уменьшения количества директив #include в заголовочных файлах и по причине значительного уменьшения частоты внесения изменений в заголовочные файлы классов Pimpl. В книге «Решение сложных задач на C++» («Exceptional C++») Герб Саттер отмечает неизменное удвоение скорости компиляции, а Джон Лакос даже утверждает, что сборка ускоряется на два порядка.

Еще одно достоинство применения Pimpl: классы с d-указателями хорошо подходят для транзакционно-ориентированного и безопасного относительно исключений кода. Например, разработчик может использовать идиому Copy-Swap (Саттер, Александреску «Стандарты программирования на C++», пункт 56), чтобы создать транзакционный (всё-или-ничего) копирующий оператор присвоения:

class Class
{
    // ...
    void swap(Class &other)
    {
        std::swap(d, other.d);
    }
    
    Class &operator=(const Class &other)
    {
        // это может не сработать, но не изменит *this
        Class copy(other);
        // это не может не сработать, фиксируя транзакцию в *this
        swap(copy);
        return *this;
    }

Реализация операций перемещения в C++0x тривиальна (и, в частности, одинакова для всех классов Pimpl):

    // Семантика перемещений в C++0x:
    Class(Class &&other) 
        : d(other.d)
    {
        other.d = 0;
    }

    Class &operator=(Class &&other)
    {
        std::swap(d, other.d);
        return *this;
    }
    // ...
};

В этой модели функция обмена и операторы присваивания могут быть реализованы как inline, без ущерба для инкапсуляции класса; разработчики могут найти эффективное применение этой возможности.

Расширенные способы композиции


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

Например, идиоматический класс диалога Qt

class QLineEdit;
class QLabel;

class MyDialog : public QDialog
{
    // ...
private:
    // идиоматически для Qt:
    QLabel    *m_loginLB;
    QLineEdit *m_loginLE;
    QLabel    *m_passwdLB;
    QLineEdit *m_passwdLE;
};

превращается в

#include <QLabel>
#include <QLineEdit>

class MyDialog::Private
{
    // ...
    // не идиоматически для Qt, но меньше выделения памяти на куче
    QLabel    loginLB;
    QLineEdit loginLE;
    QLabel    passwdLB;
    QLineEdit passwdLE;
};

Знатоки Qt могут заметить, что деструктор QDialog уже и так разрушает виджеты потомков, следовательно, прямая агрегация приведет к двойному вызову их разрушения. Действительно, использование этой техники создает угрозу появления ошибок последовательности распределения памяти (двойное удаление, использование после освобождения и т.д.), особенно если поля данных также принадлежат классу и наоборот. Тем не менее, показанное преобразование в данном случае безопасно, т.к. Qt всегда позволяет удалять потомков перед их родителями.

Этот подход особенно эффективен в случае, когда поля данных, агрегированные таким способом, сами являются экземплярами Pimpl классов. Именно так обстоит дело в последнем примере, где применение идиомы Pimpl сохраняет четыре динамических выделения памяти размером sizeof(void*), вместо чего происходит всего одно дополнительное (большое) выделение памяти. Это может привести к более эффективному использованию кучи, т.к. маленькие выделения памяти постоянно создают большие накладные расходы в аллокаторе.

Кроме того, при таком подходе компилятор имеет гораздо больше шансов на «девиртуализацию» вызовов виртуальных функций, т.е. он удалит двойные косвенные вызовы, к которым приводит виртуальность вызываемых функций. При использовании агрегации по указателю это требует межпроцедурной оптимизации. В любом случае, это даст выигрыш производительности во времени выполнения на фоне дополнительных косвенных вызовов; тем не менее d-указатель должен быть проверен по мере необходимости с помощью профилирования конкретных классов.

В случае, когда профилирование показывает, что динамическое выделение памяти превращается в узкое место, может помочь применение идиомы «Fast Pimpl» («Решение сложных задач на C++», пункт 30). В этом варианте для создания экземпляров класса Private вместо глобального оператора new() используется быстрый аллокатор, например boost::singleton_pool.

Промежуточные выводы


Pimpl — хорошо известная идиома C++, которая позволяет программисту отделить интерфейс класса от его реализации в такой степени, как того не позволяет сделать C++ напрямую. Положительными побочными эффектами использования d-указателя являются ускорение компиляции, упрощение реализации семантики транзакций и возможность сделать реализацию, потенциально более эффективную во времени выполнения, используя расширенные способы композиции.

Но d-указатели имеют также свои недостатки: помимо необходимости создавать дополнительный класс Private, динамического выделения памяти для него, измененной семантики константных методов, причиной для беспокойства также являются потенциальные ошибки последовательности распределения памяти.

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

Что дальше?


Вторая (и последняя) часть этой статьи познакомит нас с внутренним устройством Pimpl, раскроет проблемные места и дополнит идиому с помощью целого ряда улучшений.

Другие статьи про Pimpl на хабре


Идиомы Pimpl и Fast Pimpl – указатель на реализацию
Что такое Pimpl по версии Qt, и с чем его едят!
Приватные слоты в паттерне Pimpl от Qt
Tags:
Hubs:
+40
Comments19

Articles