Абстрактные базы как двоичные интерфейсы
Оказывается, применение техники разделения интерфейса и реализации может
решить и проблемы совместимости транслятора/компоновщика C++. При этом,
однако, определение класса интерфейса должно принять несколько иную форму.
Как отмечалось ранее, проблемы совместимости возникают из-за того, что
разные трансляторы имеют различные соображения по поводу того, как
- передавать особенности языка на этапе выполнения;
- символические имена будут представлены на этапе
компоновки.
Если бы кто-нибудь придумал, как скрыть детали реализации транслятора/компоновщика
за каким-либо двоичным интерфейсом, это сделало бы написанные на C++ библиотеки
DLL значительно более широко используемыми.
Двоичная защита, то есть тот факт, что класс интерфейса C++ не использует
языковых конструкций, зависящих от транслятора, решает проблему зависимости
от транслятора/компоновщика. Чтобы сделать эту независимость более полной,
необходимо в первую очередь определить те аспекты языка, которые имеют
одинаковую реализацию в разных трансляторах. Конечно, представление на
этапе выполнения таких сложных типов, как С-структуры (structs), может
быть выдержано инвариантным по отношению к трансляторам. Это - основное,
что должен делать системный интерфейс, основанный на С, и иногда это достигается
применением условно транслируемых определений типа прагм (pragmas) или
других директив транслятора. Второе, что следует сделать, - это заставить
все компиляторы проходить параметры функций в одном и том же порядке (слева
направо, справа налево) и зачищать стек также одинаково. Подобно совместимости
структур, это также решаемая задача, и для унификации работы со стеком
часто используются условные директивы транслятора. В качестве примера
можно привести макросы WINAPI/WINBASEAPI из Win32 API. Каждая
извлеченная из системных DLL функция определена с помощью этих макросов:
WINBASEAPI void WINAPI Sleep(DWORD dwMsecs);
Каждый разработчик транслятора определяет эти символы препроцессора
для создания гибких стековых фреймов. Хотя в среде производителей может
возникнуть желание использовать аналогичную методику для определений всех
методов, фрагменты программ в этой главе для большей наглядности ее не
используют.
Третье требование к независимости трансляторов - наиболее уязвимое для
критики из всех, так как оно делает возможным определение двоичного интерфейса:
все трансляторы C++ с заданной платформой одинаково осуществляют механизм
вызова виртуальных функций. Действительно, это требование единообразия
применимо только к классам, не имеющим элементов данных, а имеющим не
более одного базового класса, который также не имеет элементов данных.
Вот что означает это требование для следующего простого определения класса:
class calculator {
public:
virtual void add1(short x);
virtual void add2(short x, short y);
};
Все трансляторы с данной платформой должны создать эквивалентные последовательности
машинного кода для следующего фрагмента программы пользователя:
extern calculator *pcalc;
pcalc->add1(1);
pcalc->add2(1, 2);
Отметим, что требуется не идентичность машинного кода на всех
трансляторах, а его эквивалентность. Это означает, что каждый транслятор
должен делать одинаковые допущения относительно того, как объект такого
класса размещен в памяти и как его виртуальные функции динамически вызываются
на этапе выполнения.
Впрочем, это не такое уж блестящее решение проблемы, как может показаться.
Реализация виртуальных функций на C++ на этапе выполнения выливается в
создание конструкций vptr и vtbl практически на всех
трансляторах. При этой методике транслятор молча генерирует статический
массив указателей функций для каждого класса, содержащего виртуальные
функции. Этот массив называется vtbl (virtual function table
- таблица виртуальных функций) и содержит один указатель функции для каждой
виртуальной функции, определенной в данном классе или в ее базовом классе.
Каждый объект класса содержит единственный невидимый элемент данных, именуемый
vptr (virtual function pointer - указатель виртуальных функций);
он автоматически инициализируется конструктором для указания на таблицу
vtbl класса. Когда клиент вызывает виртуальную функцию, транслятор генерирует
код, чтобы разыменовать указатель vptr, занести его в vtbl
и вызвать функцию через ее указатель, найденный в назначенном месте. Так
на C++ обеспечивается полиморфизм и диспетчеризация динамических вызовов.
Рисунок 1.5 показывает представление на этапе выполнения массивов vptr/vtbl
для класса calculator, рассмотренного выше.
Фактически каждый действующий в настоящее время качественный транслятор
C++ использует базовые концепции vprt и vtbl. Существует
два основных способа размещения таблицы vtbl: с помощью CFRONT
и корректирующего переходника (adjuster thunk). Каждый из этих
приемов имеет свой способ обращения с тонкостями множественного наследования.
К счастью, на каждой из имеющихся платформ доминирует один из способов
(трансляторы Win32 используют adjuster thunk, Solaris - стиль
CFRONT для vtbl). К тому же формат таблицы vtbl
не влияет на исходный код C++, который пишет программист, а скорее является
артефактом сгенерированного кода. Желающие узнать подробности об этих
двух способах могут обратиться к прекрасной книге Стэна Липпмана "Объектная
модель C++ изнутри" (Stan Lippman. Inside C++ Object Model).
Основываясь на столь далеко идущих допущениях, теперь можно решить проблему
зависимости от транслятора. Предполагая, что все трансляторы на данной
платформе одинаково реализуют механизм вызова виртуальной функции, можно
определить класс интерфейса C++ так, чтобы глобальные операции над типами
данных определялись в нем как виртуальные функции; тогда можно быть уверенным,
что все трансляторы будут генерировать эквивалентный машинный код для
вызова методов со стороны клиента. Это предположение об единообразии означает,
что ни один класс интерфейса не имеет элементов данных и ни один класс
интерфейса не может быть прямым потомком более чем одного класса интерфейса.
Поскольку в классе интерфейса нет элементов данных, эти методы практически
невозможно использовать.
Чтобы подчеркнуть это обстоятельство, полезно определить члены интерфейса
как простые виртуальные функции, указав, что класс интерфейса задает только
возможность вызова методов, а не их реализацию.
// ifaststring.h
class IFastString {
public:
virtual int Length(void) const = 0;
virtual int Find(const char *psz) const = 0;
};
Определение этих методов как чисто виртуальных также дает знать транслятору,
что от класса интерфейса не требуется никакой реализации этих методов.
Когда транслятор генерирует таблицу vtbl для класса интерфейса,
входная точка для каждой простой виртуальной функции является или нулевой
(null), или точкой входа в С-процедуру этапа выполнения (_purecall
в Microsoft C++), которая при вызове генерирует логическое утверждение.
Если бы метод не был определен как чисто виртуальный, транслятор попытался
бы включить в соответствующую входную точку vtbl системную реализацию
метода класса интерфейса, которая в действительности не существует. Это
вызвало бы ошибку компоновки. Определенный таким образом класс интерфейса
является абстрактным базовым классом. Соответствующий класс реализации
должен порождаться классом интерфейса и перекрывать все чисто виртуальные
фyнкции содержательными реализациями. Эта наследственная связь проявится
в объектах, которые в качестве своего представления имеют двоичное надмножество
представления класса интерфейса (которое как раз и есть vptr/vtbl).
Дело в том, что отношение "является" ("is-a") между
порождаемым и базовым классами применяется на двоичном уровне в C++ так
же, как и на уровне моделирования в объектно-ориентированной разработке:
class FastString : public IFastString {
const int m_cch;
// count of characters
// число символов
char *m_psz;
public:
FastString(const char *psz);
~FastString(void);
int Length(void) const;
// returns # of characters
// возвращает число символов
int Find(const char *psz) const;
// returns offset
// возвращает смещение
};
Поскольку FastString порождается от IFastString, двоичное
представление объектов FastString должно быть надмножеством двоичного
представления IFastString. Это означает, что объекты FastString
будут содержать указатель vptr, указывающий на совместимую с
таблицей vtbl IFastString. Поскольку классу FastString
можно приписывать различные конкретные типы данных, его таблица vtbl
будет содержать указатели на существующие реализации методов Length
и Find. Их связь показана на рис. 1.6.
Даже несмотря на то, что открытые операторы над типами данных подняты
до уровня чисто виртуальных функций в классе интерфейса, клиент не может
приписывать значения объектам FastString, не имея определения
класса для класса реализации. При демонстрации клиенту определения класса
реализации от него будет скрыта двоичная инкапсуляция интерфейса; что
не позволит клиенту использовать класс интерфейса. Одним из разумных способов
обеспечить клиенту возможность использовать объекты FastString
является экспорт из DLL глобальной функции, которая будет вызывать новый
оператор от имени клиента. При условии, что эта подпрограмма экспортируется
с опцией extern "С", она будет доступна для любого
транслятора C++.
// ifaststring.h
class IFastString {
public:
virtual int Length(void) const = 0;
virtual int Find(const char *psz) const = 0;
};
extern "C"
IFastString *CreateFastString(const char *psz);
// faststring.cpp (part of DLL)
// faststring.cpp (часть DLL)
IFastString *CreateFastString (const char *psz)
{ return new FastString(psz); }
Как было в случае класса-дескриптора, новый оператор вызывается исключительно
внутри DLL FastString, а это означает, что размер и расположение
объекта будут установлены с использованием того же транслятора, который
транслировал все методы реализации.
Последнее препятствие, которое предстоит преодолеть, относится к уничтожению
объекта. Следующая клиентская программа пройдет трансляцию, но результаты
будут непредсказуемыми:
int f(void)
{
IFastString *pfs = CreateFastString("Deface me");
int n = pfs->Find("ace me");
delete pfs;
return n;
}
Непредсказуемое поведение вызвано тем фактом, что деструктор класса
интерфейса не является виртуальным. Это означает, что вызов оператора
delete не сможет динамически найти последний порожденный деструктор
и рекурсивно уничтожит объект ближайшего внешнего типа по отношению к
базовому типу. Поскольку деструктор FastString никогда не вызывается,
в данном примере из буфера исчезнет строка "Deface me", которая
должна там присутствовать.
Очевидное решение этой проблемы - сделать деструктор виртуальным в классе
интерфейса. К сожалению, это нарушит независимость класса интерфейса от
транслятора, так как положение виртуального деструктора в таблице vtbl
может изменяться от транслятора к транслятору. Одним из конструктивных
решений этой проблемы является добавление к интерфейсу явного метода Delete
как еще одной чисто виртуальной функции, чтобы заставить производный класс
уничтожать самого себя в своей реализации этого метода. В результате этого
будет выполнен нужный деструктор. Модифицированная версия заголовочного
файла интерфейса выглядит так:
// ifaststring.h
class IFastString {
public:
virtual void Delete(void) = 0;
virtual int Length(void) const = 0;
virtual int Find(const char *psz) const = 0;
};
extern "C"
IFastString *CreateFastString (const char *psz);
она влечет за собой соответствующее определение класса реализации:
// faststring.h
#include "ifaststring.h"
class FastString : public IFastString {
const int m_cch;
// count of characters
// счетчик символов
char *m_psz;
public:
FastString(const char *psz);
~FastString(void);
void Delete(void);
// deletes this instance
// уничтожает этот экземпляр
int Length(void) const;
// returns # of characters
// возвращает число символов
int Find(const char *psz) const;
// returns offset
// возвращает смещение
};
// faststring.cpp
#include <string.h>
#include "faststring.h"
IFastString* CreateFastString (const char *psz) {
return new FastString(psz);
}
FastString::FastString(const char *psz) : m_cch(strlen(psz)), m_psz(new char[m_cch + 1]) {
strcpy(m_psz, psz);
}
void FastString::Delete(void) {
delete this;
}
FastString::~FastString(void) {
delete[] m_psz;
}
int FastString::Lengtn(void) const {
return m_cch;
}
int FastString::Find(const char *psz) const {
// O(1) lookup code deleted for clarity
// код поиска 0(1) уничтожен для ясности
}
Рисунок 1.7 показывает представление FastString на этапе выполнения.
Чтобы использовать тип данных FastString, клиентам надо просто
включить в программу файл определения интерфейса и вызвать CreateFastString:
#include "ifaststring.h"
int f(void)
{
int n = -1;
IFastString *pfs = CreateFastString("Hi Bob!");
if (pfs) {
n = pfs->Find("ob");
pfs->Delete();
}
return n;
}
Отметим, что все, кроме одной, точки входа в DLL FastString
являются виртуальными функциями. Виртуальные функции класса интерфейса
всегда вызываются косвенно, через указатель функции, хранящийся в таблице
vtbl, избавляя клиента от необходимости указывать их символические
имена на этапе разработки. Это означает, что методы интерфейса защищены
от различий в коррекции символических имен на разных трансляторах. Единственная
точка входа, которая явно компонуется по имени, - это CreateFastString
- глобальная функция, которая обеспечивает клиенту доступ в мир FastString.
Заметим, однако, что эта функция была экспортирована с опцией extern
"С", которая подавляет коррекцию символов. Следовательно,
все трансляторы C++ ожидают, что импортируемая библиотека и DLL экспортируют
один и тот же идентификатор. Полезным результатом этой методики является
то, что вы можете спокойно извлечь класс из DLL, использующей одну среду
C++, а обратиться к этому классу из любой другой среды C++. Эта возможность
необходима при построении основы для независимых от разработчика компонентов
повторного пользования.
Полиморфизм на этапе выполнения
Расширяемость объекта
Управление ресурсами
Где мы находимся?
|