Эту проблему можно преодолеть, если разбить класс на интерфейс для типа данных и реализацию типа данных. Возможны два варианта такого подхода:
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Макрос FOO_DECLARED описывается в файле реализации после определения "настоящего" дескриптора и перед включением заголовочного файла. Таким образом мы легко контролируем, какое именно определение дескриптора будет видно в файлах проекта.
DECLARE_HEADER(Foo)
#endif
Теперь о вещах более приземлённых.
Одним из недостатков подставного определения является то, что отладчик MS Visual Studio не видит переменные-члены класса во время отладки (сразу оговорюсь, что GDB в Xсode видит нормально). Досадно, но это факт: по каким-то причинам отладчик "видит" только пустую структуру-дескриптор без полей - понятно, что отладка такой программы быстро превратится в настоящий кошмар.
Чтобы преодолеть это досадное неудобство, можно в теле метода объявить указатель на дескриптор и присвоить ему this:
const HFoo *_this = this;Ещё одно требование - HFoo должна размещаться в безымянном пространстве имён, иначе отладчик будет видеть поля структуры с переменным успехом (это зависит, похоже, от порядка сборки, а впрочем, не уверен). Если все условия выполнены, в процессе отладки можно видеть поля класса:
Ну я для GDB в этих триках нет необходимости
Поручим и эту работу по разрастанию кода препроцессору. Соберём все вспомогательные макросы в отдельный файл заголовков:
Вот как теперь выглядят сокращённые определение и реализация CFoo:
Макросы BEGIN_HEADER и END_HEADER я ввёл для упрощения определения дескриптора в безымянном пространстве имён, но, возможно, у кого-то появится решение получше.
Ну вот и всё. Дискасс из велкам :)
так тоже работает, или буду проблмы ?
ОтветитьУдалитьhttp://pastie.org/1467285
http://pastie.org/1467294
Годный пост! Спасибо.
ОтветитьУдалитьP. S. Кстати, если не смотрел, то посмотри "Вальгалла: Сага о викинге".
Leshik
ОтветитьУдалитьДублировать определение - нехорошо.
Это называется ODR violation, и за такое бьют ногами по голове. В разы лучше C-style интерфейс выносить, раз уж у тебя все равно на стеке объект не создать и в контейнер не положить.
ОтветитьУдалитьЭтот комментарий был удален автором.
ОтветитьУдалитьА как же быть со встраиваемыми методами, которые зависят от переменных класса?
ОтветитьУдалитьUpdate: переложить на препроцессор?
Арсений, лучше не ногами, а табуреткой - доказано армией. Понятно что интерфейс на стек не положишь, и подставляемые методы невозможны, и sizeof(), и иерархия классов должна быть в пределах одного .cpp. И всё же по существу: какие проблемы можно огрести конкретно в данной ситуации?
ОтветитьУдалитьАлмаз:
ОтветитьУдалитьподставляемые методы требуют знания реализации, с интерфейсами это невозможно.