Плагины на основе COM интерфейсов

Страница 1 из 3

Роман Лут
В период с 1997 по 2001 работал в компании GSC Game World над 3D-экшеном Venom: Codename Outbreak в качестве программиста графического движка и дизайнера уровней. С 2002 работает в компании Deep Shadows ведущим программистом графического движка. Принимал непосредственное участие в работе над экшен-ролевым проектом Xenus: Boiling Point.

Связанные темы: Программирование с использованием абстрактных интерфейсов, экспорт классов из DLL, межъязыковое взаимодействие, система плагинов.

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

Взаимодействие программ, написанных на разных языках программирования

Несмотря на то, что Андрей Плахов в своей лекции о языках программирования на КРИ 2006 даже не упомянул о Delphi и C++ Builder[12], мы активно используем эти продукты для создания редакторов, утилит и плагинов.

Причина проста: продукты Borland позволяют очень быстро и легко писать GUI-приложения, и для них существует огромное количество полезных компонентов.

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

Ни Delphi, ни C++ Builder не являются совместимыми с Visual C++ по формату obj и lib файлов, поэтому единственным способом связывания остается экспорт функций из DLL.


Рисунок 1. Экспорт класса как набор функций.

В принципе, это работает, но есть масса неудобных моментов. Объектно-ориентированное программирование превращается в "пародию на объекты".

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

Поэтому приходится писать и экспортировать:

    а) функцию-конструктор, которая конструирует экземпляр класса (по new) и возвращает указатель на этот экземпляр в виде void*;
    б) полный набор прокси-функций, дублирующих методы класса, которые принимают указатель на экземпляр в виде void* и вызывают на экземпляре класса соответствующий метод;
    в) функцию-деструктор, которая принимает указатель на экземпляр в виде void* и уничтожает объект.

Пример:

В DLL на VC++ реализован класс TSphere. Вот так выглядит его экспорт-импорт в Delphi:


=================== VC ++ =================
class TSphere
 private:
  T3DVECTOR center;
  float radius;

 public:
...

 T3DVECTOR& GetCenter() const;
 float GetRadius() const;
...
};

void* __cdecl TSphere _Create()
{
 return new TSphere();
}

void __cdecl TSphere _GetCenter(void* pthis, float* x, float* y, float*z)
{
 T3DVECTOR c = ( (TSphere*)pthis)->GetCenter();
 *x = c.x;
 *y = c.y;
 *z =c.z;
}

float __cdecl TSphere _GetRadius(void* pthis)
{
  return ( (TSphere*)pthis)->GetRadius();

}


TSphere _Destroy(void* pthis)
{
 delete ( (TSphere*)pthis);
}

=================== Delphi  =================

function TSphere _Create(): pointer; cdecl; external 'mydll.dll';
procedure TSphere _GetRadius(pthis: pointer; var x,y,z: single); cdecl; external 'mydll.dll';
function TSphere _GetRadius(pthis: pointer): single; cdecl; external 'mydll.dll';
procedure TSphere _Delete(pthis: pointer); cdecl; external 'mydll.dll';


var
 p: pointer;

p:= TSphere _Create();
radius:= TSphere_GetRadius(p);
TSphere_Destroy(p);

Очевидно, что так работать совсем неудобно. При изменении или добавлении метода класса, необходимо исправлять прокси-функцию, ее отражение в проекте на другом языке, и заново пересобирать оба проекта. Кроме того, в случае экспорта Delphi->VC++ приходится описывать получение адреса через GetProcAddress(), так как VC++ не позволяет просто написать "функция находится в такой-то DLL", как это можно в Delphi. DLL приходится загружать динамически, с помощью функции LoadLibrary().

Несмотря на недостатки, этот способ широко используется при портировании библиотек. На эту тему есть множество статей [13] [14] [15] [16] [17] [18].

Фундаментальной проблемой является то, что один и тот же класс, откомпилированный разными компиляторами, или даже одним и тем же компилятором, но с разными настройками, не совместим в бинарном виде.

Нельзя передавать указатель на экземпляр класса из модуля на VC++ в модуль на C++Builder, в котором пытаться вызывать методы этого класса. Даже если используется один и тот же .h-файл с описанием класса.

К счастью, практически все компиляторы поддерживают Component Object Model (COM).

Оформляя классы, как COM-объекты, но не используя все "тяжелые" возможности COM, можно добиться, чтобы классы были бинарно-совместимыми между разными языками программирования и компиляторами.

Component object model (COM)

Архитектура COM - достаточно обширная тема, поэтому я просто укажу ссылки [1] [2] [6] [9] [11].

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

Код классов располагается в библиотеках (DLL), которые регистрируются в специальном разделе системного реестра. Каждый класс реализует один или несколько известных COM-интерфейсов. DLL экспортирует функцию для создания экземпляра указанного класса, которая возвращает указатель на базовый интерфейс IUnknown (все классы обязаны реализовывать этот интерфейс).

Каждому интерфейсу (описанию интерфейса) сопоставляется уникальный идентификатор (Global unique identifier - GUID).

Для создания экземпляра класса пользователь вызывает функцию CoCreateInstance(GUID) из библиотеки COM. Именно она занимается просмотром записей в реестре, загрузкой DLL и вызовом функции создания экземпляра.

Пользователь работает с объектом через указатель на интерфейс.

Для контроля существования объекта используется подсчет ссылок. После использования экземпляра класса, пользователь обязан вызвать IUnknown->Release() для уничтожения объекта.

Архитектура COM предполагает независимость от компиляторов, и поэтому бинарный формат COM-интерфейсов строго регламентирован.

COM-интерфейс можно воспринимать как базовый абстрактный класс - без конструктора, деструктора и полей данных. На самом деле, именно так он и описывается в C++.

Указатель на COM объект можно воспринимать как указатель на экземпляр класса, который наследован от базового абстрактного класса.

В бинарном виде указатель на COM объект представляет собой указатель на экземпляр объекта, в первых четырех байтах которого содержится указатель на таблицу виртуальных функций (vtable).


Рисунок 2. Бинарный формат COM объекта.

Плагины на основе COM

Идея использовать COM-подобные интерфейсы является расширением идеи использовать абстрактные интерфейсы в дизайне движка[4]. Преимущества такого подхода описаны в упомянутой статье: разделение интерфейса и реализации, инкапсуляция, низкая связанность и, как результат, понятная архитектура и простота сопровождения.

Игровой "движок" представляет собой набор различных менеджеров (объектов, текстур, моделей, уровней и т.д.). Создав COM-интерфейс для всех этих объектов, можно обеспечить широкие возможности для написания плагинов.

Система плагинов включает в себя:

  • менеджер плагинов - dlvmnager.dll. Менеждер занимается загрузкой плагинов и диспетчеризацией вызова DLVManager.GetInterface() во все модули DLV (аналог CoCreateInstance());
  • плагины - модули dll, переименованные в dlv. DLV модуль экспортирует три функции: DLV_Init(), DLV_GetInterface() и DLV_Close().
  • каждому описанию интерфейса сопоставляется уникальный индентификатор (DWORD) и версия (DWORD) (аналог GUID);
  • для расширения функциональности приложения, плагин либо настраивает callbacks/listeners в DLV_Init(), либо создает объекты/фабрики объектов с известными Id в DLV_GetInterface().


Рисунок 3. Архитектура системы плагинов.

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

void GetInterface(void** pInteface, DWORD interfaceID, DWORD interfaceVersion);
и другие модули смогут получать указатели на интерфейсы к менеджерам в этой DLL, и смогут работать с ними как с классами.

Какие это дает преимущества перед методом, описанным в начале статьи:

  1. Из DLL необходимо экспортировать всего одну функцию (GetInterface()), независимо от количества интерфейсов, реализованных в DLL. Это единственна функция, для которой нужно получать адрес, используя GetProcAddress().
  2. Для использования класса в программе на другом языке достаточно описать его COM-интерфейс.
  3. При добавлении нового метода нужно всего лишь добавить его в описание интерфейса (на всех языках). Сравните это с необходимостью описывать и экспортировать proxy-функций, а также получать ее адрес по GetProcAddress().

Приложение-пример

К статье прилагается приложение, реализующее предложенную систему[20.1].

Основной модуль приложения представляет собой диалог рис.4. Модуль реализован на Borland C++ Builder 6.0.


Рисунок 4. Основное окно приложения-примера.

Основной модуль реализует следующие интерфейсы:

    IFractalFactory - фабрика геометрических фигур;
    ICanvas - область рисования;
    IFractalMaker и IFractal (см. ниже).

Класс, реализующий интерфейс IDLVManager, находится в DLVManager.dll. Модуль реализован на VC++ 7.0.

Плагины - модули DLV - реализуют интерфейсы IFractalMaker и IFractal (реализованы на VC++ 7.0, Borland C++ Builder, Borland Delphi 6.0, C++ .net, C# .net).

При инициализации системы плагинов с помощью метода DLVManager->Init(), все модули регистрируют в фабрике IFractalFactory набор классов, реализующих интерфейс IFractalMaker.

Интерфейс IFractalMaker предназначен для получения описания и создания экземпляров классов с интерфейсом IFractal.

Интерфейс IFractal предназначен для рисования выбранной геометрической фигуры на ICanvas.

Правила описания COM-интерфейсов

Каждый язык программирования имеет свой синтаксис описания COM-интерфейсов. Кроме него, нужно знать также следующие правила:

  1. в качестве параметров можно передавать только простые типы (int, byte, ...), указатели на простые типы (int*, byte*, char*, ...), указатели на структуры (T3DVECTOR* V, ...), указатели на массивы (int*, ....), указатели на интерфейсы (IFractal*). При описании структуры необходимо позаботиться, чтобы выравнивание членов структуры было указано явно (#pragma pack());

    Пример.

     C++  virtual HRESULT __stdcall DrawPixel(DWORD x, DWORD y, DWORD RGB) = 0;
     Delphi  function DrawPixel(x, y, RGB : DWORD): HRESULT; stdcall;
     C++.net  virtual void DrawPixel(unsigned int x, unsigned int y, unsigned int RGB) = 0;
     C#  void DrawPixel(uint x, uint y, uint RGB);

     C++  virtual HRESULT __stdcall GetDesc(OUT const char** desc) const = 0;
     Delphi  function GetDesc(var desc: pchar): HRESULT; stdcall;
     C++.net  virtual void GetDesc(OUT const char** desc) = 0;
     C#  void GetDesc(out IntPtr desc);

  2. не разрешается передавать указатели на классы или специальные типы данных (например, String в Delphi). Если необходимо передавать указатель на класс, то нужно описать интерфейс, который реализует этот класс, и передавать указатель на интерфейс;

    Пример.

     C++  virtual HRESULT __stdcall Make(OUT IFractal** instance) = 0;
     Delphi  function Make(var instance: pointer): HRESULT; stdcall;
     C++.net  virtual void Make(OUT IntPtr* instance) = 0;
     C#  void Make(out IntPtr instance);

  3. объекты в разных модулях используют разные менеджеры памяти. Если метод возвращает указатель на структуру, выделенную в куче, то должен существовать метод для освобождения этого блока памяти;
    Пример. Для освобождения объекта используется метод Release().
    IFractal* fractal;
    factory->Make(ComboBox1->Items->Strings[ComboBox1->ItemIndex].c_str(), &fractal);
    fractal->Draw(CanvasWrapper);
    fractal->Release();
    
  4. по стандарту, все методы COM интерфейса (кроме AddRef() и Release()) должны возвращать HRESULT. Допускается возвращать другие типы, но интерфейс не будет совместим с C++.net и C#.net. Нельзя возвращать структуры по значению, так как в этом случае разные компиляторы создают несовместимый код.
    Пример.
    
    virtual HRESULT __stdcall GetVersion(OUT DWORD* version)
       {
        *version = IFractalMaker::VERSION;
        return S_OK;
       }
  5. все методы должны использовать метод вызова __stdcall;
  6. как я уже говорил ранее, интерфейсы не содержат полей данных, конструкторов, деструкторов, а все методы объявлены виртуальными абстрактными.
  7. интерфейсы не наследуются, но один объект может реализовывать несколько интерфейсов (т.е. объект может быть наследован от нескольких интерфейсов).
 
  стр. 1 из 3  

Copyright © 2014 ООО "ДТФ.РУ". Все права защищены.

Воспроизведение материалов или их частей в любом виде и форме без письменного согласия запрещено.

Замечания и предложения отправляйте через форму обратной связи.

Пользовательское соглашение