Интеграция внешней объектной системы в Delphi на примере IBM SOM
Один из моих проектов реализует поддержку SOM в Delphi. Разработка начиналась на Delphi, пришлось часть привязок делать вручную и не так красиво, в процедурном стиле, без проверки типов. Используя эти привязки, был написан генератор привязок в объектном стиле, а затем и сам генератор был переписан на новые привязки, став подтверждением их работоспособности. Ради красоты пришлось хакнуть объектную систему Delphi, и, может быть, вам будет интересно, как это вообще можно делать.
Некрасивые («тонкие») привязки хотя бы как-то позволяют повзаимодействовать с библиотекой, а красивые («толстые») стремятся сделать так, чтобы это было естественно с точки зрения обычного кода. IBM SOM (Модель системных объектов) — про объекты, а у объектов методы вызываются через точку. Мне известны следующие сущности в Delphi, у которых можно вызывать методы в объектном стиле:
- Объекты
- Объекты из старой объектной системы Borland Pascal with Objects
- Kлассы
- Интерфейсы
- Диспинтерфейсы
- Варианты
- Начиная с Delphi 2006, записи
- Начиная с Delphi XE3, что угодно, к чему видно помощник
Разрабатываю я обычно на Delphi или Ada, с предпочтением ко второму, и интерес мой к технологиям сопряжения разнородных компонент начинался с того, что я пытался подружить эти два языка разными способами. В вопросе разработки привязок начало поддержки SOM именно с Delphi, а не Ada, связано с тем, что именно на Delphi это сложнее сделать. А внутри Delphi самое сложное — это сделать блок type, и именно с него начиналась разработка средства импорта. Победить type — значит, решить основные проблемы.
В принципе, это хорошо, что в Delphi есть модульность. Это лучше, чем раздрай в C++ или, особенно, C, которым пользуются поставляемые вместе с SOM DTK эмиттеры. А там, где им не хватает обычного раздрая, подливаются тонны макросов. Но при этом в похожем на Delphi языке Ada появлялись «private with», «type X;», «limited with», позволяющие подключать ограниченную информацией о типах и тем самым иметь и модульность, и свободу делать циклические связи между модулями, и тем более делать циклы в пределах одного package, а в Delphi развитие в этом направлении двигалось слабо. Что ещё хуже, основной компилятор коммерческий (в лучшем случае один заказ на FPC против 6 на Delphi, а Аду приходится впаривать, само не попадается, куда-то валить ради Ады тоже не хочется), поэтому пользуются старыми версиями, до сих пор актуальна Delphi 7, так что если мне хочется написать библиотеку на любимой Аде и выставить её, чтоб пользовались из Delphi, желательно, чтобы это могла быть Delphi 7, так что последние 2 опции отметаются. У вариантов нет контроля типов, не всплывает список методов в подсказке, эта опция тоже отметается.
Изначально я хотел сделать RAII, чтоб память управлялась автоматически, как у строк и интерфейсов, а для этого нужно завернуть интерфейс или вариант в запись Delphi 2006 или старый объект. У этого подхода есть существенный недостаток. По правилам Delphi вперёд можно объявлять только классы, интерфейсы и указатели, причём, в неделимом блоке type. И вот теперь, допустим, делаем мы привязки к классу Container и классу Contained, и они взаимосвязаны. Хорошо бы написать Container = record private FSomething: Variant; end; и аналогично для Contained, а потом уточнять, какие у них есть методы, потому что методы могут принимать ссылки на классы SOM, спроецированные в записи, а если методы можно написать только внутри record, то одна из записей будет заведомо ещё не объявлена.
Значит, и всю запись не получится объявить из-за аргументов метода. Отчасти ситуацию может спасти, если сделать сначала для каждого класса SOM object из старой системы объектов со скрытым полем, но без методов, а потом ещё раз отнаследовать, и в каждом методе на вход принимать неотнаследованную версию без методов, к которой будут автоматом приводиться отнаследованные с методами. А вот с результатом будет проблема, там, наоборот, из-за ограничений Delphi метод SOM класса, привязки для которого создаются раньше другого, не сможет вернуть в качестве результата отнаследованную версию, обросшую методами возвращаемого SOM класса.
Container и Contained находятся в несколько более сложных отношениях, они возвращают не друг друга, а sequence (корбовский аналог динамического массива) из друг друга, значит, и sequence потребовалось бы проецировать именно для неотнаследованных, не обросших методами объектов. И только в Delphi XE3 появился чуть менее костыльный способ разделить объявление структуры и методов. Сначала заворачиваем интерфейс или вариант в приватную часть записи, и так для каждого проецируемого класса, а потом навешиваем методы при помощи помощников. И эти методы помощников уже благополучно могут принимать на вход и выход всё, что нужно.
Разбираясь с управлением памяти в SOM, делать RAII я перехотел. Дело в том, что в IBM версии SOM нет счётчика ссылок для всех объектов, как это в COM и современном Objective-C, и что прикажете делать, если ссылку на объект копируют? В Apple SOM, кстати, было, а оттуда перешло в somFree, так что не безнадёжно, но у меня при этом в распоряжении DirectToSOM C++ компилятор и мост в OLE Automation, с которыми я бы хотел, чтобы моё решение было на данный момент совместимо, и они не рассчитаны на такой режим работы.
С интерфейсами и обычными классами-обёртками возникают проблемы, аналогичные «и что прикажете делать, если ссылку на объект копируют», только в случае уничтожения. Ведь обёртка может транзитивно уничтожить SOM объект, а может — нет. Это как минимум у обёрток нужно флаг владения делать. И для полного счастья ещё и запутаться в этом всём. Вот был бы счётчик ссылок, мы б его всегда дёргали и не рефлексировали, и не путались. Всё бы работало как часы. Хорошо бы жили.
Если же трогать старую объектную систему, начинают сыпаться предупреждения. Вот так я пришёл к решению хакнуть объектную систему Delphi. Мой генератор проецирует SOM классы в Delphi классы с обычными методами, и всё это используется примерно привычным для Delphi разработчика образом. Для классов замечательно делается отложенное объявление, заворачивать их потом ни во что не нужно. Поскольку все циклы нужно замкнуть в одном блоке type, все модули CORBA и все типы, вложенные в классы, приходится проецировать в один unit Delphi, чтоб в этом unit был единый блок type.
Начиналось с того, что я решил заставить Delphi считать SOM объекты объектами Delphi. До тех пор, пока Delphi не трогает VMT, всё хорошо. Каждый метод SOM класса проецируется на метод Delphi класса. При этом методы SOM, как правило, виртуальные, а проецируются на невиртуальные методы Delphi, которые знают, как отправить вызов в SOM. Невиртуальные методы вызываются, не прикасаясь к VMT, и Delphi невдомёк, что там вообще далеко не Delphi объекты обрабатываются.
Методы в SOM вызывать несложно. Здесь вы видите 8 инструкций (домашнее задание — попробовать понять, что они делают), из них для собственно вызова достаточно двух. Перед последним call два раза делается mov/push, это передача аргументов, а не вызов. Перед этим записывать адрес в var_14, а потом по нему вызывать не обязательно, можно было в первой инструкции записать адрес в edx и в конце сделать вызов по [edx + 1Ch]. Ещё минус 2, итого 2 инструкции на вызов метода SOM объекта подобно 2 м инструкциям на вызов виртуальных методов из VMT в других системах разработки. По полученному адресу находится динамически созданный фрагмент кода, который знает, как лучше всего вызвать указанный метод, и он даст дополнительные «скрытые» инструкции, зато какая сразу разница! Об этой разнице можно прочитать в переводе доклада «Release-to-Release Binary Compatibility». Если вам когда-либо хотелось понять, почему под каждую версию Delphi свои наборы dcu и bpl, теперь вы знаете.
Вернёмся к генерации привязок. Наследование в SOM множественное, и это активно используется. Вот, например, при разработке генератора я работаю с OperationDef (метаинформация о методе), и он одновременно Contained внутри класса и Container своих аргументов. А в Delphi классах — одиночное. Теоретически, можно в проекции на Delphi делать одиночное наследование для первых родителей, а методы непервых добавлять потом, как будто они заново появились. Мне это показалось некрасивым, несимметричным решением. Ведь OperationDef (а также ModuleDef, InterfaceDef) в равной степени и Container, и Contained, и я сходу даже не вспомню, в каком порядке у них объявлены родительские классы. Плюс, если позволять иерархии Delphi классов разрастаться, потом возникают другие проблемы, об этом — в абзаце после следующего. Так что я спроецировал SOM классы так, что они друг другу в Delphi не являются родителями. Они все происходят от служебного класса SOMObjectBase, который нужен, чтобы спрятать методы TObject, а методы в них каждый раз заполняются с нуля. Операции «as» и «is», понятное дело, не поддерживаются, ведь они возьмут VMT объекта SOM и будут думать, что это VMT объекта Delphi. Их, к сожалению, заблокировать не удаётся, но чтобы всё было хоть как-то типизировано, для каждого родительского класса генерируются функции As_ИмяКласса для приведения типа вверх и классовые функции Supports для приведения типа вниз.
Внимательных читателей должно было напрячь словосочетание «классовая функция», ведь раньше было написано «до тех пор, пока Delphi не трогает VMT, всё хорошо». Спрятали классовые методы TObject и выставили свои? Ведь так можно из раскрывающегося списка выбрать Supports у обычного объекта, и Delphi полезет в VMT. Оказывается, это можно элегантно обработать. Новые классовые методы тоже невиртуальны, и всё, что Delphi делает, чтобы их вызвать у объекта, — это берёт указатель по нулевому смещению объекта, и это становится Self в классовом методе. А если вызывать классовый метод у класса, обратившись к нему по имени, то Self — это VMT Delphi класса. И как отличить VMT SOM класса от VMT Delphi класса?
А вот есть один способ. Все классовые методы каждый раз заново добавляются в очередной Delphi класс, от которого в норме никто не наследуется средствами Delphi, и сами друг от друга проецируемые классы средствами Delphi не наследуются. Таким образом, у нас может быть только один возможный вариант VMT Delphi класса в Self — это VMT того самого Delphi класса, в противном случае метод был вызван у SOM объекта, и Self — это на самом деле SOM VMT. Устройство SOM VMT неизвестно, оно зависит от версии SOM.DLL и может меняться, зато известно, что в нём по нулевому смещению находится ссылка на класс-объект, а он-то нам и нужен. В SOM все классы — это тоже объекты, и классовые методы — это методы классов-объектов в самом обычном смысле. Таким образом, сделав сравнение Self со своим именем, можно дальше выбрать, либо получить ссылку на SOM класс по нулевому смещению в структуре ClassData, либо, если не совпало, взять ссылку на SOM класс, разыменовав Self. И дальше делать то, что подразумевает классовый метод. Собственно, есть два классовых метода, делающих такое сравнение, это ClassObject и NewClass, второй отличается тем, что если в структуре ClassData ничего не оказалось, то класс автоматически не создаётся. Остальные классовые методы вызывают один из этих двух. Например, если нам нужно проверить, является ли такой-то объект наследником такого-то класса, то если класс не создан, то и не надо, и так понятно, что нет, а если спрашивают InstanceSize, то без создания класса уже не обойтись. Таким образом, будут корректно работать и «o.InstanceSize» (для «var o: SOMObject»), и «SOMObject.InstanceSize». Прямо как в Delphi.
Была идея спроецировать все методы класса-объекта SOM в классовые методы Delphi, однако тут обнаружились трудности. Преодолимые, но было решено отказаться от их преодоления.
Если какой-то гипотетический компилятор типизированного языка программирования, поддерживающий SOM или аналогичную модель, видит, что есть переменная, в которой содержится некий потомок класса X с родителями Y и Z, и у Y есть явный метакласс MY, а у Z — MZ, а у X заказан метакласс MX, ну, а на самом деле будет минимальный потомок MY, MZ и MX, то типизированный компилятор может тип результата somGetClass так хакнуть, чтобы это был «MY&MZ&MX» со всеми методами, которые у них всех есть, а если и у этого класса вызвать somGetClass, то чтоб и дальше собиралось объединение, но когда генерируются привязки, генерировать каждое такое потенциальное объединение было бы слишком. И без того дублируется текст, чтобы поддерживать множественное наследование. А, значит, среди классов SOM DTK, у которых известен метакласс, остаются только те, которые сделали это явно и сами, а вот их потомки — уже нет, если только они не повторят указание явного метакласса. Так что в общем случае надо писать «o.ClassObject.МетодОбъектаКласса», ну, а для некоторых классовых методов, которые были в TObject, всё же сделан удобный доступ.
Create, вдохновлённый тем, как работает TLIBIMP.exe, я сделал классовой функцией. Получается, что, как в Delphi, пишем «repo:= Repository.Create;» Но вот возникла идея, а что если и конструкторы SOM (инициализаторы в терминологии SOM) сделать конструкторами с точки зрения Delphi. Чтобы они, будучи вызваны у класса, создавали объект, а у объекта — работали как методы. Чтобы показать, как тут можно хакнуть классы Delphi, я решил привести временную диаграмму, как вообще конструируются и уничтожаются объекты в Delphi:
Outer-Create
Outer-Create => virtual NewInstance
Outer-Create => virtual NewInstance => _GetMem
Outer-Create => virtual NewInstance
Outer-Create => virtual NewInstance => non-virtual InitInstance
Outer-Create => virtual NewInstance => non-virtual InitInstance => FillChar(0)
Outer-Create => virtual NewInstance => non-virtual InitInstance
Outer-Create => virtual NewInstance
Outer-Create
Outer-Create => Create
Outer-Create
Outer-Create => virtual AfterConstruction
Outer-Create
Free
Free => Outer-Destroy
Free => Outer-Destroy => virtual BeforeDestruction
Free => Outer-Destroy
Free => Outer-Destroy => Destroy
Free => Outer-Destroy
Free => Outer-Destroy => virtual FreeInstance
Free => Outer-Destroy => virtual FreeInstance => non-virtual CleanupInstance
Free => Outer-Destroy => virtual FreeInstance => non-virtual CleanupInstance => _FinalizeRecord
Free => Outer-Destroy => virtual FreeInstance => non-virtual CleanupInstance
Free => Outer-Destroy => virtual FreeInstance
Free => Outer-Destroy => virtual FreeInstance => _FreeMem
Free => Outer-Destroy => virtual FreeInstance
Free => Outer-Destroy
Free
Outer-Create и Outer-Destroy — это тот код, в который автоматически оборачиваются вызовы конструктора и деструктора.
Что касается SOM, то, если нужно вызвать нестандартный конструктор (не somInit), то у объекта-класса вместо somNew вызывается функция somNewNoInit, возвращающая объект, и у него затем вызывается тот конструктор, который нужно, например, somDefaultCopyInit. Или всё тот же somInit. Задумка состоит в том, чтобы как-нибудь хакнуть все методы TObject, чтобы последовательность создания объекта воссоздавалась на реальсах Delphi. В частности, мы видим, что TObject.NewInstance — виртуальная классовая функция. Трюками с именами не получится обмануть, компилятор Delphi вызывает её из VMT по определённому адресу. Но можно в SOMObjectBase, где прячутся методы TObject, NewInstance не только спрятать, но и предоставить осмысленную реализацию, которая вызовет somNewNoInit у соответствующего класса SOM. Где она этот класс возьмёт? Например, можно через Delphi VMT протянуть protected виртуальную классовую функцию, которая будет уметь возвращать соответствующий себе класс SOM. Только есть одна проблема. В конце Outer-Create вызывается AfterConstruction, виртуальный метод. Он не сработает, если у объекта уже SOM VMT. Можно, конечно, в конце Create временно перезаписывать VMT объекта с SOM на Delphi, а в AfterConstruction — обратно, но это какая-то слишком кислая схема получается. Вот и в этом вопросе пришлось отступить.
Но в остальном получились довольно натуральные привязки.
Наследование из Delphi не реализовано, но если будет, то там сделать красиво будет несколько затруднительно. Даже, если рассмотреть обычные эмиттеры для C++, то у них работа с SOM объектами похожа на работу с C++ объектами, перегружаются operator new () и operator new (void*), а вот при наследовании реализация методов классов SOM совсем не выглядит как реализация методов класса C++. Кроме специально изменённого компилятора DirectToSOM C++, конечно.
Эта деятельность ведётся в рамках изобретательского проекта и на данный момент имеет исследовательский и демонстрационный характер. Мне надо узнать от А до Я подводные камни, другим надо показать принципиальную осуществимость. Может быть, где-то и пригодится, но на чистовую планируется работать с другой, новой моделью, которая вберёт в себя лучшие черты SOM, COM и Objective-C, и будет готова работать на актуальные, не стоявшие перед прежними авторами SOM задачи.