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

Статья опубликована на сайте dtf.ru

Связанные темы: Программирование с использованием абстрактных интерфейсов, экспорт классов из 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, и смогут работать с ними как с классами. Какие это дает преимущества перед методом, описанным в начале статьи:

  • Из DLL необходимо экспортировать всего одну функцию (GetInterface()), независимо от количества интерфейсов, реализованных в DLL. Это единственна функция, для которой нужно получать адрес, используя GetProcAddress().
  •  Для использования класса в программе на другом языке достаточно описать его COM-интерфейс.
  •  При добавлении нового метода нужно всего лишь добавить его в описание интерфейса (на всех языках). Сравните это с необходимостью описывать и экспортировать 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-интерфейсов. Кроме него, нужно знать также следующие правила:

  • в качестве параметров можно передавать только простые типы (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);

  • не разрешается передавать указатели на классы или специальные типы данных (например, 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);
  • объекты в разных модулях используют разные менеджеры памяти. Если метод возвращает указатель на структуру, выделенную в куче, то должен существовать метод для освобождения этого блока памяти;

    Пример. Для освобождения объекта используется метод Release().

    IFractal* fractal; 
    factory->Make(ComboBox1->Items->Strings[ComboBox1->ItemIndex].c_str(), &fractal);
    fractal->Draw(CanvasWrapper); fractal->Release();

  • по стандарту, все методы COM интерфейса (кроме AddRef() и Release()) должны возвращать HRESULT. Допускается возвращать другие типы, но интерфейс не будет совместим с C++.net и C#.net. Нельзя возвращать структуры по значению, так как в этом случае разные компиляторы создают несовместимый код.

    Пример.

    virtual HRESULT __stdcall GetVersion(OUT DWORD* version) { *version = IFractalMaker::VERSION;  return S_OK; }

  • все методы должны использовать метод вызова __stdcall;
  • как я уже говорил ранее, интерфейсы не содержат полей данных, конструкторов, деструкторов, а все методы объявлены виртуальными абстрактными.
  • интерфейсы не наследуются, но один объект может реализовывать несколько интерфейсов (т.е. объект может быть наследован от нескольких интерфейсов).

VC++ и Borland С++ Builder

Описание интерфейса на языке C++ полностью совпадает с описанием абстрактного класса (стандартные макросы DECLARE_INTERFACE() из Platform SDK описывают интерфейсы как структуры).

Пример описания COM интерфейса на C++:

//===========================================================
// ICanvas
//===========================================================
DECLARE_INTERFACE_(ICanvas, IUnknown)
{
 IUNKNOWN_METHODS_PURE(0x83893202,0x00010000)
 virtual HRESULT __stdcall GetWidth(OUT DWORD* width) const = 0;
 virtual HRESULT __stdcall GetHeight(OUT DWORD* height) const = 0;
 virtual HRESULT __stdcall DrawPixel(DWORD x, DWORD y, DWORD RGB) = 0;
 virtual HRESULT __stdcall DrawLine(DWORD x1, DWORD y1, DWORD x2, DWORD y2, DWORD RGB) = 0;
};

Архитектура COM требует, чтобы все интерфейсы были наследованы от IUnknown. На практике это значит, что первыми тремя методами в описании интерфейса должны быть QueryInterface(), AddRef(), RemoveRef(). Они объявляются макросом IUNKNOWN_METHODS_PURE.

//Definition of IUnknown methods
#define IUNKNOWN_METHODS_PURE(InterfaceId, InterfaceVersion) \
virtual HRESULT __stdcall QueryInterface(REFIID riid, void** ppv) = 0; \
virtual ULONG __stdcall AddRef() = 0; \
virtual ULONG __stdcall Release() =0 ; \
typedef enum \
{ \
 ID = ##InterfaceId, \
 VERSION = ##InterfaceVersion, \
 FORCE_DWORD = 0xffffffff \
} desc; \


Вот так выглядит реализация интерфейса в классе TCanvasWrapper, который представляет собой область рисования:

//=========================================
// class TCanvasWrapper
//=========================================
class TCanvasWrapper : public ICanvas
{
 private:
 DWORD width;
 DWORD height;
 TCanvas* canvas;
 DWORD SwapRB(DWORD RGB);
 public:
 //================== begin COM interface ===================
 IUNKNOWN_METHODS_IMPLEMENTATION_REFERENCE()
 virtual HRESULT __stdcall GetWidth(OUT DWORD* width) const;
 virtual HRESULT __stdcall GetHeight(OUT DWORD* height) const;
 virtual HRESULT __stdcall DrawPixel(DWORD x, DWORD y, DWORD RGB);
 virtual HRESULT __stdcall DrawLine(DWORD x1, DWORD y1, DWORD x2, DWORD y2, DWORD RGB);
 //================== end COM interface ===================
 TCanvasWrapper(TCanvas* canvas, DWORD width, DWORD height);
};

Поскольку реализация методов интерфейса IUnknown у всех объектов будет одинакова, достаточно определить два макроса IUNKNOWN_METHODS_IMPLEMENTATION_REFERENCE (без подсчета ссылок) и IUNKNOWN_METHODS_IMPLEMENTATION_INSTANCE (с подсчетом ссылок), или создать промежуточные классы TUnknown, реализующие эти методы.

Здесь следует упомянуть небольшой нюанс: необходимо, чтобы метод QueryInterface() возвращал правильные указатели на интерфейс IUnknown, а также указатель на реализуемый интерфейс, так как это необходимо для поддержки плагинов на .net языках (см. ниже).

//Implementation of IUnknown methods for singletons - no reference counting
#define IUNKNOWN_METHODS_IMPLEMENTATION_REFERENCE() \
virtual HRESULT __stdcall QueryInterface(REFIID riid, void** ppv) \
{ \
 if (riid == IID_IUnknown) \
 { \
 *ppv = this; \
 return S_OK; \
 } \
 else \
 if (riid.Data1 == (unsigned long)ID && riid.Data2 == 0 && riid.Data3 ==0 && \
 riid.Data4[0] == 0 && riid.Data4[1] == 0 && \
 riid.Data4[2] == 0 && riid.Data4[3] == 0 && \
 riid.Data4[4] == 0 && riid.Data4[5] == 0 && \
 riid.Data4[6] == 0 && riid.Data4[7] == 0) \
 { \
 *ppv = this; \
 return S_OK; \
 } \
 else \
 { \ *ppv=NULL; \
 return E_NOINTERFACE; \
 } \
}; \
virtual ULONG __stdcall AddRef() {return 1;}; \ virtual ULONG __stdcall Release() {return 1;};


Borland Delphi

Для описания COM интерфейсов в Object Pascal используется ключевое слово interface:

const IID_ICANVAS = $83893202;
ICANVAS_VERSION = $00010000;
//===========================================================
//ICanvas
//===========================================================
type ICanvas = interface (IUnknown)
 function GetWidth(var width: DWORD): HRESULT; stdcall;
 function GetHeight(var height: DWORD): HRESULT; stdcall;
 function DrawPixel(x, y, RGB : DWORD): HRESULT; stdcall;
 function DrawLine(x1, y1, x2, y2, RGB: DWORD): HRESULT; stdcall; end;


При этом методы интерфейса IUnknown объявляются неявно, и их нужно реализовать в наследнике:

//=====================================================
// class IUNKNOWN_InstanceBase
//=====================================================
type IUNKNOWN_InstanceBase = class (TObject,IUnknown)
 private refCount: DWORD;
 protected function DoQueryInterface(const IID: TGUID; out Obj): HResult; virtual; abstract;
 public function QueryInterface(const IID: TGUID; out Obj): HResult; stdcall;
 function _AddRef: Integer; stdcall; function _Release: Integer; stdcall;
 constructor Create(); end;


Указатели на COM-интерфейсы в Object Pascal являются “умными” указателями. Чтобы правильно их использовать, необходимо знать следующие правила:

  1. Присваивание адреса указателю на интерфейс автоматически вызывает AddRef() на копируемом интерфейсе, и Release() на освобождаемом:

    iptr1: ISomeInteface;
    iptr2: ISomeInterface;
    iptr1:=iptr2; //вызывает Release() на iptr1, вызывает AddRef() на iptr2


    Это же правило действует при присваивании указателям на интерфейсы, переданным в функцию по var.

  2. При создании переменной-указателя на интерфейс, ей автоматически присваивается значение nil.
  3. Присваивание указателю на интерфейс значения nil вызывается Release() на освобождаемом интерфейсе(если он не-nil):

    iptr1: ISomeInteface;
    iptr1:=nil; //вызывает Release() на iptr1

  4. Если указатель на интерфейс выходит из области видимости, завершающий код проверят значение указателя, и если он не равен nil, вызывает Release():

    procedure myFunc();
     var iptr: ISomeInterface;
    begin
     iptr:=… …
    end; //здесь неявно вызывается Release()

  5. При присваивании структур, членами которых являются указатели на интерфейсы, на каждом не-nil интерфейсе вызываются Release()/AddRef(). При выходе из зоны видимости структуры, членами которой являются указатели на интерфейсы, на каждом не-nil интерфейсе вызывается Release().
  6. При передаче указателя на интерфейс в качестве параметра в функцию по значению, на интерфейсе вызывается AddRef(), при передаче по ссылке – нет:

    procedure MyFunc1(ptr :ISomeInterface); //Вызвает AddRef() на входе в функцию, Release() на выходе
    procedure MyFunc2(var ptr: ISomeInterface); //не вызывает AddRef()/Release()
    procedure MyFunc3(const ptr: ISomeInterface); //не вызывает AddRef()/Release()

Если функция возвращает указатель на интерфейс, то AddRef() вызывается один раз:

function GetInterface():ISomeInterface;
begin
 result:=TSomenterfaceImplementator.Create ();
 //вызывает AddRef(), refount=2
 //предполагается, что TSomenterfaceImplementator.Create () создает объект с начальным значением счетчика ссылок, равным 1
end;
var iptr: ISomeInterface;
begin
  iptr:=GetInterface(); //AddRef() не вызывается, refcount=2
end;

Это поведение более очевидно, если вспомнить, что при возврате из функции структуры (или указателя на интерфейс), в функцию передается адрес, по которому нужно записать выходное значение. Самой переменной result на самом деле не существует:

procedure GetInterface(var result: ISomeInterface); //эквивалентно функции

Очевидно, что указанные правила достаточно сложны. Можно значительно упростить поведение компилятора, и избавиться от “закулисной магии”, если работать с указателями на интерфейсы как с обычными указателями, и приводить их к типу указателя на интерфейс только непосредственно при вызове метода:

Var iptr: pointer;
Begin
 iptr:=GetInterfacePointer(); //не вызывает AddRef(); (ф-ция возвращает Pointer)
 ISomeInterface(iptr).SomeMethod(); //вызвать метод интерфейса
 iptr:=nil; //не вызывает Release();

Можно избавиться от лишних вызовов AddRef() и Release(), приводя тип указателя на интерфейс к обычному указателю:

Var
 iptr : ISomeInterface;
begin
 pointer(iptr):=GetInterfacePointer(); //не вызывает AddRef(); (ф-ция возвращает Pointer)
 iptr.SomeMethod();
 //не забываем обнулить указатель, иначе при выходе из функции будет неявно вызван Release()
 pointer(iptr):=nil; //не вызывает Release();
end;

Пример 2:

Var
 Iptr1 : ISomeInterface;
 iptr2: ISomeInterface;

iptr1:=iptr2; //вызывает iptr1.Release() и iptr2.AddRef();
pointer(iptr1):=pointer(iptr2); //не вызывает ничего


Использование обычных указателей лично мне видится более простым. Единственное, что хочу заметить: если не используется подсчет ссылок, то при удалении объектов, реализующих интерфейс, нужно соблюдать осторожность – необходимо явно обнулить все указатели на интерфейс, чтобы компилятор не вызвал Release() на уничтоженном объекте.

Пример:

var
 iptr : ISomeInterface;
 i: integer;
begin
 for i:=0 to Manager.ObjectsCount()-1 do
 begin
 pointer(iptr):=Manager.GetObject(i);
 if iptr.Selected()=true then
 begin
 Manager.DeleteObject(i); //обязательно обнулить указатель, иначе при выходе из функции будет вызван Release() на уничтоженном объекте.
 pointer(iptr):=nil;
 break;
 end;
 end;
end;


При этом явное объявление переменной-указателя на интерфейс в данном случае является необходимым. Что неправильно в следующем примере?

var
 i: integer;
begin
for i:=0 to Manager.ObjectsCount()-1 do
 if ISomeInterface(Manager.GetObject(i)).Selected()=true then
 begin
 Manager.DeleteObject(i);
 break;
 end;
end;


Если Вы догадались, что компилятор _может_ создать временную переменную типа ISomeInterface, которую постарается уничтожить при выходе из процедуры (то есть после удаления объекта) – снимаю перед Вами шляпу, дальше можно не читать.

Managed C++

 

Вообще-то, при разработке под платформу .net, проблема межъязыкового взаимодействия отсутствует в принципе. Однако, если некоторые модули приложения написаны на native языках, можно воспользоваться COM интерфейсами.

Для того, чтобы дать возможность native клиентам вызывать managed интерфейс, необходимо объявить его со специальными атрибутами:

//==========================================================
// ICanvas
//==========================================================
[InterfaceTypeAttribute(ComInterfaceType::InterfaceIsIUnknown),
 GuidAttribute("83893202-0000-0000-0000-000000000000")]
public interface class ICanvas
{
 public:
  virtual void GetWidth(OUT unsigned int* width)  = 0;
  virtual void GetHeight(OUT unsigned int* height) = 0;
  virtual void DrawPixel(unsigned int x, unsigned int y, unsigned int RGB) = 0;
  virtual void DrawLine(unsigned int x1, unsigned int y1, unsigned int x2,
  unsigned int y2, unsigned int RGB) = 0;
};

При описании интерфейса на других языках нужно учитывать, что все методы должны возвращать HRESULT; в .net это подразумевается неявно.

Реализация интерфейса на managed C++ компилируется в управляемый код, поэтому невозможно напрямую вызывать методы интерфейса из native кода. Для этого компилятор создает так называемый Callable COM Wrapper – специальный класс в native коде, который производит конвертирование типов и вызов managed функций.

 

Рисунок 5. Callable COM Wrapper (CCW).
Получить CCW можно с помощью следующей функции:

//===============================================
// IntPtr GetCCW()
//===============================================
//return ptr to COM callable wrapper for object implementing interface interfaceType
//used to pass pointers to interfaces out of .net framework
static IntPtr GetCCW(Object^ obj, Type^ interfaceType)
  {
   GuidAttribute^ ga = (GuidAttribute^)Attribute::GetCustomAttribute(interfaceType,
 GuidAttribute::typeid);

   String^ SIID = ga->Value;

   Guid guid(SIID);

   IntPtr unknownIntPtr = Marshal::GetIUnknownForObject(obj);

   //calls AddRef()
   IntPtr CCW;
   Marshal::QueryInterface(unknownIntPtr, guid, CCW);

   //we should not add refs when asking for CCW;
   //app is responsible for lifetime of object
   int ii = Marshal::Release(unknownIntPtr);
   System::Diagnostics::Debug::WriteLine("refcount after GetCCW() = " + ii);

   return CCW;
  }


Для того, чтобы managed код мог вызывать методы native COM интерфейсов, необходимо получить указатель на специальный объект (Runtime Callable Wrapper, RCW), который будет производить конвертацию типов и вызов native кода:

ICanvas^ Canvas = (ICanvas^)Marshal::GetTypedObjectForIUnknown(pCanvas, ICanvas::typeid);

 

Рисунок 6. Runtime Callable Wrapper (RCW).

Для того, чтобы код мог правильно получить указатель на такой объект, необходимо, чтобы реализация метода QueryInterface() корректно возвращала указатели на интерфейсы IUnknown и запрашиваемый интерфейс – см. реализацию метода QueryInterface() – макрос IUNKNOWN_METHODS_ IMPLEMENTATION_REFERENCE(), приведенный выше.

Поскольку DLL содержит .net код, менеджер плагинов не может напрямую вызывать функции DLV_Init(), DLV_Close() и DLV_GetInterface(). Описав их как extern “C”__declspec(dllexport), мы заставляем компилятор создать native-код для вызова .net функций из native кода:

extern "C" __declspec(dllexport) 
void DLV_Init() 
{
…
}

C#

Из всех упомянутых языков, C# наиболее сложно использовать для написания плагинов. Вообще говоря, отсутствие возможности экспортировать native-функци из DLL делает это невозможным. Выходит, что для того, чтобы написать плагин на C#, придется еще написать и Managed C++ проект, который экспортирует функции DLV_Init(), DLV_Close(), DLV_GetInterface() и вызовет соответствующие методы из сборки на C#.

Именно так все и работает, когда вы создаете COM-серверы на .net языках: в качестве COM-сервера регистрируется не сборка, а специальная DLL, вызывающая методы из сборки.

Однако, порывшись в интернете, я все-таки нашел способ экспортировать native функции из C# .net сборки[19.1]. Негативной стороной описанного способа является отсутствие возможности запускать плагин под отладчиком после такого преобразования сборки.

Описание интерфейса на C# следует тем же правилам, что и Managed C++:

//==========================================================
 // ICanvas
 //==========================================================
 [InterfaceTypeAttribute(ComInterfaceType.InterfaceIsIUnknown),
  Guid("83893202-0000-0000-0000-000000000000")]
 public interface ICanvas
 {
    void GetWidth(out uint width);
    void GetHeight(out uint height);
    void DrawPixel(uint x, uint y, uint RGB);
    void DrawLine(uint x1, uint y1, uint x2, uint y2, uint RGB);
 };

 public static class iCanvas
 {
  public static uint ID = 0x83893202;
  public static uint VERSION = 0x00010000;
 };

Те же правила действуют и при получении CCW/RCW.

Кроме этого, нужно знать еще несколько правил:

  1. В .net используется сборщик мусора. Он может перемещать объекты в памяти. Поэтому в native код можно передавать только те указатели, которые указывают на объекты, специально созданные в неперемещаемой памяти:
    private IntPtr name;
    
    name = System.Runtime.InteropServices.Marshal.StringToHGlobalAnsi("Diamond, implemented
     in C# plugin");
    
    public void GetDesc(out IntPtr desc)
    {
     desc = name;
    }
  2. Сборщик мусора уничтожает объект только в момент сборки мусора. Это значит, что объект может “жить” еще очень долго после момента, когда счетчик ссылок достиг 0. Если объект использует какие-то ресурсы, то необходимо предусмотреть метод, который вызывает их освобождение, так как деструктор объекта не будет вызван в момент освобождения объекта по Release()
  3. При закрытии плагина необходимо вызвать методы:
    GC.Collect();
    GC.WaitForPendingFinalizers();

    чтобы корректно отработали деструкторы всех объектов (поэтому проблема, которая упоминается к комментариям к статье [19.2] на самом деле не существует).

В книге[21] очень подробно описаны различные примеры взаимодействия managed и native кода.

Заключение

Описанную систему можно расширить и для других языков: VB, VB.net, J# и др. К сожалению, я слишком плохо знаю эти языки, чтобы написать пример.

К статье прилагается приложение-пример[20.2]. Описанные в нем приемы работы наглядно раскрывают способы реализации.

Примерно такая же система используется в движке Vital Engine 3.0. В примере к данной статье, система плагинов немного упрощена, чтобы сосредоточить внимание на основных принципах, а также расширена для поддержки .NET языков.

Ссылки

[1]3 кита COM. Кит первый: реестр

[2] 3 кита COM. Кит второй: dll

[3]Adding Plug-ins To Your Application

[4] “Programming with abstract interfaces”
Book: “Game Programming Gems 2 ”

[5]”Exporting C++ classes from DLLs”
Book: “Game Programming Gems 2 ”

[6] COM Interface Basics

[7]Abstract class versus Interface

[8]C++ и Java: совместное использование

[9] COM in plain C

[10]How to automate exporting .NET function to unmanaged

[11] Архив статей “Что такое “технология COM”

[12] Андрей Плахов.Параллельное измерение, или за гранью C++

[13] Вызов Delphi DLL из MS Visual C++

[14] Using C++ objects in Delphi

[15] Utilizing Delphi Codes in VC Without Using a DLL

[16] Using C DLLs with Delphi

[17] Step by Step: Calling C++ DLLs from VC++ and VB – Part 2

[18] Создание в среде Borland C++ Builder dll, совместимой с Visual C++

[19.1] [19.2] Unmanaged code can wrap managed methods

[20.1] [20.2] Приложение – пример

[21] Bruce Bukovics. .NET 2.0 Interoperability recipes.
ISBN-13: 978-1-59059-669-2, ISBN-10: 1-59059-669-2