Pull to refresh

Идиомы Pimpl и Fast Pimpl – указатель на реализацию

Reading time5 min
Views49K
Другие названия: Bridge, Compilation Firewall, Handle/Body
Допустим, нам необходимо написать кроссплатформенное сетевое приложение с использованием сокетов. Для этого нам необходим класс GeneralSocket (“Видимый класс”), который будет инкапсулировать в себе детали реализации конкретной платформы (“Скрываемый класс”). Часто требуется скрыть детали реализации от пользователей или других разработчиков:

  • Для того, что бы была возможность изменять реализацию скрываемого класса без перекомпиляции остального кода, так как закрытые члены хоть и недоступны извне никому, кроме функций-членов и друзей, но видимы всем, кто имеет доступ к определению класса. Изменение определения класса приводит к необходимости перекомпиляции всех пользователей класса
  • Для сокрытия имен из области видимости. Закрытые члены хоть и не могут быть вызваны кодом вне класса, тем не менее они участвуют в поиске имен и разрешении перегрузок
  • Для ускорения времени сборки, так как компилятору не нужно обрабатывать лишние определения закрытых типов

Рассмотрим пример:
//GeneralSocket.h
#include “UnixSocketImpl.h”

Class GeneralSocket{
public:
 connect();
private:
 UnixSocketImpl socket;
}

//GeneralSocket.cxx
GeneralSocket::connect(){
 socket.connectImpl();
}


* This source code was highlighted with Source Code Highlighter.


В данном примере необходимо чтобы описание скрытого класса UnixSocketImpl было известно на этапе компиляции. К тому же ничто не мешает пользователю воспользоваться функциями класса UnixSocketImpl в обход видимого класса GeneralSocket. Теперь попробуем заменить закрытый член видимого класса на указатель и убрать описание скрытого класса UnixSocketImpl из заголовочного файла:

//GeneralSocket.h
Class UnixSocketImpl;

Class GeneralSocket
{
public:
 GeneralSocket();
 void connect();
private:
 UnixSocketImpl * socket;
}

//GeneralSocket.cxx
#include “UnixSocketImpl.h”

GeneralSocket::GeneralSocket() : socket (new UnixSocketImpl){}

GeneralSocket::~GeneralSocket() {
 delete socket;
 socket = 0;
}

void GeneralSocket::connect() {
 socket->connectImpl();
}


* This source code was highlighted with Source Code Highlighter.


Нам удалось избавиться от UnixSocketImpl.h в заголовочном файле и перенести его в файл реализации класса GeneralSocket. Теперь пользователь не сможет добраться до конкретной реализации, и будет использовать функционал только через интерфейс класса GeneralSocket.

В С++, в случае изменений в классе (даже в закрытых функциях членах) все пользователи данного класса должны быть перекомпилированы. Для избежания подобных зависимостей используется указатель на функции члены, реализацию которых необходимо скрыть. Эта техника получила название Pimpl (Pointer to Implementation – указатель на реализацию). Два основных недостатка заключаются в следующем:
  1. Каждое создание объекта требует динамического выделения памяти для объекта, на который ссылается указатель
  2. Использование нескольких уровней косвенности (как минимум — один) для доступа к членам скрытого объекта

Что же можно попробовать скрыть?
  1. Только скрытые данные-члены
  2. Все скрытые данные-члены и функции-члены. К сожалению, скрыть виртуальную функцию невозможно, так как она должна быть видима для производных классов. Также в закрытом классе может понадобиться ссылка на открытый класс для использования его функций
  3. Закрытые и защищенные члены. К сожалению, защищенные члены скрыть нельзя, так как они должны быть доступны производным классам
  4. Весь класс. Преимущество заключается в том, что закрытому классу не нужен указатель на открытый класс. С другой стороны, мы лишаемся возможностей наследования

Теперь немного усложним задачу. Представим, что наш класс используется очень часто, а, как известно, динамическое выделение памяти в куче – весьма дорогостоящая операция. Попробуем заранее выделить память под скрываемый объект:
//GeneralSocket.h
Class GeneralSocket
{
public:
 GeneralSocket();
 void connect();
private:
 static const size_t sizeOfImpl = 42;/* or whatever space needed*/
 char socket [sizeOfImpl];
}

//GeneralSocket.cxx
#include “UnixSocketImpl.h”

GeneralSocket::GeneralSocket() : {
 assert(sizeOfImpl >= sizeof (UnixSocketImpl));
 new(&socket[0]) UnixSocketImpl;
}

GeneralSocket::~GeneralSocket() {
 (reinterpret_cast<UnixSocketImpl *> (&socket[0]))->~UnixSocketImpl();
}

GeneralSocket::connect() {
 socket->connectImpl();
}


* This source code was highlighted with Source Code Highlighter.


Нам удалось избавиться от объявления класса UnixSocketImpl в заголовочном файле GeneralSocket, и избавиться от динамического выделения памяти. Взамен мы получили ряд существенных недостатков:
  1. С++ — язык со строгой типизацией, и данное ухищрение – попытка обойти ограничения языка
  2. Проблемы с выравниванием памяти. Данный способ не гарантирует что память будет выравнена должным образом для всех членов UnixSocketImpl. Решение, которое не гарантирует полную переносимость, но все же работает в большинстве случаев – использование union:
    union max_align {
     void * dummy1;
     int dymmy2;
    }

    union {
     max_align m;
     char socket [sizeOfImpl];
    }


    * This source code was highlighted with Source Code Highlighter.

  3. Аккуратное обращение с функциями UnixSocketImpl. Например, теперь необходимо использовать свой оператор присваивающего копирования, либо запрещать оператор по умолчанию
  4. Поддержка класса GeneralSocket стала более трудоемкой. Теперь необходимо сохранять актуальным размер sizeOfImpl
  5. Неэффективное расходование памяти в случае если условие sizeOfImpl >= sizeof(UnixSocketImpl) не выполняется

Как известно, выделение памяти фиксированного размера происходит существенно быстрее, чем выделение произвольного объема памяти. Попробуем использовать свой аллокатор памяти:
//GeneralSocket.h
class GSimpl;

class GeneralSocket {
private:
 GSimpl * pimpl;
}

//GeneralSocket.cxx
#include “UnixSocketImpl.h”

class FixedAllocator {
public:
 static FixedAllocator* Instance();
 void* Allocate(size_t);
 void Deallocate(void*);
private:
 /*Singleton implementation that allocates memory of fixed size*/
};

struct FastPimpl {

 void* operator new( size_t s) {
  return FixedAllocator::Instance()->Allocate(s);
 }

 void operator delete( void* p) {
  FixedAllocator::Instance()->Deallocate(p);
 }
};

struct GSimpl : FastPimpl {
 /*use UnixSocketImpl here*/
};

GeneralSocket::GeneralSocket() : pimpl (new GSimpl){}

GeneralSocket::~GeneralSocket() {
 delete pimpl;
 pimpl = 0;
}


* This source code was highlighted with Source Code Highlighter.


Наследуя FastPimpl мы можем получить нужный нам закрытый класс с аллокатором памяти заданного размера (FixedAllocator).
Подведем итоги. Когда можно использовать Pimpl:
  1. Когда необходимо разделить абстракцию от реализации. В таком случае необходимую реализацию можно выбирать, например, при старте программы
  2. Когда необходимо расширять и абстракцию и реализацию новыми классами и произвольно комбинировать их. Таким образом, мы получаем две независимые иерархии классов абстракции и реализации
  3. Когда при изменении реализации клиентский код не должен перекомпилироваться
  4. Когда нужно скрыть от клиента реализацию абстракции


Ссылки по теме
  1. Стивен К. Дьюхерст. “Скользкие места С++. Как избежать проблемы при проектировании и компиляции ваших программ.” (С++ Gotchas. Avoiding Common Problems in Coding and Design). “Совет 8”
  2. Герб Саттер, Андрей Александреску. ”Стандарты программирования на С++ “. “Глава 43”
  3. Приемы объектно-ориентированного проектирования. Паттерны проектирования, Э. Гамма, Р. Хелм, Р. Джонсон, Дж. Влиссидес. Паттерн Bridge
  4. Compilation Firewalls (http://gotw.ca/gotw/024.htm)
  5. The Fast Pimpl Idiom (http://gotw.ca/gotw/028.htm)
Tags:
Hubs:
+6
Comments3

Articles