Предыдущая Оглавление Следующая

Интерфейс IUnknown

Итак, решение о том, что ещё не хватает нашему объекту сервера для того, чтобы с ним мог нормально обращаться клиент - найдено. Остались некоторые детали оформления этого решения в точную программную конструкцию. Мы установили, что требуется аппарат приведения типов указателей на интерфейсы и аппарат подсчёта ссылок на объект, с встроенным "самоликвидатором". Они могут быть доступны клиенту только посредством методов объекта, которые вызывает клиент и эти самые методы должны быть либо в составе каждого интерфейса, реализуемого объектом, либо - оформлены отдельным специальным интерфейсом. И абсолютно каждый объект, какого бы рода и племени он ни был - обязан реализовывать эту функциональность.

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

Определимся, о чём мы говорим. Первый метод будет называться QueryInterface - он должен принимать IID другого интерфейса, экспонируемого данным же объектом, и возвращать указатель на этот интерфейс. Второй метод будет называться AddRef - он не имеет параметров, а каждый его вызов приводит к продвижению счётчика ссылок объекта вперед на единицу. Третий метод - Release. Его задача - обратная AddRef, а когда счётчик ссылок достигнет нуля Release же вызовет и delete this.

Почему вместо одного метода по управлению счётчиком ссылок мы придумали два? Хотя бы потому, что код вызова метода без параметров - короче. Пусть на несколько байтов, но эти несколько байтов будут в клиенте встречаться всюду, где у нас размножается указатель. И суммарная добавка к коду может быть большой.

Итак, эти три метода:

  1. QueryInterface
  2. AddRef
  3. Release

мы можем оформить в отдельный интерфейс. Либо - мы можем прописывать в состав каждого другого интерфейса. Что лучше? И почему?

Допустим, эта функциональность выведена в совершенно отдельный интерфейс X, а все другие интерфейсы этого же объекта её не имеют. Что произойдёт? А произойдёт вот что - если при создании объекта мы попросим у сервера вернуть нам указатель на интерфейс X, то, владея этим указателем, мы легко получим указатели и на все другие интерфейсы объекта - QueryInterface же находится в составе интерфейса X. Но вот если мы у сервера попросим вернуть любой другой интерфейс этого же объекта, то так с этим интерфейсом и останемся - в этом-то интерфейсе нет QueryInterface. Это вынуждает всякий раз запрашивать не нужный нам интерфейс, а именно X, и потом из него уже производить нужный нам указатель. Налицо двойная работа на стороне клиента - при получении указателя на интерфейс.

Если же эту функциональность помещать внутрь всякого интерфейса, который экспонирует объект, то у каждого интерфейса будут заниматься три дополнительных ячейки Vtbl, но зато нам не нужно будет выполнять никакой двойной работы на стороне клиента, всё управление объектом можно осуществить посредством его любого интерфейса. И как-то кажется, что второй способ значительно удобнее со стороны клиента, разумеется - повторно-то используется именно сервер.

Но ведь так можно построить и такой объект, у которого не будет ни одного "полезного" интерфейса? Можно. А вот эта служебная функциональность в любом случае должна быть. И можно будет размножать указатель, а потом - уничтожить объект. А всё что сверх того - определяется исключительно существом решаемой программистом задачи.

Описанная функциональность настолько фундаментальна, что без нее "вообще ничего не работает" - мы ведь и задумались над ней потому, что в нашей реализации компонентного взаимодействия не хватало очень существенных фрагментов. Можно ли её реализовать иначе? В деталях - да, в сущности - нет. Ведь причина наличия этой функциональности в составе объекта - философская. Если бы у нас компилятор знал точный статический тип объекта и тип этот был один и для клиента и для сервера, то конечно, компилятор мог бы реализовать и правильный вызов new и правильный вызов delete*, и сам компилятор мог бы преобразовывать указатели на тип... Но, фактически, это означает, что и клиент и сервер должны располагаться в контексте одного и того же проекта - а мы как раз имеем совершенно обратные "начальные условия". У нас и клиент и сервер обязательно должны располагаться в разных проектах, в разных контекстах. У нас ведь - программирование из двоичных компонент.

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

Нужно особо заметить - что хотя мы и изучаем COM, то, что сейчас сформулировано имеет философскую природу. А, значит, в том или ином виде может быть найдено и в реализации CORBA и должно быть вообще в любой двоично-компонентной технологии. "Обёртка" этого может быть разной, а вот сущность - одинакова.

В COM эта действительно фундаментальная сущность называется "интерфейс IUnknown", что в вольном переводе может звучать как "неизвестный интерфейс" и вызывает по меньшей мере некоторое недоумение - какой же это неизвестный интерфейс, если его наличие гарантировано в любом объекте? Однако, если это переводить, как "интерфейс Неизвестно Кто", - всё встаёт на свои места. Более того, двоичный компонентный объект будет объектом COM в том и только в том случае, если он реализует, по меньшей мере, интерфейс IUnknown. Если такого интерфейса нет - это не объект COM, хотя, как мы уже видели, объект может быть "двоично-компонентным" и без использования COM.

С этим интерфейсом нам волей-неволей придётся познакомиться очень близко - он есть "альфа и омега" всех интерфейсов, именно он определяет специальное поведение объекта. А сейчас пока что отметим - любой интерфейс COM должен быть унаследован от интерфейса IUnknown. Должно быть понятно почему - именно IUnknown в составе любого интерфейса обеспечивает управление временем жизни объекта и приведение типа указателя. И для этого не нужно никаких дополнительных затрат клиента.

Собственно, только введение в состав объектов нашего предыдущего примера реализации IUnknown и отделяло наши объекты от превращения в "настоящие объекты COM". Но для того, чтобы двигаться дальше нам надо точно познакомиться со спецификацией - что есть "интерфейс COM" в C++ и как он описывается.

С++ - очень гибкий язык (сделайте в другом языке вызов функции с переменным числом параметров или откомпилируйте функцию без пролога/эпилога?), а именно в данном случае конструирования его гибкость является скорее недостатком. Поэтому, определяя проектные конструкции C++, предназначенные для построения конструкций COM постоянно приходится помнить о том, что компилятор должен быть специально ограничен. Это - категория ошибок, причина которых очень трудно диагностируется - ведь в разных единицах компиляции порознь-то все правильно! Например, стандартным соглашением о связях для компилятора C++ является __cdecl, которое предписывает вызывающей процедуре не только помещать параметры в стек перед вызовом вызываемой процедуры, но и самой очищать стек после вызова. А вызываемая процедура этого не делает. Это - единственная возможность правильно оформить вызов функции с переменным числом параметров. Во всех других языках соглашение о связях - __stdcall, которое предписывает вызываемой процедуре самой очищать стек перед завершением. Стоит написать клиента, который оформит вызов метода в __stdcall (по умолчанию для его языка), а сам метод сервера будет написан в соглашении __cdecl (тоже по умолчанию, но для C++), как вызов метода будет приводить в разрушению стека процесса и нарушению защиты памяти. Вы сможете "с ходу" припомнить почему бы это могло быть?

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

Другой распространённой ошибкой отнимающей пропасть собственной жизни программиста является неидентичность интерфейсов, используемых при сборке клиента и сервера. Технология COM с этим научилась бороться, как именно, мы до этого ещё дойдем. Но в C++ интерфейс описывается только чисто абстрактным классом. Соответственно, где-то существует файл, подаваемый компилятору, где все эти классы и перечислены - файл с определением всех интерфейсов. И это только в теории "интерфейс - никогда не изменяемая сущность". Пока интерфейс не опубликован и разрабатывается, программист, естественно, его иногда и изменяет. Изменять-то изменяет, а вот всё ли (всех клиентов) после этого перекомпилирует и пересобирает? Об этом тоже всегда следует помнить.

Поэтому точное знание "какая именно конструкция языка есть интерфейс в COM" для программиста - насущная необходимость. В языке C++ всякий интерфейс описывается структурой - структура это класс, все члены которого являются public. Не исключено и описание интерфейса самой конструкцией class. В включаемом файле <basetyps.h> имеются такие определения конструкций для определения частей интерфейса:

#define STDMETHODCALLTYPE __stdcall
#define interface struct
#define STDMETHOD(method) virtual HRESULT STDMETHODCALLTYPE method
#define STDMETHOD_(type,method) virtual type STDMETHODCALLTYPE method
#define DECLARE_INTERFACE(iface) interface iface 
#define DECLARE_INTERFACE_(iface, baseiface) interface iface : public baseiface

а в файле <wtypes.h>:

typedef LONG HRESULT;

В файле же <unknwn.h> (с небольшими сокращениями и упрощениями) известный нам интерфейс описан как:

class IUnknown
{
public:
  virtual HRESULT STDMETHODCALLTYPE QueryInterface(REFIID riid, void ** ppvObject) = 0;
  virtual ULONG STDMETHODCALLTYPE AddRef(void) = 0;
  virtual ULONG STDMETHODCALLTYPE Release(void) = 0; 
};

из чего можно заключить, что стандартный интерфейс это:

struct
{
  ...

или

class
{
public:
  ...

а стандартный метод, экспонируемый интерфейсом, должен иметь атрибуты:

virtual long __stdcall <имя метода>(<<списокпараметров метода>) = 0;

Учитывая, что в среде компиляции определено макро STDMETHOD(method), методы в интерфейсе можно описывать и так:

STDMETHOD(<имяметода>)(<список параметров метода>) = 0;

Ну, и немного о типе HRESULT - это тип стандартного значения, которое должен возвращать COM-метод. Все методы любых интерфейсов, кроме методов IUnknown::AddRef и IUnknown::Release, обязаны возвращать значение именно этого типа. Структура сообщения о состоянии, возвращаемого через HRESULT, тоже определена на уровне системной спецификации и мы рассмотрим эту спецификацию позднее, в соответствующей теме. Когда именно проблема возвращения кода ошибки из сервера клиенту для нас станет актуальной. А пока, в следующей статье, мы возвращаемся к основному интерфейсу COM - к интерфейсу IUnknown.

Итак - интерфейс IUnknown является основным интерфейсом на котором зиждется COM. Его обязан реализовывать любой COM-объект, он обязан присутствовать в составе любого интерфейса, экспонируемого объектом. Но IUnknown - совершенно особый интерфейс, если иметь в виду не его видимость со стороны клиента, а его реализацию внутри объекта сервера.

Знакомство лучше начать с точной спецификации. Точная спецификация интерфейса IUnknown содержится в заголовочном файле C++ с именем <unknwn.h>:

MIDL_INTERFACE("00000000-0000-0000-C000-000000000046") IUnknown
{
public:
  virtual HRESULT STDMETHODCALLTYPE QueryInterface(REFIID riid,void **ppvObject) = 0;
  virtual ULONG STDMETHODCALLTYPE AddRef(void) = 0;
  virtual ULONG STDMETHODCALLTYPE Release(void = 0; 
};

Его определение немного сокращено за счёт конструкций языка C++ для нас пока не существенных, но не выплеснул вместе с водой ребенка - файл этот не написан руками, а сгенерирован компилятором MIDL c языка IDL - специального языка, на котором описываются интерфейсы. Поэтому компилятор туда вставляет всё что можно и для всех возможных случаев использования этого файла. Если хотите заглянуть в оригинал сами, то вот ссылка на его "стандартное" местоположение (если соглашаться на те пути и каталоги, которые при инсталляции Visual Studio предлагает по умолчанию инсталлятор) - "C:\Program Files\Microsoft Visual Studio\VC98\Include\UNKNWN.H".

Интерфейс IUnknown имеет "свой GUID":

{00000000-0000-0000-C000-000000000046}

который в программах на C++ адресуется ссылкой IID_IUnknown . Состоит интерфейс из трёх методов: QueryInterface, AddRef, Release - именно в таком порядке адреса их точек входа расположены в vtbl. Учитывая, что всякий интерфейс обязан начинаться с IUnknown, то, выходит, что первые три входа любой vtbl занимают эти методы и именно в таком порядке.

Зачем нужны методы IUnknown - подробно разбиралось ранее. Поэтому сейчас мы рассматриваем только как эти методы нужно реализовать. Начнем по порядку следования...

Метод QueryInterface. Предназначен для преобразования типа указателя на интерфейс - на вход принимается IID интерфейса и адрес переменной, куда нужно поместить указатель на интерфейс, именуемый данным IID. Если запрашиваемый интерфейс действительно реализуется данным объектом, то метод помещает указатель на него в предоставленную переменную. Если нет - метод возвращает код возврата E_NOINTERFACE и, по хорошему, должен вернуть NULL в предоставленной переменной (чего некоторые, особенно - старые, серверы делают не всегда, к сожалению). Обратите внимание - метод принимает на вход IID любого интерфейса (произвольный GUID), а возвращает указатель на него, если интерфейс реализуется объектом. Т.е. метод QueryInterface любого интерфейса данного объекта должен знать все интерфейсы, реализуемые данным объектом. Это требование не так трудно и выдержать, оно не означает, что для каждого интерфейса этого объекта нужно писать свою реализацию этого метода. Оно означает, что реализации всех интерфейсов могут воспользоваться одной реализацией метода QueryInterface - единой для всего данного COM-объекта.

Метод AddRef. Предназначен для продвижения вперед на единицу счетчика ссылок. Должен возвращать новое значение этого счётчика ссылок - от 1 до n, но пользоваться этим значением можно только для отладки. Microsoft сообщает, что иногда это значение может быть "нестабильно". Что означает "нестабильно"- я не знаю. Видимо имеется в виду то, что в многопоточной среде значение счётчика совершенно точно известно только самому этому счётчику, который защищён от одновременного доступа нескольких потоков сразу. А вот актуальность текущего значения счётчика, выданного в любой поток может быть сразу же уничтожена параллельным потоком. Фактически смысл этого счётчика на клиентской стороне действительно имеется только при отладке, ничего другого на этом значении построить не удаётся.

Метод Release. Предназначен для продвижения счетчика ссылок назад на единицу. Возвращает новое (после декремента) значение счётчика ссылок, которое тоже может использоваться только для отладки. При этом, если счётчик становится равным нулю - реализация метода должна освободить ресурсы, примитивно говоря - уничтожить объект.

И тут нужно обратить внимание - что AddRef и Release любого интерфейса обслуживают один и тот же счётчик ссылок - счётчик ссылок всего объекта. Т.е. реализация и этих методов может быть одной на весь объект.

А вот дальше нужно немного призадуматься. Что на самом деле означает фраза: "метод должен освободить ресурсы"? Для простых по конструкции и типовых объектов это, понятно, равносильно удалению самого объекта полученного сервером по операции new. Но для сложных объектов это может быть и не так - некоторые интерфейсы (они называются tear-off, "отрывными") сложный объект может реализовывать "по отдельности", т.е. не получать для них ресурсы при создании самого объекта, а получать ресурсы только тогда, когда кто-то попросит ссылку именно на этот интерфейс. В таком случае, методы AddRef и Release будут вынуждены обслуживать уже два и более счётчиков ссылок - общий для всего объекта и частные именно для таких интерфейсов в составе объекта. И так же дробно, как объект получал ресурсы, Release будет их и освобождать. При этом под термином "ресурсы" я понимаю не только динамическую память. Например, реализация интерфейса может открывать файл, устанавливать сетевое соединение и т.д. Эти ресурсы тоже должны освобождаться при потере всех ссылок на интерфейс.

Для некоторых объектов (а мало ли, вам понадобится и такие объекты реализовывать?), которые являются статическими объектами уровня модуля, "самоликвидатор" не нужен. Но счётчик ссылок для такого объекта вести придётся и клиент и для такого указателя будет вызывать AddRef/Release, хотя они в данном случае ничего и не делают. Принцип должен выдерживаться - клиент не знает, как реализован объект сервера. Клиент знает только интерфейс, посредством которого он взаимодействует с объектом. И должен соблюдать протокол.

В сущности, теперь мы можем написать и второй пример - пример, в котором конструктивные недостатки объектов первого примера будут исправлены программным механизмом, который мы только что изучили. Это сделает наши объекты действительно "настоящими объектами COM", но, к сожалению пока ещё не позволит избавиться от эмулятора CoCreateInstance - мало иметь COM-объекты, надо, чтобы они обитали и в "настоящем COM-сервере". А от "настоящего COM-сервера" мы пока знаем только одну из четырех реализуемых и экспонируемых им системе функций - DllGetClassObject.

Описанная выше структура интерфейса естественно решает первую проблему экспорта объектов между модулями - каждый интерфейс однозначно определяется своим GUID, имеет вполне определенный список методов с вполне определенными параметрами и условиями вызова. То есть имеется абсолютно однозначный протокол вызова методов, и это гарантирует корректность передаваемых данных и сохранность стека. При помощи интерфейсов также естественно решается проблема унифицированной передачи данных. В списках формальных параметров методов интерфейса, вообще говоря, используются не все типы данных, которые определены в данном языке программирования. Точнее сказать, могут использоваться любые типы данных, но если ссылка на данный интерфейс может быть передана в другой модуль, то список формальных параметров методов интерфейса обязан содержать только определенные типы данных - так называемые OLE Automation Datatypes. В таблице приведены эти типы данных.

Тип Описание
__int64 байт, целое со знаком, диапазон -263+1...263-1, сопроцессорный тип
BSTR Строка, в который для хранения каждого символа используют 2 байта. Минимальный размер - 4 байта
byte 1 байт, целое без знака, диапазон 0..255
CURRENCY 8 байт, с фиксированной запятой и четырьма знаками после запятой, диапазон -922337203685477.5808..922337203685477.5807, сопроцессорный тип
DATE 8 байт, с плавающей запятой, целая часть - число дней с 30 декабря 1899 года, дробная часть - доля от 24 часов
DECIMAL Структура, содержит число с плавающей запятой и точность его представления (сколько имеется значимых десятичных знаков). Расшифровка - ActiveX.h
DISPPARAMS Структура, содержит параметры вызова методов через метод Invoke интерфейса Idispatch. Расшифровка структуры приведена в модуле ActiveX.h
double 8 байт, с плавающей запятой, диапазон 5.0х10-324...1.7х10208. 15-16 знаков
EXCEPINFO Структура, содержащая информацию об исключении. Расшифровка приведена в модуле ActiveX.h
float 4 байта, с плавающей запятой, диапазон 1.5x10-45...3.4x1038 7-8 знаков
GUID Глобальный идентификатор (класса, интерфейса). Структура размером 16 байт
HRESULT 4 байта, целое число без знака, диапазон 0..4294967295
int 4 байта, целое число со знаком, диапазон -2147483648..2147483647
VARIANT Содержит любые данные, тип может меняться динамически. Минимальный размер - 16 байт.
SAFEARRAY Указатель на массив целых чисел, 4 байта
short 2 байта, целое со знаком, -32768.. 32767
VARIANT_BOOL 2 байта, логическая переменная (true = -l, false = 0)

Помимо перечисленных в таблице типов список формальных параметров может еще содержать ссылки на интерфейсы, определенные в модуле ActiveX, а также переменные, тип которых начинается с OLE (OLE_COLOR, OLE_XPOS и др.). Нельзя при передаче данных через интерфейсы использовать параметры типа bool - можно только VARIANT_BOOL. Нельзя использовать параметры типа char * - можно только BSTR. При работе с СОМ-объектами необходимо иметь информацию не только о том, где что-то находится, но и о том, что находится в данной области памяти.

Эти ограничения введены потому, что СОМ-объект можно реализовывать на разных языках программирования. При этом такой язык программирования обязан поддерживать вышеперечисленные типы данных. Помимо этого, любой язык может содержать свои собственные типы данных -например, bool, char в C++. Поэтому программист может с уверенностью применять вышеперечисленные типы данных, зная, что при их передаче между модулями не произойдет искажения. Язык программирования, который не поддерживает эти типы данных, не поддерживает и СОМ-технологию и не сможет получить ссылку на интерфейс.IUnknown.

Предыдущая Оглавление Следующая