воскресенье, 16 января 2011 г.

С++: отделение интерфейса от реализации

Концепция инкапсуляции основана на разделении того, как выглядит объект (интерфейс), и того, как он в действительности работает (реализация). Но известно, что в С++ определение класса включает в себя описание как методов, так и типов данных и переменных-членов класса. Описание членов класса в файле заголовков (.h) приводит к необходимости подключать описания и определения дополнительных типов, что увеличивает объём компилируемого кода и как следствие, компиляция замедляется. Кроме того, при каждом изменении внутренних деталей определения происходит перекомпиляция всех зависимых файлов, даже если интерфейс остался неизменным.

Эту проблему можно преодолеть, если разбить класс на интерфейс для типа данных и реализацию типа данных. Возможны два варианта такого подхода:

1) Интерфейс представляется в виде абстрактного класса, где все методы чисто виртуальные. Реализация представлена производным классом, который содержит реализацию всех виртуальных методов и необходимые переменные-члены класса. Конструктором объекта выступает фабрика, возвращающая указатель на абстрактный интерфейс.

2) Идиома Pimpl. Интерфейс хранит указатель на непрозрачный (opaque) для клиента тип данных. Реализация типа данных и методов интерфейса размещается в .cpp файле, при этом методы класса-интерфейса просто вызывают соответствующие методы класса-реализации. Возможен и другой вариант: opaque тип данных содержит только члены-переменные класса, с которыми работают методы интерфейса: вместо обращения this->field добавляется один уровень косвенности: this->pimpl->field.

Оба этих подхода имеют ряд недостатков. Использование виртуальных методов или передача вызовов классу реализации снижает эффективность программы в случае высокочастотных взаимодействий между классами, а необходимость выделять память отдельно для интерфейса и реализации замедляет создание объектов и повышает фрагментацию памяти. С другой стороны, дополнительный уровень косвенности замедляет доступ к данным, увеличивает объём кода и ухудшает его читаемость.

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

Для преодоления этой проблемы на вооружение была взята следующая идея:

Cделать различным определение класса для разных единиц трансляции: это возможно, т. к. в С и C++ единица трансляции компилируется автономно и независимо от остальных.

В данном случае роль дескриптора (opaque handle) играет некий базовый класс, от которого наследуется класс-интерфейс реализации. Представим себе некий класс CFoo, реализация которого должна быть скрыта, а клиенту предоставляется лишь интерфейс. Определим класс как производный от класса-дескриптора HFoo:

Declaration of CFoo
(для просмотра я рекомендую тему Slush and Poppies)

Напишем код клиента, который работает с интерфейсом класса CFoo:

Client

Мы определили дескриптор HFoo как пустую структуру, после чего стало возможным правильное определение CFoo: подключаем его определение и компилируем код. Всё нормально.

В файле реализации мы делаем следующий трюк: определяем HFoo так, как того требует реализация (например, нужны две целочисленные переменные), после этого подключаем определение:

Implementation of CFoo

Теперь класс CFoo имеет разные определения в файле реализации и в коде клиента, но это никак не мешает компиляции и сборке программы. Но работать программа не будет, при её запуске можно легко получить heap corruption или AV: почему? Дело в том, что для клиента CFoo имеет, как и положено, размер 1 байт, поскольку не содержит каких-либо членов класса. Поэтому CRT честно выделит 1 байт для экземпляра класса и вернёт указатель на него. Реализация же попытается обратиться как минимум к 2 * sizeof(int) байтам памяти, что вызовет ошибку. Решить проблему можно двумя способами: создать фабрику или перегрузить операторы new и delete для CFoo. Главное, чтобы память выделялась в файле реализации, где CFoo определяется как объект размером 8 байт. Мне больше нравится второй подход:

Extended definition of CFoo
Extended implementation of CFoo

Этот код компилируется и выполняется без ошибок как в MSC, так и в GCC (единственная сложность - неочевидное требование подключения cstddef для operator new(),  в противном случае получаем ошибку declaration of 'operator new' as non-function).

Достоинство данного подхода: никакого оверхеда по памяти и скорости выполнения. Недостаток: хак. Завтра я также постараюсь рассмотреть проблемы, возникающие при таком подходе и возможные пути их решения.

До встречи!

Update

Неудобно каждый раз для нового класса прописывать собственный аллокатор, поэтому мы эту работу поручим препроцессору. Более того, неудобно каждый раз определять пустую структуру-дескриптор перед включением .h файла с классом. Назовём эту структуру заголовком (header), и определим её в том же .h файле, где размещается определение соотв. класса, а определение обложим гвардами препроцессора:
#ifndef FOO_DECLARED
DECLARE_HEADER(Foo)
#endif
Макрос FOO_DECLARED описывается в файле реализации после определения "настоящего" дескриптора и перед включением заголовочного файла. Таким образом мы легко контролируем, какое именно определение дескриптора будет видно в файлах проекта.
 
Теперь о вещах более приземлённых.
 
Одним из недостатков подставного определения является то, что отладчик MS Visual Studio не видит переменные-члены класса во время отладки (сразу оговорюсь, что GDB в Xсode видит нормально). Досадно, но это факт: по каким-то причинам отладчик "видит" только пустую структуру-дескриптор без полей - понятно, что отладка такой программы быстро превратится в настоящий кошмар.

Чтобы преодолеть это досадное неудобство, можно в теле метода объявить указатель на дескриптор и присвоить ему this:
const HFoo *_this = this;
Ещё одно требование - HFoo должна размещаться в безымянном пространстве имён, иначе отладчик будет видеть поля структуры с переменным успехом (это зависит, похоже, от порядка сборки, а впрочем, не уверен). Если все условия выполнены, в процессе отладки можно видеть поля класса:

Ну я для GDB в этих триках нет необходимости

Поручим и эту работу по разрастанию кода препроцессору. Соберём все вспомогательные макросы в отдельный файл заголовков:


Вот как теперь выглядят сокращённые определение и реализация CFoo:


Макросы BEGIN_HEADER и END_HEADER я ввёл для упрощения определения дескриптора в безымянном пространстве имён, но, возможно, у кого-то появится решение получше.

Ну вот и всё. Дискасс из велкам :)

8 комментариев:

  1. так тоже работает, или буду проблмы ?
    http://pastie.org/1467285
    http://pastie.org/1467294

    ОтветитьУдалить
  2. Годный пост! Спасибо.

    P. S. Кстати, если не смотрел, то посмотри "Вальгалла: Сага о викинге".

    ОтветитьУдалить
  3. Leshik
    Дублировать определение - нехорошо.

    ОтветитьУдалить
  4. Это называется ODR violation, и за такое бьют ногами по голове. В разы лучше C-style интерфейс выносить, раз уж у тебя все равно на стеке объект не создать и в контейнер не положить.

    ОтветитьУдалить
  5. Этот комментарий был удален автором.

    ОтветитьУдалить
  6. А как же быть со встраиваемыми методами, которые зависят от переменных класса?

    Update: переложить на препроцессор?

    ОтветитьУдалить
  7. Арсений, лучше не ногами, а табуреткой - доказано армией. Понятно что интерфейс на стек не положишь, и подставляемые методы невозможны, и sizeof(), и иерархия классов должна быть в пределах одного .cpp. И всё же по существу: какие проблемы можно огрести конкретно в данной ситуации?

    ОтветитьУдалить
  8. Алмаз:
    подставляемые методы требуют знания реализации, с интерфейсами это невозможно.

    ОтветитьУдалить