Как экспортировать классы C++ из DLL

ОГЛАВЛЕНИЕ

Назначение этой статьи – показать несколько методов экспорта классов C++ из модуля DLL. Исходный код демонстрирует разные приемы экспорта воображаемого объекта Xyz.

•    Скачать исходники - 11.1 Кб

Введение

Динамически подключаемые библиотеки (DLL) являются составной частью платформы Windows с самого ее начала. DLL позволяют инкапсулировать часть функционала в автономном модуле с точным списком функций C, доступных внешним пользователям. В 1980-е годы, когда Windows DLL были введены, единственным практичным вариантом разговора с широкой аудиторией разработчиков был язык C. Поэтому Windows DLL предоставляли свой функционал в виде функций и данных C. Внутренне DLL может быть реализована на любом языке, но для использования из других языков и сред интерфейс DLL должен вернуться к наименьшему общему знаменателю – языку C.

Использование интерфейса C не означает автоматически, что разработчик должен отказаться от объектно-ориентированного подхода. Даже интерфейс C можно использовать для истинного объектно-ориентированного программирования, хотя это трудоемкое дело. Неудивительно, что второй наиболее используемый язык программирования, а именно C++, стал жертвой искушения DLL. Однако, в отличие от языка C, где двоичный интерфейс между вызывающей программой и вызываемой программой точно определен и общепринят, в C++ нет признанного двоичного интерфейса приложений (ABI). На деле это значит, что двоичный код, генерируемый компилятором C++, несовместим с другими компиляторами C++. Более того, двоичный код одного и того же компилятора C++ может быть несовместим с другими версиями этого компилятора. Все это затрудняет экспорт классов C++ из DLL.

Назначение этой статьи – показать несколько методов экспорта классов C++ из модуля DLL. Исходный код демонстрирует разные приемы экспорта воображаемого объекта Xyz. Объект Xyz очень простой и имеет только один метод: Foo.

Ниже приведена схема объекта Xyz:

Xyz
int Foo(int)

Реализация объекта Xyz находится внутри DLL, распространяемой по широкому диапазону клиентов. Пользователь может получить доступ к функционалу Xyz путем:
•    Использования чистого C
•    Использования обычного класса C++
•    Использования абстрактного интерфейса C++

Исходный код состоит из двух проектов:
•    XyzLibrary – проект библиотеки DLL
•    XyzExecutable – консольная программа Win32, использующая "XyzLibrary.dll"

Проект XyzLibrary экспортирует свой код посредством следующего удобного макроса:

#if defined(XYZLIBRARY_EXPORT) // внутри DLL
#   define XYZAPI   __declspec(dllexport)
#else // вне DLL
#   define XYZAPI   __declspec(dllimport)
#endif  // XYZLIBRARY_EXPORT

Имя XYZLIBRARY_EXPORT определено только для проекта XyzLibrary, поэтому макрос XYZAPI расширяется в __declspec(dllexport) для сборки DLL и в __declspec(dllimport) для сборки клиента.


Подход языка 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++ - объявить абстрактный класс без членов данных. Еще один отдельный класс наследуется от интерфейса и реализует методы интерфейса, но реализация скрыта от клиентов интерфейса. Клиент интерфейса не знает и не заботится о том, как реализован интерфейс. Он только знает, какие методы доступны и что они делают.


Как это работает

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

Нажмите на изображение, чтобы просмотреть полноразмерную схему в новом окне:

Схема выше показывает интерфейс IXyz, используемый модулями DLL и EXE. Внутри модуля DLL класс XyzImpl наследуется от IXyz и реализует его методы. Вызовы методов в модуле EXE вызывают фактическую реализацию в модуле DLL через виртуальную таблицу.

Почему это работает с другими компиляторами

Краткое объяснение: потому что технология COM работает с другими компиляторами. Теперь длинное объяснение. Использование лишенного членов абстрактного класса как интерфейса между модулями – именно то, что COM делает для предоставления интерфейсов COM. Понятие виртуальной таблицы из языка C++ отлично встраивается в спецификацию стандарта COM. Это не совпадение. Язык C++, будучи преобладающим языком разработки более 10 лет, широко применялся в программировании COM вследствие естественной поддержки объектно-ориентированного программирования в языке C++.

Неудивительно, что Microsoft счел язык C++ основным мощным инструментом для промышленной разработки COM. Будучи владельцем технологии COM, Microsoft позаботился о том, чтобы двоичный стандарт COM и их собственная реализация объектной модели C++ в компиляторе Visual C++ совпадали с минимальными издержками.

Естественно, что другие разработчики компиляторов C++ присоединились к победителям и реализовали формат виртуальной таблицы в своих компиляторах так же, как сделал Microsoft. Все хотели поддерживать технологию COM и обеспечить совместимость с существующим решением от Microsoft. Гипотетический компилятор C++, не поддерживающий COM эффективно, обречен на забвение на рынке Windows. Поэтому сейчас предоставление класса C++ из DLL через абстрактный интерфейс надежно работает во всех достойных компиляторах C++ на платформе Windows.

Использование интеллектуального указателя

Чтобы обеспечить правильное освобождение ресурса, абстрактный интерфейс предоставляет дополнительный метод для уничтожения экземпляра. Вызов этого метода вручную трудоемок и подвержен ошибкам. Эта ошибка широко распространена в сфере C, где разработчик должен не забыть освободить ресурсы путем явного вызова функции. Поэтому типичный код C++ использует идиому RAII(получение ресурса является инициализацией) с помощью интеллектуальных указателей. Проект XyzExecutable использует шаблон AutoClosePtr, предоставленный с примером. Шаблон AutoClosePtr является простейшей реализацией интеллектуального указателя, вызывающего произвольный метод класса для уничтожения экземпляра вместо operator delete. Ниже приведен фрагмент кода, демонстрирующий использование интеллектуального указателя вместе с интерфейсом IXyz:

#include "XyzLibrary.h"
#include "AutoClosePtr.h"

...
typedef AutoClosePtr<IXyz, void, &IXyz::Release> IXyzPtr;

IXyzPtr ptrXyz(::GetXyz());

if(ptrXyz)
{
    ptrXyz->Foo(42);
}

// Не нужно вызывать ptrXyz->Release(). Интеллектуальный указатель
// вызовет этот метод автоматически в деструкторе.

Использование интеллектуального указателя обеспечивает правильное освобождение объекта Xyz несмотря ни на что. Функция может завершиться досрочно из-за ошибки или внутреннего исключения, но язык C++ гарантирует, что деструкторы всех локальных объектов будут вызваны при завершении.

Использование стандартных интеллектуальных указателей C++

Последние версии MS Visual C++ предоставляют интеллектуальные указатели вместе со стандартной библиотекой C++. Ниже приведен пример использования объекта Xyz вместе с классом std::shared_ptr:

#include "XyzLibrary.h"

#include <memory>
#include <functional>

...

typedef std::shared_ptr<IXyz> IXyzPtr;

IXyzPtr ptrXyz(::GetXyz(), std::mem_fn(&IXyz::Release));

if(ptrXyz)
{
    ptrXyz->Foo(42);
}

// Не нужно вызывать ptrXyz->Release(). Класс std::shared_ptr
// вызовет этот метод автоматически в своем деструкторе.

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

Так же, как интерфейсу COM не разрешено выдавать никакие внутренние исключения, абстрактный интерфейс C++ не вправе позволять никаким внутренним исключениям прорываться через границы DLL. Методы класса должны использовать коды возврата для указания ошибки. Реализация обработки исключений C++ очень специфичная для каждого компилятора и не может разделяться. Значит, в этом смысле абстрактный интерфейс C++ должен вести себя как простая функция C.

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

Минусы
•    Требуется явный вызов функции для создания нового экземпляра объекта и для его удаления. Однако интеллектуальный указатель избавляет разработчика от второго вызова.
•    Метод абстрактного интерфейса не может возвращать или принимать обычный объект C++ в качестве параметра. Это должен быть встроенный тип (например, int, double, char*, и т.д.) или другой абстрактный интерфейс. Это же ограничение действует для интерфейсов COM.

Как насчет шаблонных классов STL?

Контейнеры стандартной библиотеки C++ (например, vector, list или map) и другие шаблоны не были спроектированы с расчетом на DLL. Стандарт C++ умалчивает о DLL, потому что это зависящая от платформы технология, не обязательно присутствующая на других платформах, где используется язык C++. Сейчас компилятор MS Visual C++ может экспортировать и импортировать создания экземпляров классов STL, которые разработчик явно помечает спецификатором __declspec(dllexport/dllimport). Компилятор выдает пару неприятных предупреждений, но это работает. Однако надо помнить, что экспорт созданий экземпляров шаблона STL ничем не отличается от экспорта обычных классов C++ со всеми сопутствующими ограничениями. В этом смысле нет ничего особенного с STL.

Резюме

Статья рассмотрела разные методы экспорта объекта C++ из модуля DLL. Дано подробное описание плюсов и минусов каждого метода. Описаны принципы безопасности исключений. Сделаны следующие выводы:
•    Плюс экспорта объекта в виде набора простых функций C в совместимости с широчайшим спектром сред разработки и языков программирования. Однако пользователю DLL приходится использовать устаревшие приемы C или обеспечивать дополнительные обертки над интерфейсом C, чтобы использовать современные парадигмы программирования.
•    Экспорт обычного класса C++ ничем не отличается от предоставления отдельной статической библиотеки с кодом C++. Применение очень простое и знакомое; но есть сильная связь между DLL и ее клиентом. Должна использоваться одна и та же версия одного и того же компилятора C++ и для DLL, и для ее клиента.
•    Объявление абстрактного класса без членов и его реализация внутри модуля DLL пока является лучшим подходом к экспорту объектов C++. Этот метод обеспечивает чистый, строго определённый объектно-ориентированный интерфейс между DLL и ее клиентом. Такая DLL может использоваться вместе с любым современным компилятором C++ compiler на платформе Windows. Использовать интерфейс вместе с интеллектуальными указателями почти столь же легко, как и использовать экспортированный класс C++.

Язык программирования C++ является мощным, универсальным и гибким инструментом разработки.