В первой части статьи, переведенной уважаемым skb7, было рассмотрено идиому Pimpl (pointer to implementation, указатель на реализацию), ее назначение и преимущества. Во второй части будет рассмотрено проблемы, возникающие при использовании этой идиомы, а также предложены некоторые варианты их решения.
Это перевод второй части статьи, опубликованной на сайте Heise Developer. Перевод первой части можно найти тут. Оригиналы обеих частей (на немецком языке) лежат тут и здесь.
Перевод был сделан с английского перевода.
Много было написано про эту забавно звучащую идиому, также известную как d-pointer, compiler firewall или Cheshire Cat. За первой статьей в Heise Developer, представившей классическую реализацию идиомы Pimpl и её преимущества, следует эта, вторая и завершающая, статья о решении некоторых проблем, неизбежно возникающих при использовании идиомы Pimpl.
Первый нюанс, который далеко не очевиден, связан с интерпретацией константности полей объекта. При использовании идиомы Pimpl, методы получают доступ к полям объекта реализации через указатель
Внимательно рассмотрев данный пример, можно заметить, что этот код обходит механизм защиты константных объектов в C++: так как метод объявлен как
Помните: в C++ позиция модификатора
Таким образом, при использовании идиомы Pimpl все методы (и те, которые объявлены как
Этот недостаток системы типов обычно нежелателен и поэтому должен быть устранен. Для этого можно применять два способа: класс-обертку
Используя трюк с перегрузкой константных и обычных версий методов
Теперь, вместо использования
Конечно, ничто не воспрещает прямой доступ к
Следующее препятствие возникает, когда разработчик выносит все
Эта проблема может быть решена введением обратной ссылки (имя поля
При использовании обратной ссылки важно помнить, что инициализация
Для перестраховки разработчик должен инициализировать обратную ссылку null-указателем, а правильное значение ссылки установить лишь после отработки конструктора
Несмотря на вышеприведенные ограничения, обычно существенную часть кода инициализации класса можно перенести в конструктор
Теперь, когда нам удалось восстановить функционал, утраченный с введением приватного класса реализации идиомой Pimpl, оставшаяся часть статьи будет посвящена некоторой «магии», которая позволит нивелировать дополнительные расходы памяти, возникающие при использовании идиомы Pimpl.
Будучи хорошим С++-разработчиком, читатель наверняка полон скепсиса после прочтения аннотации к статье, описывающей классическую идиому Pimpl. В частности, дополнительные выделения памяти могут быть крайне невыгодны, особенно касаемо классов, которые сами по себе почти не требуют памяти.
В первую очередь такие соображения должны быть проверены профилированием кода, однако это не может быть причиной для отказа от поисков решения потенциальной проблемы с производительностью. В первой части статьи уже было упомянуто встраивание полей класса в объект реализации, что сокращало количество запросов на выделение памяти. Далее мы рассмотрим еще одну, существенно более продвинутую методику: повторное использование указателя на реализацию.
В иерархии полиморфных классов проблема дополнительных расходов памяти усугубляется глубиной иерархии: каждый класс иерархии имеет свою скрытую реализацию, даже если при этом он не несет новых полей (к примеру, наследование с целью переопределения виртуальных методов без введения новых членов класса).
Разработчик может бороться с разрастанием количества
Наличие помимо публичных конструкторов
Теперь автор
Чтобы конструктор
Для того, чтобы от
Во-первых, разработчик должен сделать деструктор
Во-вторых, разработчик должен реализовать оба класса в одной библиотеке, поскольку
И, наконец, в-третьих, определение
А в
Строго говоря, вышеприведенный код противоречит правилу One Definition Rule, так как реализации
На практике, это не является проблемой, так как все, кто будут вызывать
Часто разработчики прячут возникающий при этой методике избыточный код под макросы. К примеру, Qt определяет макрос
Один недостаток все же остается: если разработчик хочет скомбинировать повторное использование указателя на реализацию и механизм обратной ссылки, возникают некоторые сложности. В частности, необходимо тщательно следить за тем, чтобы не разыменовать (даже в неявном виде!) указатель на
В момент разыменования не создан не только
В этом случае, так же как и ранее, следует инициализировать обратную ссылку
При желании разработчик может вынести код инициализации, требующий доступ по обратной ссылке, в отдельный метод
Кроме того, каждый класс
Будучи известной идиомой С++, Pimpl позволяет разработчикам разделить интерфейс и реализацию в той мере, которая недостижима встроенными средствами С++. Как положительный побочный эффект, разработчики получают ускорение компиляции, возможность реализации семантики транзакций и, с помощью активного использования композиции, общего ускорения кода в перспективе.
Не все так гладко при использовании
Однако, проекты, которых не пугают возможные трудности, будут вознаграждены замечательной стабильностью интерфейса, позволяющей капитально менять реализацию.
1 Здесь и далее: игра слов — Pimpl созвучно с глаголом to pimp, который является отсылкой к телевизионному шоу «Тачку на прокачку» (англ. «Pimp my Ride»).
Ссылки на оригинал
Это перевод второй части статьи, опубликованной на сайте 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 <typename T>
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<Private> 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<Private*>( Base::d_func() );
}
const Derived::Private * Derived::d_func() const {
return static_cast<const Private*>( 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<const Private*>( Base::d_func() );
}
inline Derived::Private * Derived::d_func() {
return static_cast<Private*>( 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»).