Как экспортировать классы C++ из DLL - Подход языка C

ОГЛАВЛЕНИЕ

Подход языка C

Описатели

Классический подход языка C к объектно-ориентированному программированию – использование непрозрачных указателей, т.е. описателей. Пользователь вызывает функцию, создающую объект внутри и возвращающую описатель для этого объекта. Далее пользователь вызывает различные функции, принимающие описатель в качестве параметра, и выполняет всевозможные операции над объектом. Хороший пример использования описателя – API работы с окнами Win32, представляющее окно с помощью описателя HWND. Воображаемый объект Xyz экспортируется через интерфейс C следующим образом:

typedef tagXYZHANDLE {} * XYZHANDLE;

// Функция-фабрика, создающая экземпляры объекта Xyz.
XYZAPI XYZHANDLE APIENTRY GetXyz(VOID);

// Вызывает метод Xyz.Foo.
XYZAPI INT APIENTRY XyzFoo(XYZHANDLE handle, INT n);
// Освобождает экземпляр Xyz и освобождает ресурсы.
XYZAPI VOID APIENTRY XyzRelease(XYZHANDLE handle);

// APIENTRY определен как __stdcall в заголовке WinDef.h.

Ниже приведен пример, как может выглядеть клиентский код C:

#include "XyzLibrary.h"

...

/* Создать экземпляр Xyz. */
XYZHANDLE hXyz = GetXyz();

if(hXyz)
{
    /* Вызвать метод Xyz.Foo. */
    XyzFoo(hXyz, 42);

    /* Уничтожить экземпляр Xyz и освободить запрошенные ресурсы. */
    XyzRelease(hXyz);

    /* Защититься. */
    hXyz = NULL;
}

При таком подходе DLL должна предоставлять явные функции для создания и удаления объекта.

Соглашения о вызовах

Важно не забыть указать соглашение о вызовах для всех экспортируемых функций. Пропущенное соглашение о вызовах – широко распространенная ошибка, допускаемая многими новичками. Пока стандартное соглашение о вызовах клиента совпадает с соглашением о вызовах DLL –  все работает. Но как только клиент изменит свое соглашение о вызовах, разработчик не замечает этого до появления аварийных завершений при выполнении. Проект XyzLibrary использует макрос APIENTRY, определенный как __stdcall в заголовочном файле "WinDef.h".

Безопасность исключений

Никакому исключению C++ не позволяется пересечь границу DLL. Язык C ничего не знает об исключениях C++ и не способен правильно обработать их. Если методу объекта надо сообщить об ошибке, следует использовать код возврата.

Плюсы
•    DLL может использоваться максимально широкой аудиторией программистов. Почти все современные языки программирования поддерживают взаимодействие с функциями чистого C.
•    Библиотеки времени выполнения C из DLL и из клиента не зависят друг от друга. Так как получение и освобождение ресурса происходит полностью внутри модуля DLL, на клиента не влияет выбор CRT в DLL.

Минусы
•    Обязанность вызова нужных методов для нужного экземпляра объекта лежит на пользователе DLL. Например, в следующем фрагменте кода компилятор не сможет уловить ошибку:

/* void* GetSomeOtherObject(void) объявлен в другом месте. */
XYZHANDLE h = GetSomeOtherObject();

/* Ошибка: Вызов Xyz.Foo для неверного экземпляра объекта. */
XyzFoo(h, 42);

•    Требуются явные вызовы функции для создания и уничтожения экземпляров объекта. Это особенно неприятно для удаления экземпляра. Функция клиента обязана кропотливо вставлять вызов XyzRelease во всех точках выхода из функции. Если разработчик забывает вызвать XyzRelease, то происходит утечка ресурсов, потому что компилятор не отслеживает время жизни экземпляра объекта. Языки программирования, поддерживающие деструкторы или имеющие сборщик мусора, могут смягчить эту проблему, делая обертку над интерфейсом C.
•    Если методы объекта возвращают или принимают другие объекты в качестве параметров, то автор DLL обязан предоставить надлежащий интерфейс C для этих объектов. Альтернатива – вернуться к наименьшему общему знаменателю, то есть языку C, и использовать только встроенные типы (например, int, double, char*, и т.д.) в качестве возвращаемых типов и параметров методов.

Примитивный подход C++: экспорт класса

Почти любой современный компилятор C++, существующий на платформе Windows, поддерживает экспорт класса C++ из DLL. Экспорт класса C++ походит на экспорт функций C. Разработчику необходимо использовать спецификатор __declspec(dllexport/dllimport) перед именем класса, если надо экспортировать целый класс, или перед объявлениями методов, если надо экспортировать лишь конкретные методы класса. Ниже приведен фрагмент кода:

// Весь класс CXyz экспортируется со всеми его методами и членами.
//
class XYZAPI CXyz
{
public:
    int Foo(int n);
};

// Только метод CXyz::Foo экспортируется.
//
class CXyz
{
public:
    XYZAPI int Foo(int n);
};

Не надо явно указывать соглашение о вызовах для экспорта классов или их методов. По умолчанию компилятор C++ использует соглашение о вызовах __thiscall для методов класса. Однако из-за разных схем украшения имен, применяемых разными компиляторами, экспортированный класс C++ может использоваться только тем же самым компилятором и той же самой версией компилятора. Ниже приведен пример украшения имен, используемого компилятором MS Visual C++:

Обратите внимание, что украшенные имена отличаются от оригинальных имен C++. Далее приведен снимок экрана того же самого модуля DLL с расшифровкой украшения имен инструментом иллюстратор зависимостей:

Только компилятор MS Visual C++ может использовать эту DLL. Код DLL и клиента должны быть скомпилированы с помощью одной и той же версии MS Visual C++, чтобы гарантировать совпадение схемы украшения имен между вызывающей программой и вызываемой программой. Ниже приведен пример кода клиента, использующего объект Xyz:

#include "XyzLibrary.h"

...
// Клиент использует объект Xyz как обычный класс C++.
CXyz xyz;
xyz.Foo(42);

Как видно, экспортированный класс используется так же, как и любой другой класс C++. Ничего особенного.

Важно: Использование DLL, экспортирующей классы C++, не отличается от использования статической библиотеки. Все правила, применяемые к статической библиотеке, содержащей код C++, полностью применимы к DLL, экспортирующей классы C++.

Получишь не то, что видишь

Внимательный читатель уже заметил, что инструмент иллюстратора зависимостей показывает дополнительный экспортированный член, то есть оператор присваивания CXyz& CXyz::operator =(const CXyz&). Это работает богатство C++. По стандарту C++, каждый класс имеет четыре специальных функции-члена:
•    Конструктор по умолчанию
•    Конструктор копий
•    Деструктор
•    Оператор присваивания (оператор =)

Если автор класса не объявляет и не предоставляет реализацию этих членов, то компилятор C++ объявляет их и генерирует неявную реализацию по умолчанию. В случае класса CXyz компилятор решил, что конструктор по умолчанию, конструктор копий и деструктор достаточно тривиальны, и оптимизировал их. Однако оператор присваивания пережил оптимизацию и был экспортирован из DLL.

Важно: Пометка класса как экспортируемого посредством спецификатора __declspec(dllexport) приказывает компилятору попытаться экспортировать все, что связано с классом. Это включает в себя всех членов данных класса, всех членов-функций класса (явно объявленных или неявно сгенерированных компилятором), все базовые классы класса и все их члены. Рассмотрите:

class Base
{
    ...
};

class Data
{
    ...
};

// Компилятор MS Visual C++ выдает предупреждение C4275 о неэкспортированном базовом классе.
class __declspec(dllexport) Derived :
    public Base
{
    ...

private:
    Data m_data;    // Предупреждение C4251 о неэкспортированном члене данных.
};

Во  фрагменте кода выше компилятор предупредит о неэкспортированном базовом классе и неэкспортированном классе члена данных. Чтобы успешно экспортировать класс C++, разработчику необходимо экспортировать все важные базовые классы и все классы, используемые для определения членов данных. Это требование массового экспорта является существенным изъяном. Поэтому, например, очень трудно и утомительно экспортировать классы, производные от шаблонов STL, или использовать шаблоны STL как члены данных. Создание экземпляра контейнера STL вроде std::map<>, например, может требовать экспортирования десятков дополнительных внутренних классов.

Безопасность исключений

Экспортированный класс C++ может сгенерировать исключение без проблем. Из-за того, что одна и та же версия одного и того же компилятора C++ используются и DLL, и ее клиентом, исключения C++ генерируются и перехватываются через границы DLL, словно границ вообще нет. DLL, экспортирующая код C++, используется так же, как статическая библиотека с таким же кодом.

Плюсы
•    Экспортированный класс C++ используется так же, как любой другой класс C++.
•    Исключение, сгенерированное внутри DLL, перехватывается клиентом без проблем.
•    При внесении только небольших изменений в модуль DLL не требуется собирать другие модули. Это очень выгодно для больших проектов, где задействуется огромное количество кода.
•    Выделение логических модулей в большом проекте в модули DLL является первым шагом на пути к истинному выделению модулей. Это полезная деятельность, повышающая модульность проекта.

Минусы
•    Экспорт классов C++ из DLL не избавляет от очень сильной связи между объектом и его пользователем. DLL следует воспринимать как статическую библиотеку в отношении зависимостей кода.
•    Код и клиента, и DLL должен динамически компоноваться с помощью одной и той же версии CRT. Это необходимо для обеспечения правильного учета ресурсов CRT между модулями. Если клиент и DLL компонуются с разными версиями CRT, или компонуются с CRT статически, то ресурсы, полученные в одном экземпляре CRT, придется освобождать в другом экземпляре CRT. Это повредит внутреннее состояние экземпляра CRT, пытающегося производить действия над чужими ресурсами, и, скорее всего, приведет к краху.
•    И код клиента, и DLL должны договориться о модели обработки/распространения исключения и использовать одни и те же настройки компилятора в отношении исключений C++.
•    Экспорт класса C++ требует экспорта всего, что связано с этим классом: всех его базовых классов, всех классов, используемых для определения членов данных, и т.д.

Зрелый подход C++: использование абстрактного интерфейса

Абстрактный интерфейс C++ (т.е. класс C++, содержащий только чисто виртуальные методы и никаких членов данных) пытается получить лучшее из двух миров: независимый от компилятора чистый интерфейс для объекта и удобный объектно-ориентированный способ вызовов методов. Надо предоставить заголовочный файл с объявлением интерфейса и реализовать функцию-фабрику, возвращающую вновь созданные экземпляры объекта. Функция-фабрика должна быть объявлена со спецификатором __declspec(dllexport/dllimport). Интерфейс не требует никаких дополнительных спецификаторов.

// Абстрактный интерфейс для объекта Xyz.
// Не требуются никакие дополнительные спецификаторы.
struct IXyz
{
    virtual int Foo(int n) = 0;
    virtual void Release() = 0;
};

// Функция-фабрика, создающая экземпляры объекта Xyz.
extern "C" XYZAPI IXyz* APIENTRY GetXyz();

Во фрагменте кода выше функция-фабрика GetXyz объявлена как extern "C". Это требуется для предотвращения искажения имени функции. Эта функция предоставляется как обычная функция C и легко распознается любым совместимым с C компилятором. Так выглядит код клиента при использовании абстрактного интерфейса:

#include "XyzLibrary.h"

...
IXyz* pXyz = ::GetXyz();

if(pXyz)
{
    pXyz->Foo(42);

    pXyz->Release();
    pXyz = NULL;
}

C++ не предоставляет специального обозначения для интерфейса, как делают другие языки программирования (например, C# или Java). Но это не значит, что C++ не может объявлять и реализовывать интерфейсы. Типовой подход к созданию интерфейса C++ - объявить абстрактный класс без членов данных. Еще один отдельный класс наследуется от интерфейса и реализует методы интерфейса, но реализация скрыта от клиентов интерфейса. Клиент интерфейса не знает и не заботится о том, как реализован интерфейс. Он только знает, какие методы доступны и что они делают.