C++ / [Из песочницы] Перевод статьи «Pimp my Pimpl», часть 2

в 13:13, , рубрики: c plus plus, pimpl, qt, метки: , ,

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

Это перевод второй части статьи, опубликованной на сайте Heise Developer. Перевод первой части можно найти тут. Оригиналы обеих частей (на немецком языке) лежат тут и здесь.
Перевод был сделан с английского перевода.
Аннотация

Много было написано про эту забавно звучащую идиому, также известную как d-pointer, compiler firewall или Cheshire Cat. За первой статьей в Heise Developer, представившей классическую реализацию идиомы Pimpl и её преимущества, следует эта, вторая и завершающая, статья о решении некоторых проблем, неизбежно возникающих при использовании идиомы Pimpl.
Часть 2

Нарушение const-корректности

Первый нюанс, который далеко не очевиден, связан с интерпретацией константности полей объекта. При использовании идиомы Pimpl, методы получают доступ к полям объекта реализации через указатель d:
SomeThing & Class::someThing() const {
return d->someThing;
}

Внимательно рассмотрев данный пример, можно заметить, что этот код обходит механизм защиты константных объектов в C++: так как метод объявлен как const, указатель this внутри метода someThing()имеет тип const Class *, а указатель d, соответственно, тип Class::Private * const. Этого, однако, недостаточно для запрета модифицирующего доступа к полям класс Class::Private, поскольку d хоть и константен, но *d — нет.
Помните: в C++ позиция модификатора const имеет значение:
const int * pci; // указатель на константный int
int * const cpi; // константный указатель на int
const int * const cpci; // константный указатель на константный int

*pci = 1; // ошибка: *pci константен
*cpi = 1; // работает: *cpi не константен
*cpci = 1; // ошибка: *cpci константен

int i;
pci = &i; // работает
cpi = &i; // ошибка: cpi константен
cpci = &i; // ошибка: cpci константен

Таким образом, при использовании идиомы Pimpl все методы (и те, которые объявлены как const) могут модифицировать поля объекта реализации. Если бы мы не использовали Pimpl, компилятор сумел бы отловить такие ошибки.
Этот недостаток системы типов обычно нежелателен и поэтому должен быть устранен. Для этого можно применять два способа: класс-обертку deep_const_ptr или пару методов d_func(). Первый метод заключается в реализации «умного» указателя, который навязывает константность выбранному указателю. Определение такого класса сводится к следующему:
template
class deep_const_ptr {
T * p;
public:
explicit deep_const_ptr( T * t ) : p( t ) {}

const T & operator*() const { return *p; }
T & operator*() { return *p; }

const T * operator->() const { return p; }
T * operator->() { return p; }
};

Используя трюк с перегрузкой константных и обычных версий методов operator*() и operator->(), удается навязать константность указателя d объекту *d. Замена Private *d на deep_const_ptr d полностью устраняет рассматриваемую проблему. Но такое решение может быть избыточным: в данной ситуации трюк с перегрузкой операторов разыменования можно применить прямо к классу Class:
class Class {
// ...
private:
const Private * d_func() const { return _d; }
Private * d_func() { return _d; }
private:
Private * _d;
};

Теперь, вместо использования _d в реализациях методов следует вызывать d_func():
void Class::f() const {
const Private * d = f_func();
// используем 'd' ...
}

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

Следующее препятствие возникает, когда разработчик выносит все private методы класса Class в класс Private: теперь в этих методах нельзя вызывать другие (не static) методы класса Class, так как связь Class -> Private однонаправленная:
class Class::Private {
public:
Private() : ... {}
// ...
void callPublicFunc() { /*???*/Class::publicFunc(); }
};

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

Эта проблема может быть решена введением обратной ссылки (имя поля q упоминается в коде Qt):
class Class::Private {
Class * const q; // обратная ссылка
public:
explicit Private( Class * qq ) : q( qq ), ... {}
// ...
void callPublicFunc() { q->publicFunc(); }
};

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

При использовании обратной ссылки важно помнить, что инициализация d не выполнена до тех пор, пока не отработал конструктор Private. Разработчику не следует вызывать методы Class, которые обращаются к полу d, в теле конструктора Private, иначе он получит undefined behaviour.
Для перестраховки разработчик должен инициализировать обратную ссылку null-указателем, а правильное значение ссылки установить лишь после отработки конструктора Private, в теле конструктора Class:
class Class::Private {
Class * const q; // back-link
public:
explicit Private( /*Class * qq*/ ) : q( 0 ), ... {}
// ...
};

Class::Class()
: d( new Private/*( this )*/ )
{
// устанавливаем обратную ссылку:
d->q = this;
}

Несмотря на вышеприведенные ограничения, обычно существенную часть кода инициализации класса можно перенести в конструктор Private, что важно для классов с несколькими конструкторами. Стоит также упомянуть, что и с указателем q (обратной ссылкой) возникает уже рассмотренная проблема нарушения константности, которая решается аналогично.
Промежуточные итоги

Теперь, когда нам удалось восстановить функционал, утраченный с введением приватного класса реализации идиомой Pimpl, оставшаяся часть статьи будет посвящена некоторой «магии», которая позволит нивелировать дополнительные расходы памяти, возникающие при использовании идиомы Pimpl.
Повышение эффективности с помощью повторного использования объектов

Будучи хорошим С++-разработчиком, читатель наверняка полон скепсиса после прочтения аннотации к статье, описывающей классическую идиому Pimpl. В частности, дополнительные выделения памяти могут быть крайне невыгодны, особенно касаемо классов, которые сами по себе почти не требуют памяти.
В первую очередь такие соображения должны быть проверены профилированием кода, однако это не может быть причиной для отказа от поисков решения потенциальной проблемы с производительностью. В первой части статьи уже было упомянуто встраивание полей класса в объект реализации, что сокращало количество запросов на выделение памяти. Далее мы рассмотрим еще одну, существенно более продвинутую методику: повторное использование указателя на реализацию.
В иерархии полиморфных классов проблема дополнительных расходов памяти усугубляется глубиной иерархии: каждый класс иерархии имеет свою скрытую реализацию, даже если при этом он не несет новых полей (к примеру, наследование с целью переопределения виртуальных методов без введения новых членов класса).
Разработчик может бороться с разрастанием количества d-указателей (и связанных с ними выделений памяти) с помощью повторного использования d-указателя базового класса в наследующих классах:
// base.h:
class Base {
// ...
public:
Base();
protected:
class Private;
explicit Base( Private * d );
Private * d_func() { return _d; }
const Private * d_func() const { return _d; }
private:
Private * _d;
};

// base.cpp:
Base::Base()
: _d( new Private )
{
// ...
}

Base::Base( Private * d )
: _d( d )
{
// ...
}

Наличие помимо публичных конструкторов protected-конструктора позволяет наследующим классам внедрять свой d-указатель в базовый класс. В коде также используется фикс const-корректности с помощью методов d_func() (которые теперь тоже protected) для (немодифицирующего) доступа наследующих классов к _d.
// derived.h:
class Derived : public Base {
public:
Derived();
// ...
protected:
class Private;
Private * d_func(); // не могут быть
const Private * d_func() const; // заинлайнены
};

// derived.cpp:
Derived::Private * Derived::d_func() {
return static_cast( Base::d_func() );
}

const Derived::Private * Derived::d_func() const {
return static_cast( Base::d_func() );
}

Derived::Derived()
: Base( new Private ) {}

Теперь автор Derived использует новый конструктор Base для передачи Derided::Private вместо Base::Private в Base::_d (обратите внимание на использование одного и того же имени Private в разных контекстах). Также автор реализует свои методы d_func() в терминах методов Base с принудительным приведением типа.
Чтобы конструктор Base верно отработал, Base::Private должен быть предком Derived::Private:
class Derived::Private : public Base::Private {
// ...
};

Для того, чтобы от Base::Private действительно можно было унаследовать класс, необходимо выполнение трех условий.
Во-первых, разработчик должен сделать деструктор Base::Private виртуальным. В противном случае будет undefined behaviour при срабатывании деструктора Base, который попытается удалить объект реализации Derived::Private через указатель на Base::Private.
Во-вторых, разработчик должен реализовать оба класса в одной библиотеке, поскольку Private обычно не попадают в таблицу экспорта — они не указаны в declspec(dllexport) на Windows, и не указаны как visibility=hidden в бинарниках ELF. Однако, экспорт неизбежен, если Base и Derived реализованы в разных библиотеках. В исключительных случаях экспортируются Private-классы главных классов библиотеки: к примеру, разработчики Nokia вынесли в экспорт классы QObjectPrivate (из QtCore) и QWidgetPrivate (из QtGui), которые весьма востребованы, так как очень многие классы из других модулей наследуют от QObject и QWidget. Однако, делая так, разработчики вносят зависимости между библиотеками не только на уровне интерфейсов, но и на уровне «внутренностей», нарушая, таким образом, совместимость библиотек разных версий: в общем случае libQtGui.so.4.5.0 не станет работать, если динамический компоновщик подключит к ней libQtCore.so.4.6.0.
И, наконец, в-третьих, определение Base::Private больше не может оставаться спрятаным в файле реализации базового класса (base.cpp), так как его требует определение Derived::Private. Так где же разместить определение Base::Private? Можно просто включить его в base.h, но тогда какой смысл использовать Pimpl, если внутренняя реализация все равно видна извне? Ответ на эти вопросы заключается в создании специального, приватного файла заголовков. Для этой цели Qt и KDE установили схему наименования имякласса_p.h (также применяются суффиксы _priv, _i и _impl). Кроме определения Base::Private, в этом приватном файле могут размещаться inline-реализации методов Base, к примеру конструктор:
inline Base::Base( Private * d ) : _d( d ) {}

А в derived_p.h:
inline Derived::Derived( Private * d ) : Base( d ) {}
inline const Derived::Private * Derived::d_func() const {
return static_cast( Base::d_func() );
}
inline Derived::Private * Derived::d_func() {
return static_cast( Base::d_func() );
}

Строго говоря, вышеприведенный код противоречит правилу One Definition Rule, так как реализации d_func() инлайнятся в файлах, которые включают derived_p.h, и не инлайнятся в других файлах.
На практике, это не является проблемой, так как все, кто будут вызывать d_func(), так или иначе должны будут включить файл derived_p.h. Для перестраховки можно объявить проблемные методы inline-ом в определении Derived в файле derived.h — современные компиляторы допускают наличие ключевого слова inline в методах без реализации.
Часто разработчики прячут возникающий при этой методике избыточный код под макросы. К примеру, Qt определяет макрос Q_DECLARE_PRIVATE для использования в определении класса, и макрос Q_D, который объявляет указатель d в реализации метода и инициализирует его вызовом d_func().
Один недостаток все же остается: если разработчик хочет скомбинировать повторное использование указателя на реализацию и механизм обратной ссылки, возникают некоторые сложности. В частности, необходимо тщательно следить за тем, чтобы не разыменовать (даже в неявном виде!) указатель на Derived, который передается в конструктор Private, пока не отработают конструкторы в иерархии наследования.
Derived::Private( Derived * qq )
: Base( qq ) // хорошо, разыменования нет
{
q->setFoo( ... ); // плохо, программа аварийно завершит работу
}

В момент разыменования не создан не только Derived, но и — и в этом заключается отличие от неполиформного случая, описанного ранее — Base, так как все еще создается его поле Private.
В этом случае, так же как и ранее, следует инициализировать обратную ссылку null-указателем. Задача установки обратной ссылки в правильное значение ложится на плечи класса, лежащего в конце иерархической цепочки, то есть класса, который и внедряет свой класс Private в иерархию. В случае с Derived, код будет выглядеть так:
Derived::Derived()
: Base( new Private/*( this )*/ )
{
d_func()->_q = this;
}

При желании разработчик может вынести код инициализации, требующий доступ по обратной ссылке, в отдельный метод Private::init() (что означает конструкцию Private в два этапа). Этот метод вызывается (только) в конструкторе класса, который самостоятельно создает экземпляр Derived.
Derived::Derived( Private * d )
: Base( d )
{
// _не_ вызывает d->init()!
}
Derived::Derived()
: Base( new Private )
{
d_func()->init( this );
}
Derived::Private::init( Derived * qq ) {
Base::Private::init( qq ); // устанавливает _q
// далее идет инициализация
}

Кроме того, каждый класс Private должен иметь собственную обратную ссылку на класс-контейнер, либо определить методы q_func(), которые будут отвечать за приведение типа для обратной ссылки базового класса Base::Private. Соответствующий код здесь не приводится — его написание остается как упражнение для уважаемого читателя. Решение этого упражнения можно найти на FTP сервере Heise в виде «прокачанной»1 иерархии Shape.
Выводы

Будучи известной идиомой С++, Pimpl позволяет разработчикам разделить интерфейс и реализацию в той мере, которая недостижима встроенными средствами С++. Как положительный побочный эффект, разработчики получают ускорение компиляции, возможность реализации семантики транзакций и, с помощью активного использования композиции, общего ускорения кода в перспективе.
Не все так гладко при использовании d-указателей: дополнительный класс Private, связанные с ним выделения памяти, нарушение const-корректности и потенциальные ошибки в порядке инициализации способны испортить много крови разработчику. Для всех перечисленных проблем в данной статье были предложены решения, которые, однако, требуют написания большого количества кода. Из-за повышенной сложности, полностью «прокачанный» Pimpl (с повторным использованием и обратными ссылками) может быть рекомендован лишь для небольшого числа классов или проектов.
Однако, проекты, которых не пугают возможные трудности, будут вознаграждены замечательной стабильностью интерфейса, позволяющей капитально менять реализацию.
Источники
John Lakos; Large-Scale C++ Software Design; Addison-Wesley Longman, 1996

Herb Sutter; Exceptional C++: 47 Engineering Puzzles, Programming Problems, and Solutions; Addison-Wesley Longman, 2000

Herb Sutter, Andrei Alexandrescu: C++ Coding Standards: 101 Rules, Guidelines and Best Practices; Addison-Wesley Longman, 2004

Marc Mutz; Pimp my Pimpl; C++: Vor- und Nachteile des d-Zeiger-Idioms, Teil 1; Artikel auf heise Developer (English translation available)

Примечания переводчика
1 Здесь и далее: игра слов — Pimpl созвучно с глаголом to pimp, который является отсылкой к телевизионному шоу «Тачку на прокачку» (англ. «Pimp my Ride»).


* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js