Разработка API (контракта) для своей DLL
Или: не создавайте своих DLL, не прочитав эту статью!
Это статья по мотивам вопросов на форумах: «Как мне вернуть строку из DLL?», «Как передать и вернуть массив записей?», «Как передать в DLL форму?».
Чтобы вам не тратить половину жизни на разобраться — в этой статье я принесу всё на блюдечке.
Темы этой статьи в разной степени уже не раз затрагивались в этом блоге, но в этой статье они собраны в кучу, приведены обоснования. Короче, ссылкой на эту статью можно кидаться в тех, кто разрабатывает DLL.
Важное примечание: статью нужно читать последовательно. Примеры кода приводятся только как примеры, на каждом шаге (пункте) статьи код примеров добавляется новыми подробностями. Например, в самом начале статьи нет обработки ошибок, указываются «классические» способы (типа, использования GetLastError
, соглашения sdtcall
и т.д.), которые по ходу статьи заменяются на более адекватные. Сделано так по той причине, чтобы «новые» («необычные») конструкции не вызывали вопросов. Иначе при пришлось бы к каждому примеру вставлять примечание вида: «вот это обсуждается в том пункте ниже, а вот то — в этом вот». В любом случае в конце статьи есть ссылка на уже готовый код, написанный с учётом всего сказанного в статье. Можете просто его брать и использовать. А статья объясняет зачем и почему. Если вам не интересно «зачем и почему» — листайте в конец к заключению и ссылке на скачивание примера.
Содержание
- Общие понятия
- Типы данных
- Строковые данные и кодировки
- Общий менеджер памяти (и почему его не нужно использовать)
- Управление памятью в API DLL
- Обработка ошибок (и соглашение вызова)
- Обход проблем DllMain
- Callback-функции (функции обратного вызова)
- Прочие правила
- Заключение (и примеры кода)
Общие понятия
Когда вы разрабатываете свою DLL, вы должны придумать прототипы экспортируемых из неё функций («заголовки»), а также основанный на них контракт (правила вызова). Всё вместе это образует API вашей DLL. API или Application Programming Interface (программный интерфейс приложения) — это описание способов, которыми один код может взаимодействовать с другим, это средство интеграции приложений.
Когда вы разрабатываете свою DLL, вы должны определить в каких условиях она будет использоваться:
- Могут ли её использовать приложения, написанные на другом языке программирования (например, Microsoft Visual C++) — «универсальная DLL»;
- Или же библиотеку DLL смогут использовать только приложения, написанные на том же языке — «Delphi DLL».
Это принципиальный момент, решить который вы должны в первую очередь: ещё до того как начнёте писать код и даже проектировать API вашей DLL. Дело в том, что во втором случае («Delphi DLL») вы можете использовать все возможности вашего языка программирования при создании API. К примеру, для Delphi это означает возможность использовать строки, объекты (в частности — формы, компоненты), динамические массивы, специализированные простые типы (Extended
, множества и т.п.) — в общем, всё то, что не существует в других языках. Также это означает возможность обмениваться памятью, делать прозрачную обработку ошибок (межмодульные исключения).
Если вы пойдёте этим путём, то вам следует рассмотреть использования run-time пакетов (BPL) вместо DLL. BPL-пакеты — это специализированные DLL, которые специально «заточены» под использование только в Delphi, что предоставляет вам множество «плюшек». Но об этом чуть позже.
Если же вы разрабатываете «универсальную DLL», то вы не можете использовать возможности вашего языка, которые не существуют в других языках программирования. И в этом случае вы можете использовать только «общеизвестные» типы данных. Но об этом также ниже.
Эта статья — в основном про «универсальные DLL» в Windows.
Что вам необходимо будет создать при разработке API вашей DLL:
- Заголовочники, заголовочные файлы (headers) — набор исходных файлов, которые содержат объявления структур и функций, использующихся в API. Как правило, не содержат реализации. Заголовочные файлы предоставляются на нескольких языках — как правило, это язык, на котором написана программа (в нашем случае — Delphi), C++ (как стандарт) и некоторыми дополнительными (Basic и т.п.). Все эти файлы эквивалентны и просто представляют собой перевод из одного языка программирования на другой. Чем больше языков будет в комплекте — тем лучше. Если вы не предоставите заголовочные файлы для какого-то языка, то программисты на этом языке не смогут использовать вашу DLL, пока они сами не переведут файлы с предоставляемого языка (Delphi или C++) на их язык. Т.е. отсутствие заголовочников на каком-то языке — это не красный «стоп», но достаточное препятствие.
- Документация — представляет собой словесное описание API и должна указывать дополнительные правила, не заложенные в синтаксисе заголовочников. К примеру, то, что такую-то функцию можно вызвать, передав ей число — это информация из заголовочников. А то, что перед вызовом этой функции нужно вызвать другую функцию — это информация из документации. В такой документации как минимум должно быть формальное описание API — перечисление всех функций, методов, интерфейсов и типов данных с объяснениями «как» и «зачем» (т.н. Reference). Дополнительно, документация может содержать неформальное описание процесса использования DLL (guide, how-to и т.п.). В простейших случаях документация пишется прямо в заголовочниках (комментариях), но чаще всего это файл (или файлы) в формате chm, html или pdf.
SDK (Software Development Kit) — набор из заголовочников + документации. SDK — это то, что необходимо стороннему разработчику для использования вашей DLL. SDK — это то, что вы должны создать и публично распространять для всех желающих использовать вашу DLL.
Типы данных
Если вы хотите получить «универсальную DLL», то вы не можете использовать в вашем API специфичные для Delphi типы данных, потому что они не имеют аналога в других языках. Например, string
, array of
, TObject
, TForm
(и вообще — любые объекты и уж тем более компоненты) и т.п.
Что можно использовать? Целочисленные типы (Integer
, Cardinal
, Int64
, UInt64
, NativeInt
, NativeUInt
, Byte
, Word
и т.п.; я бы не рекомендовал использовать Currency
, если только он вам действительно нужен), вещественные (Single
и Double
; я бы рекомендовал избегать типов Extended
и Comp
, если только они действительно вам нужны и иначе никак), TDateTime
(алиас для системного OLEDate
), перечислимые и subrange-типы (с некоторыми оговорками), символьные типы (AnsiChar
и WideChar
— но не Char
), строки (только в виде WideString
/BSTR
), логический тип (BOOL
, но не Boolean
), интерфейсы (interface
), в методах которых используются допустимые типы, записи (record
) из вышеуказанных типов, а также указатели на них (в том числе указатели на массивы из вышеуказанных типов, но не динамические массивы).
Как узнать, какой тип можно использовать, а какой — нет? Относительно простое правило — если вы не видите тип в этом списке, и типа нет в модуле Windows
(модуле Winapi.Windows
, начиная с Delphi XE2), то этот тип использовать нельзя. Если же тип перечислен мною выше или находится в модуле Windows
/Winapi.Windows
— используйте его. Это достаточно грубое правило, но для начала — сойдёт.
В случае использования записей (record
) — вам нужно указать выравнивание данных. Используйте или ключевое слово packed
(без выравнивания) или указывайте директиву {$A8} (выравнивание на 8 байт) в начале файла заголовочников.
В случае использования перечислимых типов (Color = (clRed, clBlue, clBlack);
) — добавьте в начало заголовочников директиву {$MINENUMSIZE 4}
(размер перечислимого типа не меньше 4 байт).
Строковые данные и кодировки
Если вам нужно передавать в DLL строки или возвращать из DLL строки — используйте только тип BSTR
. Почему?
- Тип
BSTR
есть во всех языках программирования.
Примечание: по историческим причинам в Delphi типBSTR
называетсяWideString
. Поэтому, чтобы содержимое ваших Delphi-заголовочников было бы более понятным разработчикам на других языках — добавьте в их начало такой код:type
BSTR = WideString;и в дальнейшем используйте тип
BSTR
. - Тип
BSTR
(WideString
) относится к автоматическим типам Delphi, т.е. вам не нужно выделять и освобождать память вручную. За вас всё автоматически сделает компилятор; - Тип
BSTR
имеет фиксированную кодировку: Unicode. Т.е. у вас не будет проблем с неверной кодовой страницей, приводящей к «кракозябрам»; - Магия компилятора Delphi позволяет просто присваивать
BSTR
(через оператор присваивания:=
) любым строкам Delphi и наоборот. Все необходимые преобразование будут сделаны автоматически под капотом языка, не нужно вызывать никаких функций преобразования; - Память для строк
BSTR
всегда выделяется через один и тот же менеджер памяти, поэтому у вас никогда не будет проблем с передачей памяти между исполняемыми модулями (см. ниже);
Если по каким-то причинам вы не можете использовать BSTR
, то застрелитесь, пожалуйста используйте PWideChar
:
- Не используйте
PAnsiChar
, потому что на дворе 2019 год, а не 1995. При использованииPAnsiChar
вы получаете кучу головной боли с кодировками; - Не используйте
PChar
, потому что он определён не однозначно: это может быть илиPAnsiChar
илиPWideChar
(в зависимости от версии компилятора).
Аналогично использованию системного имени BSTR
вместо Delphi-имени WideString
, и для PWideChar
тоже можно сделать так:
type
LPWSTR = PWideChar;
и далее использовать LPWSTR
. LPWSTR
— это имя системного типа данных, который в Delphi называется PWideChar
.
Конечно же, при использовании LPWSTR
/PWideChar
вы получаете кучу минусов:
- Вам нужно вручную выделять и освобождать память для
PWideChar
, что увеличивает шансы на проблемы с утечками памяти; - Хотя в некоторых случаях вы можете делать прямые присваивания (например,
PWideChar
в строку), но чаще — нет. Вам придётся вызывать функции преобразования и/или функции выделения/копирования памяти; - Память для строк
PWideChar
выделяется как обычно (без специально выделенного менеджера памяти), т.е. у вас есть проблема с передачей памяти через границу модуля (см. ниже); - У
PWideChar
нет поля для длины. Поэтому если вы хотите передавать строки с#0
внутри и/или вы хотите передавать большие строки, то вам придётся явно передавать длину строки вместе со строкой (два параметра вместо одного).
Читать далее: String и PChar.
ANSI и Unicode
Из вышесказанного напрямую следует, что все ваши экспортируемые функции должны быть в Unicode. Не надо, глядя на Windows API, делать два варианта функций (с суффиксами -A и -W) — делайте один вариант (без суффикса, просто Unicode). Даже если вы разрабатываете на ANSI версии Delphi (Delphi 7) — не надо делать ANSI-варианты экспортируемых функций. Сейчас не 1995 год.
Общий менеджер памяти
(и почему его не нужно использовать)
В языках программирования динамическая память выделяется и освобождается специальным кодом в программе — т.н. менеджером памяти. К примеру, в Delphi менеджер памяти реализует функции вида GetMem
и FreeMem
. Все прочие способы управления памятью (New
, SetLength
, TForm.Create
и т.д.) являются переходниками (т.е. где-то внутри они вызывают GetMem
и FreeMem
).
Проблема заключается в том, что каждый исполняемый модуль (будь это DLL или exe) имеют свой собственный код менеджера памяти, и, к примеру, менеджер памяти Delphi не знает ничего про менеджер памяти Microsoft C++ (и наоборот). Поэтому, если вы выделите память в Delphi и, к примеру, попытаетесь передать её в код Visual C++, то ничего хорошего не произойдёт. Более того, даже если вы выделите память в Delphi DLL и вернёте её в Delphi exe, то всё будет ещё хуже: в обоих исполняемых модулях используется два разных, но однотипных менеджера памяти. Менеджер памяти exe посмотрит на память и ему покажется, что это его память (ведь она выделена аналогичным менеджером памяти), он попытается её освободить, но только испортит этим данные учёта.
Решение этой проблемы просто — нужно использовать правило: кто выделяет память, тот её и освобождает.
Добиться выполнения этого правила можно разными способами. Часто упоминаемый способ: использование т.н. общего менеджера памяти или менеджера общей памяти (shared memory manager). Суть способа заключается в том, что несколько модулей «договариваются» использовать один и тот же менеджер памяти.
Когда вы создаёте DLL — вам об этой особенности сообщает комментарий в начале .dpr файла DLL:
{ Important note about DLL memory management: ShareMem must be the
first unit in your library's USES clause AND your project's (select
Project-View Source) USES clause if your DLL exports any procedures or
functions that pass strings as parameters or function results. This
applies to all strings passed to and from your DLL--even those that
are nested in records and classes. ShareMem is the interface unit to
the BORLNDMM.DLL shared memory manager, which must be deployed along
with your DLL. To avoid using BORLNDMM.DLL, pass string information
using PChar or ShortString parameters. }
Что переводится как:
Важное примечание об управлении памятью DLL: модуль
ShareMem
должен быть указан первым элементом в конструкцииUSES
вашей библиотеки И конструкцииUSES
вашего проекта (используйте Project-View Source), если ваша DLL экспортирует какие-либо процедуры или функции, которые передают строки в качестве параметров или результатов функции. Это относится ко всем строкам, передаваемым в и из вашей DLL — даже к тем, которые вложены в записи и классы.ShareMem
— это интерфейсный модуль для общего менеджера памятиBORLNDMM.DLL
, который должен распространяться вместе с вашей DLL. Чтобы избежать использованияBORLNDMM.DLL
, передавайте строковую информацию с помощью параметров типаPChar
илиShortString
.
Это — чудовищно неправильный комментарий:
- Комментарий говорит о необходимости использования общего менеджера памяти, как если бы это был единственный способ решения проблемы обмена памяти — что в корне неверно (см. ниже);
- Комментарий говорит только о строках, хотя описываемая проблема применима к любым данным с динамическим выделением памяти: объектам, динамическим массивам, указателям;
- Комментарий никак не упоминает, что делать с не строковыми данными;
- Использование общего менеджера памяти никак не коррелирует с использованием отдельной выделенной DLL. Это — всего лишь одна из возможных реализаций;
- Комментарий требует использовать
PChar
для избежания описываемой проблемы — что также неправильно (см. выше про кодировки); - Комментарий требует использовать
ShortString
— что, опять же, неверно с точки зрения «универсальной DLL» (ShortString
— тип, специфичный для Delphi). Хотя, это уже придирка, поскольку использование Delphi-строк и Delphi DLL в качестве общего менеджера памяти и так уже ставит крест на «универсальной DLL».
К сожалению, этот комментарий «от самих создателей Delphi» породил огромное количество мифов и плохих практик.
Что же не так с использованием общего менеджера памяти?
- Другие языки программирования ничего не знают про менеджер памяти Delphi;
- А раз вы ориентируетесь только на Delphi, то зачем вам DLL? Собирайте программу с пакетами выполнения (BPL) — этим вы автоматически получите:
- Общий менеджер памяти в
rtl.bpl
; - Гарантию совместимости структуры объектов, поскольку все модули будут собираться одним компилятором;
- Отсутствие дублирования RTL и VCL (ошибки типа «
TForm
не совместим сTForm
«, два объектаApplication
и т.д.); - Беспроблемную обработку ошибок с исключениями.
- Общий менеджер памяти в
- Общий менеджер памяти сильно затрудняет поиск утечек памяти, поскольку модуль может загрузиться, выделить память, выгрузиться, а созданная утечка будет найдена только во время финализации менеджера памяти при выходе из программы.
Суммируя: общий менеджер памяти — это костыль. Не надо его использовать. А что нужно использовать? См. разделы ниже.
Подробнее о менеджерах памяти и общих менеджерах памяти.
Управление памятью в API DLL
Итак, как же вам передать память из DLL в вызывающего и наоборот? Есть несколько способов.
Как неправильно?
Для начала — как делать не следует.
Во-первых, не надо «делать как в Delphi»: не используйте общий менеджер памяти — по причинам, указанным выше.
Во-вторых, не надо «делать как в Windows». Многие смотрят на Windows API и делают так же. Но при этом они упускают, что этот API создавался хорошо если в 1995 году, а ведь многие функции идут ещё от 16-битных Windows. Те окружение и условия, для которых создавались эти функции, сегодня уже не существуют. Сегодня есть гораздо более простые и удобные способы.
Например, вот типичная Windows функция:
function GetUserName(lpBuffer: PWideChar; var nSize: DWORD): BOOL; stdcall;
Параметры
lpBuffer
Указатель на буфер для получения имени пользователя. Если этот буфер недостаточно велик, чтобы вместить полное имя пользователя, функция завершается ошибкой.pcbBuffer
На входе эта переменная указывает размер буфераlpBuffer
в символах. На выходе переменная получает количество символов, скопированных в буфер, включая завершающий нулевой символ.Если
lpBuffer
слишком мал, функция завершается ошибкой иGetLastError
возвращаетERROR_INSUFFICIENT_BUFFER
. Тогда этот параметр будет содержать требуемый размер буфера, включая завершающий нулевой символ.
Чтобы получить результат от такой функции Windows, её надо вызвать дважды. Сначала вы вызываете функцию, чтобы определить размер буфера, затем вы выделяете буфер, и только потом вызываете функцию снова. А если данные поменяются в это время? Функции снова не хватит места. Таким образом, чтобы надёжно получить полные данные, вам придётся писать цикл. Это же ужас. Не надо так делать.
Строки
Со строками всё просто — используйте BSTR
(который WideString
). Это мы подробно разобрали выше.
Обратите внимание, что в некоторых случаях сложные структурированные данные (объекты) вы можете вернуть в виде JSON или аналогичного способа упаковки данных в строку. И если это ваш случай — вы также можете воспользоваться BSTR
.
Во всех прочих случаях вам нужно использовать один из трёх способов ниже.
Системный менеджер памяти
Выполнить правило «кто выделяет память, тот её и освобождает» можно следующим образом: попросить выделять и освобождать память третью сторону, про которую знают и вызываемый и вызывающий. К примеру, такой третьей стороной может быть любой системный менеджер памяти. Именно так и работает BSTR
/WideString
. Вот несколько вариантов, которые вы можете использовать:
- Системная куча процесса:
HeapAlloc
иHeapFree
, вызываемые дляGetProcessHeap
;GlobalAlloc
иGlobalFree
;LocalAlloc
иLocalFree
.
Все эти функции выделяют память из одной и той же динамической кучи процесса. Несколько вариантов функций появились по историческим причинам.
- COM-подобные менеджеры памяти:
И снова: все эти функции сегодня эквивалентны. Несколько вариантов функций появились по историческим причинам.
VirtualAlloc
иVirtualFree
.
Примечание: функции менеджера памяти COM и оболочки (Shell) можно вызывать сразу, без инициализации COM/OLE.
Довольно большой список. Что же из этого лучше использовать?
VirtualAlloc
/VirtualFree
выделяют память с гранулярностью в 64 Кб, поэтому их использовать нужно только если вам нужно обмениваться данными огромных размеров;GlobalAlloc
/GlobalFree
иLocalAlloc
/LocalFree
совсем уж устарели и имеют бо́льшие накладные расходы, нежелиHeapAlloc
/HeapFree
, поэтому их использовать не нужно;
Остаются HeapAlloc
/HeapFree
и COM. Вариант с Heap вполне может быть вариантом по умолчанию. Менеджеры памяти COM могут быть более привычны некоторым языкам программирования. Кроме того, там есть уже готовый интерфейс менеджера памяти (см. ниже). В общем, тут выбор скорее вкуса, особой разницы нет.
Вот пример, как это могло бы выглядеть в коде. В DLL (упрощённый код без обработки ошибок):
uses
ActiveX; // или uses OLE2;
function GetDynData(const AFlags: DWORD; out AData: Pointer; out ADataSize: DWORD): BOOL; stdcall;
var
P: array of Something;
begin
P := { ... готовим данные ... };
ADataSize := Length(P) * SizeOf(Something);
AData := CoTaskMemAlloc(ADataSize);
Move(Pointer(P)^, AData^, ADataSize);
Result := True;
end;
В exe:
uses
ActiveX; // или uses OLE2;
var
P: array of Something;
Data: Pointer;
DataSize: DWORD;
begin
GetDynData(0, Data, DataSize);
SetLength(P, DataSize div SizeOf(Something));
Move(Data^, Pointer(P)^, DataSize);
CoTaskMemFree(Data);
// Работаем с P
end;
Примечание: это только пример. В реальных приложениях вы можете (на стороне вызываемого) как готовить данные сразу в возвращаемом буфере (при условии, что вам наперёд известен его размер), так и (на стороне вызывающего) работать с возвращёнными данными напрямую, не копируя их в буфер другого типа.
Разумеется, при этом в вашем SDK должна быть документация по функции GetDynData
, где будет явно сказано, что возвращаемую память нужно освобождать вызовом CoTaskMemFree
, например так:
GetDynData
Возвращает XYZ.Синтаксисfunction GetDynData(constAFlags: DWORD;outAData: Pointer;outADataSize: DWORD): BOOL; stdcall;ПараметрыAFlags [входной]Тип: DWORDНеобязательные флаги: …AData [выходной]Тип: PointerУказатель на запрошенные данные размеромADataSize
. После завершения работы с данными вызывающий должен удалить их вызовомCoTaskMemFree
.ADataSize [выходной]Тип: PointerРазмер данныхAData
в байтах.Возвращаемое значениеЕсли функция успешна, то возвращаетсяTrue
.
Если функция завершается неудачно, то возвращаетсяFalse
. В этом случае код ошибки можно получить вызвавGetLastError
.Примечания…ПримерыПример использования можно увидеть в статье Пример получения данных.Требования
Версия DLL 1 Заголовочный файл MyDll.pas
Примечание: разумеется, вызовы CoTaskMemAlloc
/CoTaskMemFree
вы можете заменить на HeapAlloc
/HeapFree
или любые другие, удобные вам.
Обратите внимание, что при этом способе, как правило, вам нужно копировать данные дважды: в вызываемом (чтобы скопировать данные из подготовленного места в место, пригодное для возврата вызывающему) и, возможно, в вызывающем (чтобы скопировать возвращённые данные в структуры, пригодные для дальнейшего использования). Иногда вы сможете обойтись одним копированием, если вызывающий сможет использовать данные сразу. Но от копирования данных в вызываемом удаётся избавиться редко.
Выделенные функции
Другой вариант — обернуть ваш предпочитаемый менеджер памяти в экспортируемую функцию. Соответственно, в документации к функции должно быть указано, что для освобождения памяти нужно вызывать не CoTaskMemFree
(или что вы там использовали), а вашу функцию-обёртку. Тогда вы сможете просто возвращать подготовленные данные сразу, без копирования. Например, в DLL (упрощённый код без обработки ошибок):
function GetDynData(const AFlags: DWORD; out AData: Pointer; out ADataSize: DWORD): BOOL; stdcall;
var
P: array of Something;
begin
P := { ... готовим данные ... };
ADataSize := Length(P) * SizeOf(Something);
Pointer(AData) := Pointer(P); // копируем указатель, не копируем данные
Pointer(P) := nil; // блокируем автоматическую очистку
Result := True;
end;
procedure DynDataFree(var AData: Pointer); stdcall;
var
P: array of Something;
begin
if AData = nil then
Exit;
Pointer(P) := Pointer(AData); // и снова: копируем только указатель
AData := nil;
Finalize(P); // подходящая функция очистки
// (в данном случае - этот вызов опционален)
end;
В exe:
var
P: array of Something;
Data: Pointer;
DataSize: DWORD;
begin
GetDynData(0, Data, DataSize);
SetLength(P, DataSize div SizeOf(Something));
Move(Data^, Pointer(P)^, DataSize);
CoTaskMemFree(Data);
// Работаем с P
end;
Примечание: заметьте, что мы не можем просто скопировать указатель в массив на стороне вызывающего, поскольку контракт GetDynData
ничего не говорит про совместимость возвращаемых данных с динамическим массивом Delphi. Действительно, DLL может быть написана на MS Visual C++, в котором нет динамических массивов.
Как и в предыдущем случае, этот контракт также должен быть явно закреплён в документации вашего SDK:
AData [выходной]Тип: PointerУказатель на запрошенные данные размеромADataSize
. После завершения работы с данными вызывающий должен удалить их вызовомDynDataFree
.
Заметьте, что используя функцию-обёртки вы сможете снизить число копирования данных, поскольку теперь вам не нужно копировать данные на стороне вызываемого, ибо вы используете один и тот же менеджер памяти в вычислениях и для возврата данных. Минусом же этого способа является необходимость написания дополнительных функций-обёрток. Иногда вы можете обойтись одной общей функцией-обёрткой, общей для всех экспортируемых функций. Но чаще вам потребуется индивидуальная функция очистки для каждой экспортируемой функции (возвращающей данные).
Если вы будете использовать одну универсальную функцию для очистки, то вы можете возвращать её в виде интерфейса IMalloc
. Это будет более привычно для знакомых с основами COM. Но это также позволит вам не только возвращать память вызывающему, но и принимать от него память с передачей владения. Например:
uses
ActiveX; // или Ole2
type
TAllocator = class(TInterfacedObject, IMalloc)
function Alloc(cb: Longint): Pointer; stdcall;
function Realloc(pv: Pointer; cb: Longint): Pointer; stdcall;
procedure Free(pv: Pointer); stdcall;
function GetSize(pv: Pointer): Longint; stdcall;
function DidAlloc(pv: Pointer): Integer; stdcall;
procedure HeapMinimize; stdcall;
end;
{ TAllocator }
function TAllocator.Alloc(cb: Integer): Pointer;
begin
Result := AllocMem(cb);
end;
function TAllocator.Realloc(pv: Pointer; cb: Integer): Pointer;
begin
ReallocMem(pv, cb);
Result := pv;
end;
procedure TAllocator.Free(pv: Pointer);
begin
FreeMem(pv);
end;
function TAllocator.DidAlloc(pv: Pointer): Integer;
begin
Result := -1;
end;
function TAllocator.GetSize(pv: Pointer): Longint;
begin
Result := -1;
end;
procedure TAllocator.HeapMinimize;
begin
// ничего не делает
end;
function GetMalloc(out AAllocator: IMalloc): BOOL; stdcall;
begin
AAllocator := TAllocator.Create;
Result := True;
end;
//_______________________________________
function GetDynData(const AOptions: Pointer; out AData: Pointer; out ADataSize: DWORD): BOOL; stdcall;
var
P: array of Something;
begin
P := { ... готовим данные с учётом AOptions ... };
// предполагаем, что AOptions нам передали с правом владения
FreeMem(AOptions);
ADataSize := Length(P) * SizeOf(Something);
AData := GetMem(ADataSize);
Move(Pointer(P)^, Pointer(AData)^, ADataSize);
Result := True;
end;
В exe:
var
A: IMalloc;
Options: Pointer;
P: array of Something;
Data: Pointer;
DataSize: DWORD;
begin
GetMalloc(A);
Options := A.Alloc({ размер опций });
{ подготовка Options }
GetDynData(Options, Data, DataSize);
// Не освобождаем Options, так как передали владение в GetDynData
SetLength(P, DataSize div SizeOf(Something));
Move(Data^, Pointer(P)^, DataSize);
A.Free(Data);
// Работаем с P
end;
Примечание: конечно, это немного бессмысленный пример, потому что в данном конкретном случае нет никакой необходимости передавать права на владение AOptions
в функцию GetDynData
: вызывающий может и сам очистить память, тогда вызываемый может не освобождать память. Но это только пример. В реальных приложениях вам может понадобится держать AOptions
внутри DLL дольше вызова функции. В примере показано, как это можно реализовать, обернув менеджер памяти в интерфейс.
Если вы реализуете метод TAllocator.GetSize
, то параметр ADataSize
можно будет убрать.
Интерфейсы
Вместо использования системного менеджера памяти и специальных экспортируемых функций (два способа выше) гораздо удобнее использовать интерфейсы по следующим причинам:
- Интерфейс — это запись с указателями на функции, аналог класса с виртуальными функциями. Благодаря этому каждый метод автоматически становится функцией-обёрткой из предыдущего пункта, т.е. всегда работает с правильным менеджером памяти. Иными словами, нет необходимости ни использовать фиксированный сторонний менеджер памяти, ни вводить функции-обёртки;
- Интерфейсы понимают все языки программирования;
- Интерфейсами можно передавать сложные данные (объекты);
- Интерфейсы относятся к типам с автоматической очисткой, не надо явно вызывать функции очистки;
- Интерфейсы можно легко модифицировать, расширяя их в будущих версиях DLL;
- Способ реализации интерфейсов в Delphi с помощью магии компилятора позволяет легко реализовать правильную обработку ошибок (см. ниже следующий раздел).
Прошлый пример можно реализовать на интерфейсах примерно так, вот DLL:
type
IData = interface
['{C79E39D8-267C-4726-98BF-FF4E93AE1D44}']
function GetData: Pointer; stdcall;
function GetDataSize: DWORD; stdcall;
property Data: Pointer read GetData;
property DataSize: DWORD read GetDataSize;
end;
TData = class(TInterfacedObject, IData)
private
FData: Pointer;
FDataSize: DWORD;
protected
function GetData: Pointer; stdcall;
function GetDataSize: DWORD; stdcall;
public
constructor Create(const AData: Pointer; const ADataSize: DWORD);
end;
constructor TData.Create(const AData: Pointer; const ADataSize: DWORD);
begin
inherited Create;
if ADataSize > 0 then
begin
GetMem(FData, ADataSize);
Move(AData^, FData^, ADataSize);
end;
end;
function TData.GetData: Pointer; stdcall;
begin
Result := FData;
end;
function TData.GetDataSize: DWORD; stdcall;
begin
Result := FDataSize;
end;
//________________________________
function GetDynData(const AFlags: DWORD; out AData: IData): BOOL; stdcall;
var
P: array of Something;
begin
P := { ... готовим данные ... };
AData := TData.Create(Pointer(P), Length(P) * SizeOf(Something));
Result := True;
end;
В exe:
var
P: array of Something;
Data: IData;
begin
GetDynData(0, Data);
SetLength(P, Data.DataSize div SizeOf(Something));
Move(Data^, Data.Data^, Data.DataSize);
// Работаем с P
end;
В данном случае мы сделали один универсальный интерфейс IData, который можно написать один раз и использовать во всех функциях. Хотя при этом не придётся писать код для каждой функции, но при этом также получается копирование данных на стороне вызываемого, а также отсутствие типизации. Вот как мог бы выглядеть улучшенный вариант, DLL:
type
IData = interface
['{C79E39D8-267C-4726-98BF-FF4E93AE1D44}']
function GetData: Pointer; stdcall;
function GetDataSize: DWORD; stdcall;
property Data: Pointer read GetData;
property DataSize: DWORD read GetDataSize;
end;
TSomethingArray = array of Something;
TSomethingData = class(TInterfacedObject, IData)
private
FData: TSomethingArray;
FDataSize: DWORD;
protected
function GetData: Pointer; stdcall;
function GetDataSize: DWORD; stdcall;
public
constructor Create(var AData: TSomethingArray);
end;
constructor TSomethingData.Create(var AData: TSomethingArray);
begin
inherited Create;
FDataSize := Length(AData) * SizeOf(Something);
if FDataSize > 0 then
begin
Pointer(FData) := Pointer(AData);
Pointer(AData) := nil;
end;
end;
function TSomethingData.GetData: Pointer; stdcall;
begin
Result := Pointer(FData);
end;
function TSomethingData.GetDataSize: DWORD; stdcall;
begin
Result := FDataSize;
end;
function GetDynData(const AFlags: DWORD; out AData: IData): BOOL; stdcall;
var
P: TSomethingArray;
begin
P := { ... готовим данные ... };
AData := TSomethingData.Create(P);
Result := True;
end;
В этом случае внешняя обёртка (т.е. интерфейс) остаётся без изменений, меняется только код DLL. Поэтому код вызывающего (в exe) также не меняется. Но если менять контракт (интерфейс), то можно сделать и так:
type
ISomethingData = interface
['{CF8DF791-1E8D-4363-94A2-9FF035A9015A}']
function GetData: Pointer; stdcall;
function GetDataSize: DWORD; stdcall;
function GetCount: Integer; stdcall;
function GetItem(const AIndex: Integer): Something; stdcall;
property Data: Pointer read GetData;
property DataSize: DWORD read GetDataSize;
property Count: Integer read GetCount;
property Items[const AIndex: Integer]: Something read GetItem; default;
end;
TSomethingArray = array of Something;
TSomethingData = class(TInterfacedObject, ISomethingData)
private
FData: TSomethingArray;
FDataSize: DWORD;
protected
function GetData: Pointer; stdcall;
function GetDataSize: DWORD; stdcall;
function GetCount: Integer; stdcall;
function GetItem(const AIndex: Integer): Something; stdcall;
public
constructor Create(var AData: TSomethingArray);
end;
constructor TSomethingData.Create(var AData: TSomethingArray);
begin
inherited Create;
FDataSize := Length(AData) * SizeOf(Something);
if FDataSize > 0 then
begin
Pointer(FData) := Pointer(AData);
Pointer(AData) := nil;
end;
end;
function TSomethingData.GetData: Pointer; stdcall;
begin
Result := Pointer(FData);
end;
function TSomethingData.GetDataSize: DWORD; stdcall;
begin
Result := FDataSize;
end;
function TSomethingData.GetCount: Integer; stdcall;
begin
Result := Length(FData);
end;
function TSomethingData.GetItem(const AIndex: Integer): Something; stdcall;
begin
Result := FData[AIndex];
end;
function GetDynData(const AFlags: DWORD; out AData: ISomethingData): BOOL; stdcall;
var
P: TSomethingArray;
begin
P := { ... готовим данные ... };
AData := TSomethingData.Create(P);
Result := True;
end;
Тогда на стороне exe можно будет делать так:
var
Data: ISomethingData;
begin
GetDynData(0, Data);
// Работаем с Data, например:
for X := 0 to Data.Count do
AddToList(Data[X]);
end;
В общем, тут довольно широкие возможности, можно делать почти как хотите. Причём даже если вы сначала сделали контракт через IData
, то позже вы можете добавить ISomethingData
, просто расширив интерфейс наследованием. При этом старые клиенты вашей DLL версии 1 будут использовать IData
, а клиенты версии 2 могут запросить более удобный ISomethingData
.
Как вы видите из кода выше: интерфейсы тем полезнее, чем сложнее возвращаемые данные. Сложные объекты очень просто возвращать интерфейсами, когда как для возврата простого блока памяти получается очень много писанины кода.
Очевидным минусом является необходимость писать больше кода для интерфейсов, поскольку вам необходим объект-переходник для реализации интерфейса. Но этот минус легко нейтрализуется следующим пунктом (см. ниже «Обработка ошибок»). Также он частично убирается, если вам изначально нужно вернуть объект (потому что при этом не потребуется объект-переходник, сам возвращаемый объект может реализовать интерфейс).
Примечание: код выше — просто пример. В реальном коде вам нужно добавить обработку ошибок и вынести определения интерфейсов IData
/ISomethingData
в отдельные файлы (заголовочники вашего SDK).
Обработка ошибок
(и соглашение вызова)
Когда программист пишет код, он определяет в программе последовательность действий, располагая в нужном порядке операторы, вызовы функций и т.п. При этом реализуемая последовательность действий соответствует логике алгоритма: сперва делаем это, потом вот то, а затем — вот это. Основной код соответствует «идеальной» ситуации, когда все файлы находятся на своих местах, все переменные имеют допустимые значения и т.п. Но при реальной эксплуатации программы неизбежно случаются ситуации, когда написанный программистом код будет действовать в недопустимом (а иногда — и непредусмотренном) окружении. Такие (и некоторые другие) ситуации называют обобщённым словом «ошибка». Поэтому программист обязан как-то определить, что же он будет делать в таких ситуациях. Как он будет определять допустимость ситуации, как на это реагировать и т.п.
Как правило, минимальными блоками, подвергаемым контролю, являются функция или процедура (подпрограмма). Каждая подпрограмма выполняет определённую задачу. И мы можем ожидать различный уровень «успешности» выполнения этой задачи: выполнение задачи было успешно или же при её выполнении возникла ошибка. Для написания надёжного кода нам совершенно необходим способ обнаружения ошибочных ситуаций — как мы определим, что в функции возникла ошибка? И реагирования на них — так называемое, «восстановление после ошибок» (т.е.: что мы будем делать при возникновении ошибки?). Традиционно, для обработки ошибок используется два основных способа: коды ошибок и исключения.
Коды ошибок
(и почему их не надо использовать)
Коды ошибок — это, пожалуй, самый простой способ реагирования на ошибки. Суть его проста: подпрограмма должна вернуть какой-либо признак успешности выполнения поставленной задачи. Тут есть два варианта: либо она вернёт простой признак (успешно/неуспешно), либо же она вернёт статус выполнения (иначе говоря — «описание ошибки»), т.е. некий код (число) одной из нескольких заранее определённых ситуаций: неверно заданы параметры функции, файл не найден и т.п. В первом случае может существовать дополнительная функция, которая возвращает статус выполнения последней вызванной функции. При таком подходе ошибки, обнаруженные в функции, обычно передаются выше (в вызывающую функцию). Каждая функция должна проверять результаты вызовов других функций на наличие ошибок и выполнять соответствующую обработку. Чаще всего обработка заключается в простой передаче кода ошибки ещё выше, в «более верхнюю» вызывающую функцию. Например: функция A вызывает B, B вызывает C, C обнаруживает ошибку и возвращает код ошибки в B. B проверяет возвращаемый код, видит, что возникла ошибка, и возвращает код ошибки в A. A проверяет возвращаемый код и выдает сообщение об ошибке (либо решает сделать что-нибудь еще).
Например, вот типичная функция Windows API:
RegisterClassEx
Регистрирует оконный класс для использования в функцияхCreateWindow
илиCreateWindowEx
.Синтаксисfunction RegisterClassEx(constAClass: TWndClassEx): ATOM; stdcall;ПараметрыAClass [входной]Тип: TWndClassExУказатель на записьWNDCLASSEX
. Вы должны заполнить эту запись до передачи в функцию.Возвращаемое значениеЕсли функция завершается успешно, возвращаемое значение является атомом класса, который однозначно идентифицирует регистрируемый класс. Этот атом может использоваться только функциямиCreateWindow
,CreateWindowEx
,GetClassInfo
,GetClassInfoEx
,FindWindow
,FindWindowEx
иUnregisterClass
, а также методомIActiveIMMap.FilterClientWindows
.Если функция завершается ошибкой, возвращаемое значение равно нулю. Чтобы получить расширенную информацию об ошибке, вызовите
GetLastError
.Примечания… вырезано …ПримерыПример использования можно увидеть в статье Using Window Classes.Требования
Минимальная версия ОС Windows 95 Заголовочный файл Winuser.h Библиотека User32.dll
Это — типичный способ обработки ошибок в классическом API Windows. При этом используются т.н. Win32 error codes. Это — обычное число типа DWORD
. Коды ошибок закреплены и объявлены в модуле Windows. За отсутствие ошибки принимается значение ERROR_SUCCESS
или NO_ERROR
равное 0. Для всех ошибок определены константы, начинающиеся (обычно) со слова ERROR_
, например:
{ Incorrect function. }
ERROR_INVALID_FUNCTION = 1; { dderror }
{ The system cannot find the file specified. }
ERROR_FILE_NOT_FOUND = 2;
{ The system cannot find the path specified. }
ERROR_PATH_NOT_FOUND = 3;
{ The system cannot open the file. }
ERROR_TOO_MANY_OPEN_FILES = 4;
{ Access is denied. }
ERROR_ACCESS_DENIED = 5;
{ The handle is invalid. }
ERROR_INVALID_HANDLE = 6;
// ... и т.п.
Описание Win32-ошибки можно получить через функцию FormatMessage
. В Delphi для этой системной функции с кучей параметров имеется (конкретно для нашего случая) более удобная для использования оболочка: функция SysErrorMessage
. Она, по переданному ей коду ошибки Win32, возвращает его описание. Кстати, обратите внимание, что сообщения возвращаются локализованными. Т.е. если у вас русская Windows, то сообщения будут на русском. Если английская — на английском.
Суммируя сказанное, вызывать такие функции приходится примерно так:
{ готовим WndClass }
ClassAtom := RegisterClassEx(WndClass);
if ClassAtom = 0 then
begin
// произошла ошибка с причиной из GetLastError
Application.MessageBox(
PChar('Произошла ошибка: ' + SysErrorMessage(GetLastError)),
PChar('Ошибка'), MB_OK or MB_ICONSTOP);
Exit;
end;
// ... продолжение нормального выполнения
Как и в случае с управлением памятью — и в этом случае не надо следовать примеру Windows. Подобный стиль давно уже устарел. И вот что с ним не так (это не полный список):
- Чтобы вызвать функцию требуется два вызова: сама функция и
GetLastError
(добавьте к этому необходимость дважды вызывать саму функцию для получения от неё памяти — получается вообще страшный ужас аж в четыре вызова функций вместо одного); - Вам требуется явно писать проверку вида
if что-то then ошибка
. И если вы забудете написать этот код, то получите баг: ваша программа будет продолжать выполнение при ошибке. Вероятно, портя данные и затрудняя локализацию бага (видимая проблема случится позже);- Подобные if-проверки также сильно визуально засоряют код;
- Если при ошибке вам нужно освободить какие-то ресурсы, да ещё если их несколько и вызовов функций тоже несколько, то правильный код для освобождения ресурсов может стать весьма нетривиальным;
- Вы никак не передадите дополнительную информацию. Например, никак не укажете какой именно аргумент неверен, или доступа к какому файлу у вас нет;
- Вы никак не узнаете, какая именно функция завершилась неудачно: была ли это вызываемая вами функция, или, быть может, какая-то другая функция, которую могла вызвать вызываемая вами;
- Отладчик никак не уведомит вас о проблеме (хотя, гипотетически, вы можете поставить точку останова на
GetLastError
).
Несмотря на все минусы, у кодов ошибок есть и плюс: поскольку это просто числа, то они понятны любому языку программирования. Т.е. коды ошибок совместимы между разными языками.
Исключения
(и почему их не надо использовать)
Исключения лишены многих минусов кодов ошибок:
- Исключения не нужно явно проверять, ситуация по умолчанию — реакция на ошибку;
- Программа не «засоряется» кодом проверки, он выносится отдельно;
- Легко освобождать ресурсы (через
try
—finally
); - Исключения легко расширять, наследовать, добавлять дополнительные поля, делать вложенные исключения;
- Отладчик уведомит вас о возникновении исключений;
- Вы можете назначить свой код для диагностики исключений (т.н. трейсер исключений).
Но несмотря на все плюсы, у исключений есть один существенный минус, который перечёркивает все плюсы (применительно к API DLL).
Вспомните как возбуждаются исключения в Delphi:
var
E: Exception;
begin
E := EMyExceptionClass.Create('Something');
raise E;
end;
Я разделил типичную строку «raise EMyExceptionClass.Create('Something');
» на две, чтобы проблема стала ещё более очевидной. Мы создаём объект Delphi (исключение) и «кидаем» (throw) его. И тот, кто хочет обработать это исключение, делает так:
except
on E: EMyException do
begin
ShowMessage(E.Message);
end; // - здесь E удаляется
end;
Это означает, что объект Delphi передаётся от вызываемого (DLL), где исключение возбуждается, в вызывающего (exe), где исключение обрабатывается. Как мы узнали ранее (см. раздел «Типы данных») это — проблема. Другие языки программирования не знают что такое объект Delphi, как его прочитать, как его удалить. Даже сама Delphi не всегда это знает (например, если исключение возбуждается кодом, собранном на Delphi 7, а ловится кодом, собранном на Delphi XE, или наоборот). В других языках программирования используются похожие конструкции: исключение представлено объектом. Соответственно, Delphi код понятия не имеет как нужно работать с объектами на других языках.
Иными словами, исключения не нужно использовать по причине языковой несовместимости.
Следствие 1: исключения не должны покидать вашу DLL.
Следствие 2: вы должны ловить все исключения в своих экспортируемых функциях.
Следствие 3: все экспортируемые функции обязаны иметь глобальную конструкцию try
—except
.
function GetDynData(const AFlags: DWORD; out AData: IData): BOOL; stdcall;
begin
try
// ... непосредственно код функции, полезная нагрузка ...
Result := True;
except
// ... обработка исключений ...
Result := False;
end;
end;
Что использовать можно и нужно
(и какое использовать соглашение вызова)
Если мы не можем использовать коды ошибок и не можем использовать исключения, то что нам нужно использовать? А использовать нам нужно их комбинацию — и вот как она выглядит.
В Delphi есть встроенная магия компилятора, которая может обернуть любую функцию в скрытый блок try
—except
с автоматическим (скрытым) вызовом функции обработки. И есть магия компилятора, которая работает наоборот: по возвращаемому коду ошибки автоматически возбуждает подходящее исключение.
Прежде чем мы познакомимся с этим волшебством, нам нужно познакомится с кодами ошибок в виде типа HRESULT
. HRESULT
— это тоже число, но теперь уже типа Integer
. HRESULT
уже не просто код ошибки, он состоит из нескольких частей, подробно рассматривать которые мы не будем, но достаточно сказать, что они включают в себя собственно код ошибки (то, что раньше было Win32 кодом), признак успешности или ошибки, идентификатор возбудителя ошибки. Коды ошибок начинаются с префикса E_
(например, E_FAIL
, E_UNEXPECTED
, E_ABORT
или E_ACCESSDENIED
), а коды успеха — с S_
(например, S_OK
или S_FALSE
). Легко определить успешность кода HRESULT
можно сравнив его с нулём: ошибочные коды HRESULT
должны быть меньше нуля.
Выделение признака успеха/ошибки означает, что теперь нет необходимости функции возвращать только этот признак (через BOOL
), а сам код ошибки — через отдельную функцию (GetLastError
). Теперь функция может вернуть всю информацию сразу, за один вызов:
function GetDynData(const AFlags: DWORD; out AData: IData): HRESULT; stdcall;
begin
try
// ... непосредственно код функции, полезная нагрузка ...
Result := S_OK;
except
// ... обработка исключений ...
Result := E_FAIL; // какой-то из кодов ошибок
end;
end;
Вместе с введением HRESULT
был придуман и интерфейс IErrorInfo
, который позволяет ассоциировать с возвращаемым HRESULT
дополнительную информацию: произвольное описание, GUID возбуждающего (интерфейса), место возбуждения ошибки (произвольная строка), справку. Вам даже не нужно реализовывать этот интерфейс, в системе уже есть готовый объект — возвращаемый функцией CreateErrorInfo
.
Наконец, в Delphi есть уже упоминаемая магия компилятора, которая может упростить написание такого кода. Для этого функция должна иметь соглашение вызова stdcall
и возвращать HRESULT
. Если раньше функция возвращала какой-то Result
, то его надлежит сделать последним out-параметром, например:
// Было:
function GetDynData(const AFlags: DWORD): IData;
// Стало:
function GetDynData(const AFlags: DWORD; out AData: IData): HRESULT; stdcall;
Если функция удовлетворяет этим требованиям, то вы можете объявить её так:
function GetDynData(const AFlags: DWORD): IData; safecall;
Этот вариант будет двоично эквивалентен (т.е. полностью совместим) такому:
function GetDynData(const AFlags: DWORD; out AData: IData): HRESULT; stdcall;
Объявив функцию как safecall
вы включите для неё магию компилятора, а именно:
- Возвращаемый результат будет автоматически преобразован в последний out-параметр;
- Функция будет скрыто передавать
HRESULT
(и, возможно,IErrorInfo
); - Вызов функции будет обёрнут в if-проверку возвращаемого кода. При получение ошибочного
HRESULT
будет возбуждено исключение:var
Data: IData;
begin
Data := GetDynData(Flags); // возбуждает исключение при ошибке
// выполнение продолжается только при успешном вызове - Сама функция будет обёрнута в скрытый блок
try
—except
, преобразующий исключение вHRESULT
(и, возможно, вIErrorInfo
):function GetDynData(const AFlags: DWORD): IData; safecall;
begin
// ... сам код функции, полезная нагрузка ...
end; // - скрытый блок try-except
Как вы видите, с такой поддержкой компилятора — можно писать код почти как если бы это была обычная функция в обычном модуле Delphi. И что самое вкусное — подобный подход могут использовать и другие языки программирования. Конечно, в них может не быть подходящей магии компилятора, но они вполне способны принять HRESULT
у stdcall
функции и проанализировать его (возможно, вместе с IErrorInfo
).
Как правильно работать с safecall
Теперь, когда мы описали плюсы safecall
, то настала пора для ложки дёгтя. Дело в том, что «из коробки» магия safecall
работает в минимальном режиме. И чтобы получить от неё максимальную выгоду, нам нужно сделать дополнительные действия. К счастью, их нужно сделать один раз и можно использовать повторно в дальнейшем.
Пункт номер один: простые экспортируемые функции:
procedure DoSomething; safecall;
begin
// ... код функции
end;
exports
DoSomething;
Дело в том, что для обычных функций компилятор не позволяет произвести настройку процесса конвертирования исключения в HRESULT
, всегда возвращая фиксированный код и теряя информацию. Поэтому вместо экспортируемых функций нужно использовать интерфейсы с методами. Т.е. было:
procedure DoSomething; safecall;
begin
// ... сам код функции, полезная нагрузка ...
end;
function GetDynData(const AFlags: DWORD): IData; safecall;
begin
// ... сам код функции, полезная нагрузка ...
end;
function DoSomethingElse(AOptions: IOptions): BSTR; safecall;
begin
// ... сам код функции, полезная нагрузка ...
end;
exports
DoSomething,
GetDynData,
DoSomethingElse;
Стало:
type
IMyDLL = interface
['{C5DBE4DC-B4D7-475B-9509-E43193796633}']
procedure DoSomething; safecall;
function GetDynData(const AFlags: DWORD): IData; safecall;
function DoSomethingElse(AOptions: IOptions): BSTR; safecall;
end;
TMyDLL = class(TInterfacedObject, IMyDLL)
protected
procedure DoSomething; safecall;
function GetDynData(const AFlags: DWORD): IData; safecall;
function DoSomethingElse(AOptions: IOptions): BSTR; safecall;
end;
procedure TMyDLL.DoSomething; safecall;
begin
// ... сам код функции, полезная нагрузка ...
end;
function TMyDLL.GetDynData(const AFlags: DWORD): IData; safecall;
begin
// ... сам код функции, полезная нагрузка ...
end;
function TMyDLL.DoSomethingElse(AOptions: IOptions): BSTR; safecall;
begin
// ... сам код функции, полезная нагрузка ...
end;
function GetFunctions(out AFunctions: IMyDLL): HRESULT; stdcall;
begin
try
AFunctions := TMyDLL.Create;
Result := S_OK;
except
on E: Exception do
Result := HandleSafeCallException(E, ExceptAddr);
end;
end;
exports
GetFunctions;
где HandleSafeCallException
— наша функция, которую мы опишем ниже.
Как видите, все экспортируемые функции мы поместили в единый интерфейс (объект) — это позволит нам настроить/контролировать процесс преобразования исключений в HRESULT
. При этом DLL экспортирует единственную функцию, которую нам пришлось описать вручную, без safecall
— что также позволило нам контролировать процесс преобразования. Не забываем, что она двоично совместима с safecall
, поэтому, если вы хотите использовать эту DLL в Delphi, то вы можете делать так:
function GetFunctions: IMyDLL; safecall; external 'MyDLL.dll';
и это будет прекрасно работать.
Что касается объектов, то при возбуждении исключения в safecall
методе компилятор вызывает виртуальный метод TObject.SafeCallException
, который по умолчанию не делает ничего полезного, и который мы можем заменить на свой метод:
type
TMyDLL = class(TInterfacedObject, IMyDLL)
protected
procedure DoSomething; safecall;
function GetDynData(const AFlags: DWORD): IData; safecall;
function DoSomethingElse(AOptions: IOptions): BSTR; safecall;
public
function SafeCallException(ExceptObject: TObject; ExceptAddr: Pointer): HResult; override;
end;
function TMyDLL.SafeCallException(ExceptObject: TObject; ExceptAddr: Pointer): HResult;
begin
Result := HandleSafeCallException(ExceptObject, ExceptAddr);
end;
Далее, когда код вызывает safecall
метод, компилятор заворачивает вызов метода в обёртку CheckAutoResult
, которая (в случае ошибочного кода) возбуждает исключение через глобальную функцию-переменную SafeCallErrorProc
, которую, опять же, мы можем заменить на свою:
procedure RaiseSafeCallException(ErrorCode: HResult; ErrorAddr: Pointer);
begin
// ... наш код ...
end;
initialization
SafeCallErrorProc := RaiseSafeCallException;
end.
Теперь нам осталось сделать так, чтобы наши HandleSafeCallException
и RaiseSafeCallException
работали бы парой и делали что-то полезное.
Для начала нам потребуются две вспомогательные функции-обёртки:
uses
ActiveX; // или Ole2
function SetErrorInfo(const ErrorCode: HRESULT; const ErrorIID: TGUID;
const Source, Description, HelpFileName: WideString;
const HelpContext: Integer): HRESULT;
var
CreateError: ICreateErrorInfo;
ErrorInfo: IErrorInfo;
begin
Result := E_UNEXPECTED;
if Succeeded(CreateErrorInfo(CreateError)) then
begin
CreateError.SetGUID(ErrorIID);
if Source '' then
CreateError.SetSource(PWideChar(Source));
if HelpFileName '' then
CreateError.SetHelpFile(PWideChar(HelpFileName));
if Description '' then
CreateError.SetDescription(PWideChar(Description));
if HelpContext 0 then
CreateError.SetHelpContext(HelpContext);
if ErrorCode 0 then
Result := ErrorCode;
if CreateError.QueryInterface(IErrorInfo, ErrorInfo) = S_OK then
ActiveX.SetErrorInfo(0, ErrorInfo);
end;
end;
procedure GetErrorInfo(out ErrorIID: TGUID; out Source, Description, HelpFileName: WideString; out HelpContext: Longint);
var
ErrorInfo: IErrorInfo;
begin
if ActiveX.GetErrorInfo(0, ErrorInfo) = S_OK then
begin
ErrorInfo.GetGUID(ErrorIID);
ErrorInfo.GetSource(Source);
ErrorInfo.GetDescription(Description);
ErrorInfo.GetHelpFile(HelpFileName);
ErrorInfo.GetHelpContext(HelpContext);
end
else
begin
FillChar(ErrorIID, SizeOf(ErrorIID), 0);
Source := '';
Description := '';
HelpFileName := '';
HelpContext := 0;
end;
end;
Как несложно сообразить, они предназначены для передачи и получения дополнительной информации вместе с HRESULT
.
Далее нам необходим способ как-то передавать имя класса исключения. Делать это можно разными способами. Например, передавать его непосредственно в HRESULT
. Для этого его нужно закодировать. Например, так:
uses
ComObj, // для типов EOleSysError и EOleException
VarUtils; // для ESafeArrayError
const
// Идентификатор нашей системы передачи исключений
ThisDllIID: TGUID = '{AA76E538-EF3C-4F35-9914-B4801B211A6D}';
// "Customer" бит, всегда равен 0 в кодах Microsoft
CUSTOMER_BIT = 1 shl 29;
// Delphi использует это значение для передачи EAbort
// Подразумевается, что сам E_Abort должен приводить
// к показу сообщения "операция прервана",
// а EAbort - обрабатываться молча
EAbortRaisedHRESULT = HRESULT(E_ABORT or CUSTOMER_BIT);
function Exception2HRESULT(const E: TObject): HRESULT;
function NTSTATUSFromException(const E: EExternal): DWORD;
begin
// ...
end;
begin
if E = nil then
Result := E_UNEXPECTED
else
if not E.InheritsFrom(Exception) then
Result := E_UNEXPECTED
else
if E.ClassType = Exception then
Result := E_FAIL
else
if E.InheritsFrom(ESafecallException) then
Result := E_FAIL
else
if E.InheritsFrom(EAssertionFailed) then
Result := E_UNEXPECTED
else
if E.InheritsFrom(EAbort) then
Result := EAbortRaisedHRESULT
else
if E.InheritsFrom(EOutOfMemory) then
Result := E_OUTOFMEMORY
else
if E.InheritsFrom(ENotImplemented) then
Result := E_NOTIMPL
else
if E.InheritsFrom(ENotSupportedException) then
Result := E_NOINTERFACE
else
if E.InheritsFrom(EOleSysError) then
Result := EOleSysError(E).ErrorCode
else
if E.InheritsFrom(ESafeArrayError) then
Result := ESafeArrayError(E).ErrorCode
else
if E.InheritsFrom(EOSError) then
Result := HResultFromWin32(EOSError(E).ErrorCode)
else
if E.InheritsFrom(EExternal) then
if Failed(HRESULT(EExternal(E).ExceptionRecord.ExceptionCode)) then
Result := HResultFromNT(Integer(EExternal(E).ExceptionRecord.ExceptionCode))
else
Result := HResultFromNT(Integer(NTSTATUSFromException(EExternal(E))))
else
Result := MakeResult(SEVERITY_ERROR, FACILITY_ITF, Hash(E.ClassName)) or CUSTOMER_BIT;
end;
Здесь мы проверяем на несколько специальных предопределённых классов, а также у нас есть возможность прямо передавать коды Win32 и коды аппаратных исключений. Для всех прочих (специфичных для Delphi) классов исключений мы используем хэш от имени класса вместе с FACILITY_ITF
. В качестве хэша можно использовать, к примеру, SDBM — это очень простая хэш-функция с хорошей рандомизацией результата. Конечно, вы можете использовать любой другой способ — например, просто вручную выделить и зафиксировать коды для каждого класса исключения.
HRESULT
с типамиFACILITY_NULL
иFACILITY_RPC
имеют универсальное значение, поскольку они определены Microsoft.HRESULT
сFACILITY_ITF
определяются функцией или методом интерфейса, из которых они возвращаются. Это означает, что одно и то же 32-битное значение вFACILITY_ITF
, но возвращаемое двумя разными интерфейсами, может иметь разный смысл. Таким образом, Microsoft может определять несколько универсальных кодов ошибок, в то же время позволяя другим программистам определять новые коды ошибок, не опасаясь конфликта. Соглашение распределении кодов выглядит следующим образом:
HRESULT
с типами, отличных отFACILITY_ITF
, могут быть определены только Microsoft;HRESULT
с типомFACILITY_ITF
определяются исключительно разработчиком интерфейса или функции, которая возвращаетHRESULT
. Чтобы избежать конфликтующихHRESULT
, тот, кто определяет интерфейс, отвечает за координацию и публикацию кодовHRESULT
, связанных с этим интерфейсом;- Все
HRESULT
, определяемые Microsoft, имеют значение кода ошибки в диапазоне $0000-$01FF. Хотя вы можете использовать любой код вместе сFACILITY_ITF
, но рекомендуется использовать значения в диапазоне $0200-$FFFF. Эта рекомендация предназначена для уменьшения путаницы с кодами Microsoft.
Вот почему в коде выше мы также определили ThisDllIID
— это идентификатор «интерфейса», который задаёт смысл для возвращаемых кодов с типом FACILITY_ITF
. Это значение нужно передавать как ErrorIID
в SetErrorInfo
, определённую выше.
29-й «Customer» бит изначально был зарезервированным битом, который в дальнейшем был выделен для использования в качестве флага, указывающего определён ли код Microsoft (0) или сторонним разработчиком (1). В некотором роде этот бит дублирует FACILITY_ITF
. Обычно даже сторонние разработчики используют только FACILITY_ITF
. В данном же случае мы его ставим для уменьшения возможных проблем с плохим кодом (который не учитывает GUID интерфейса).
С обратной конвертацией (код в исключение) всё немного сложнее, нам потребуются таблицы для поиска класса исключения по коду. Простая реализация может выглядеть так:
function HRESULT2Exception(const E: HRESULT): Exception;
function MapNTStatus(const ANTStatus: DWORD): ExceptClass;
begin
// ...
end;
function MapException(const ACode: DWORD): ExceptClass;
begin
// ...
end;
var
NTStatus: DWORD;
ErrorIID: TGUID;
Source: WideString;
Description: WideString;
HelpFileName: WideString;
HelpContext: Integer;
begin
if GetErrorInfo(ErrorIID, Source, Description, HelpFileName, HelpContext) then
begin
if Pointer(StrToInt64Def(Source, 0)) nil then
ErrorAddr := Pointer(StrToInt64(Source));
end
else
Description := SysErrorMessage(DWORD(E));
if (E = E_FAIL) or (E = E_UNEXPECTED) then
Result := Exception.Create(Description)
else
if E = EAbortRaisedHRESULT then
Result := EAbort.Create(Description)
else
if E = E_OUTOFMEMORY then
begin
OutOfMemoryError;
Result := nil;
end
else
if E = E_NOTIMPL then
Result := ENotImplemented.Create(Description)
else
if E = E_NOINTERFACE then
Result := ENotSupportedException.Create(Description)
else
if HResultFacility(E) = FACILITY_WIN32 then
begin
Result := EOSError.Create(Description);
EOSError(Result).ErrorCode := HResultCode(E);
end
else
if E and FACILITY_NT_BIT 0 then
begin
// Получаем класс исключения по коду
NTStatus := Cardinal(E) and (not FACILITY_NT_BIT);
Result := MapNTStatus(NTStatus).Create(Description);
// На всякий случай делаем заглушку для ExceptionRecord
ReallocMem(Pointer(Result), Result.InstanceSize + SizeOf(TExceptionRecord));
EExternal(Result).ExceptionRecord := Pointer(NativeUInt(Result) + Cardinal(Result.InstanceSize));
FillChar(EExternal(Result).ExceptionRecord^, SizeOf(TExceptionRecord), 0);
EExternal(Result).ExceptionRecord.ExceptionCode := cDelphiException;
EExternal(Result).ExceptionRecord.ExceptionAddress := ErrorAddr;
end
else
if (E and CUSTOMER_BIT 0) and
(HResultFacility(E) = FACILITY_ITF) and
CompareMem(@ThisDllIID, @ErrorIID, SizeOf(ErrorIID)) then
Result := MapException(HResultCode(E)).Create(Description)
else
Result := EOleException.Create(Description, E, Source, HelpFileName, HelpContext);
end;
В целом код достаточно прямолинеен, за исключением аппаратных исключений. Для них мы делаем эмуляцию.
Также замечу, что поле Source
интерфейса IErrorInfo
должно указывать на место, в котором возникла ошибка. Поле это произвольно и определяется разработчиком интерфейса (т.е. опять же по GUID). В данном случае мы просто туда пишем адрес исключения. Но, к примеру, если вы используете трейсер исключений, то можете писать туда стек вызовов.
Тогда с указанными выше вспомогательными функциями, наши HandleSafeCallException
и RaiseSafeCallException
становятся тривиальными:
function HandleSafeCallException(ExceptObj: TObject; ErrorAddr: Pointer): HRESULT;
var
ErrorMessage: String;
HelpFileName: String;
HelpContext: Integer;
begin
if ExceptObj is Exception then
ErrorMessage := Exception(ExceptObj).Message
else
ErrorMessage := SysErrorMessage(DWORD(E_FAIL));
if ExceptObj is EOleException then
begin
HelpFileName := EOleException(ExceptObj).HelpFile;
HelpContext := EOleException(ExceptObj).HelpContext;
end
else
begin
HelpFileName := '';
if ExceptObj is Exception then
HelpContext := Exception(ExceptObj).HelpContext
else
HelpContext := 0;
end;
Result := SetErrorInfo(Exception2HRESULT(ExceptObj), ThisDllIID,
'$' + IntToHex(NativeUInt(ErrorAddr), SizeOf(ErrorAddr) * 2), ErrorMessage,
HelpFileName, HelpContext);
end;
procedure RaiseSafeCallException(ErrorCode: HResult; ErrorAddr: Pointer);
var
E: Exception;
begin
E := HRESULT2Exception(ErrorCode, ErrorAddr);
raise E at ErrorAddr;
end;
Примечание: в нашей модели мы не используем поля для справки интерфейса IErrorInfo
.
Надо заметить, что если интерфейс использует вместе с HRESULT
и IErrorInfo
, то ему также следует реализовывать ещё и интерфейс ISupportErrorInfo
. Некоторые языки программирования этого требуют. Вызывая ISupportErrorInfo.InterfaceSupportsErrorInfo
, клиентская сторона может определить, что объект поддерживает дополнительную информацию.
И последний момент — в реализации Delphi для Windows 32-бита есть неприятный баг, которого нет в 64-битном RTL, а также на других платформах. Исправление этого бага включено в примеры кода по ссылке в конце статьи.
Если у вас возникают проблемы с отладкой вашей DLL, то я разобрал этот вопрос в этой статье.
Обход проблем DllMain
DllMain
— это специальная функция в DLL, которая вызывается системой, когда DLL загружается в процесс, выгружается из него (а также подключается/отключается к/от потока). К примеру, секции initialization
и finalization
ваших Delphi модулей выполняются именно внутри DllMain
.
Проблема заключается в том, что DllMain
— это очень специальная функция. Она вызывается во время удержания критической секции загрузчика (модулей) операционной системы. Если говорить длинно и подробно — посмотрите ссылки в конце этого пункта, а если говорить кратко: DllMain
— это оружие, из которого вы легко можете застрелиться. Есть не так много вещей, которые можно делать легально в DllMain. Зато невероятно легко сделать что-то запретное — вам постоянно нужно быть уверенными, что вот эта вот функция, которую вы только что вызвали, никогда и ни при каких условиях не может выполнить что-либо запретное. Это делает невероятно сложным использование кода, написанного в другом месте. А компилятор вам ничего не скажет. И код чаще всего будет работать, как будто так и должно быть… но иногда он будет вылетать или зависать.
Решение проблемы заключается в том, чтобы не делать ничего в DllMain
(читай: не пишите кода в секциях initialization
и finalization
модулей когда вы создаёте DLL).
Вместо этого вам нужно сделать отдельные функции инициализации и финализации DLL. Вам нужно делать их даже в том случае, если ваша DLL не нуждается в каких-то действиях по инициализации или очистки. Ведь такая потребность может возникнуть в будущем, и если вы не предусмотрите в вашем API отдельных функций инициализации и финализации, то не сможете потом решить эту проблему.
Вот шаблон кода:
// В заголовочниках:
type
IMyDll = interface
['{C5DBE4DC-B4D7-475B-9509-E43193796633}']
procedure InitDLL(AOptional: IUnknown = nil); safecall;
procedure DoneDLL; safecall;
// ...
end;
// В DLL:
type
TInitFunc = procedure(const AOptional: IUnknown);
TDoneFunc = procedure;
TInitDoneFunc = record
Init: TInitFunc;
Done: TDoneFunc;
end;
procedure RegisterInitFunc(const AInitProc: TInitFunc; const ADoneFunc: TDoneFunc = nil);
// ...
var
InitDoneFuncs: array of TInitDoneFunc;
procedure RegisterInitFunc(const AInitProc: TInitFunc; const ADoneFunc: TDoneFunc);
begin
SetLength(InitDoneFuncs, Length(InitDoneFuncs) + 1);
InitDoneFuncs[High(InitDoneFuncs)].Init := AInitProc;
InitDoneFuncs[High(InitDoneFuncs)].Done := ADoneFunc;
end;
procedure TMyDLL.InitDLL(AOptional: IUnknown); safecall;
var
X: Integer;
begin
for X := 0 to High(InitDoneFuncs) do
if Assigned(InitDoneFuncs[X].Init) then
InitDoneFuncs.Init(AOptional);
end;
procedure TMyDLL.DoneDLL; safecall;
var
X: Integer;
begin
for X := 0 to High(InitDoneFuncs) do
if Assigned(InitDoneFuncs[X].Done) then
InitDoneFuncs.Done;
end;
// В модулях:
procedure InitUnit(const AOptional: IUnknown);
begin
// ... код, который вы обычно помещаете в секцию initialization
end;
procedure DoneUnit;
begin
// ... код, который вы обычно помещаете в секцию finalization
end;
initialization
RegisterInitFunc(InitUnit, DoneUnit);
end;
Параметр AOptional
сделан с заделом на будущее. В коде выше он не используется, но в дальнейшем (в следующей версии DLL) вы можете использовать его, чтобы передать параметры инициализации. IUnknown
— это базовый интерфейс, от которого наследуются все другие интерфейсы (т.е. некий аналог TObject
для интерфейсов).
Надеюсь, этот код достаточно понятен. Разумеется, его надо разносить по разным модулям и секциям. Интерфейс — в заголовочники, объявление RegisterInitFunc
— в interface
общего модуля DLL, вызывать её нужно из секции initialization
других модулей.
Разумеется, в документации вашего SDK должны быть слова о том, что использующий вашу DLL обязан вызвать метод InitDLL
сразу после загрузки вашей DLL функцией LoadLibrary
и вызвать метод DoneDLL
непосредственно перед выгрузкой DLL функцией FreeLibrary
:
var
DLL: HMODULE;
DLLApi: IMyDll;
begin
DLL := LoadLibrary('MyDLL.dll');
Win32Check(DLL 0);
try
DLLApi.InitDLL(nil);
// работа с DLL, например, вызов DLLApi.GetDynData
finally
DLLApi.DoneDLL;
DLLApi := nil;
FreeLibrary(DLL);
end;
end;
Больше подробностей и примеров о DllMain
:
- DllMain и жизнь до родов
- DllMain — страшилка на ночь
- Несколько причин, чтобы не делать ничего страшного в своей DllMain
- Ещё причины, почему не надо делать ничего страшного в DllMain: случайная блокировка
Callback-функции
(функции обратного вызова)
Callback-функция (англ. call — вызов, англ. back — обратный) или функция обратного вызова в программировании — передача исполняемого кода в качестве одного из параметров другого кода. К примеру, если вы хотите установить таймер с использованием Windows API, вы можете вызвать функцию SetTimer
, передав в неё указатель на свою функцию, которая и будет callback-функцией. Система будет вызывать вашу функцию каждый раз, когда срабатывает таймер:
procedure MyTimerHandler(Wnd: HWND; uMsg: UINT; idEvent: UINT_PTR; dwTime: DWORD); stdcall;
begin
// Будет вызвана через 100 мс.
end;
procedure TForm1.Button1Click(Sender: TObject);
begin
SetTimer(Handle, 1, 100, @MyTimerHandler);
end;
Вот ещё пример: если вы хотите найти все окна на рабочем столе, вы можете использовать функцию EnumWindows
:
function MyEnumFunc(Wnd: HWND; lpData: LPARAM): Bool; stdcall;
begin
// Вызывается для каждого найденного окна в системе
end;
procedure TForm1.Button1Click(Sender: TObject);
begin
EnumWindows(@MyEnumFunc, 0);
end;
Поскольку функция обратного вызова обычно выполняет ту же задачу, что и код, который её устанавливает, то получается, что обоим кускам кода нужно работать с одними и теми же данными. Следовательно, данные от устанавливающего кода необходимо как-то передать в функцию обратного вызова. Для этой цели в функциях обратного вызова предусматриваются т.н. user-параметры: это либо указатель, либо целое число (обязательно типа Native(U)Int, но не (U)Int), который никак не используются самим API и прозрачно передаются в callback-функцию. Либо (в редких случаях) это может быть какое-то значение, уникально идентифицирующее вызов функции.
К примеру, в SetTimer
есть idEvent
, а в EnumWindows
есть lpData
. Мы можем использовать эти параметры, чтобы передать произвольные данные. Вот, к примеру, как можно найти все окна заданного класса:
type
PEnumArgs = ^TEnumArgs;
TEnumArgs = record
ClassName: String;
Windows: TStrings;
end;
function FindWindowsOfClass(Wnd: HWND; lpData: LPARAM): Bool; stdcall;
var
Args: PEnumArgs;
WndClassName, WndText: String;
begin
Args := Pointer(lpData);
SetLength(WndClassName, Length(Args.ClassName) + 2);
SetLength(WndClassName, GetClassName(Wnd, PChar(WndClassName), Length(WndClassName)));
if WndClassName = Args.ClassName then
begin
SetLength(WndText, GetWindowTextLength(Wnd) + 1);
SetLength(WndText, GetWindowText(Wnd, PChar(WndText), Length(WndText)));
Args.Windows.Add(Format('%8x : %s', [Wnd, WndText]));
end;
Result := True;
end;
procedure TForm1.Button1Click(Sender: TObject);
var
Args: TEnumArgs;
begin
// В Edit можно вводить значения типа
// 'TForm1', 'IME', 'MSTaskListWClass', 'Shell_TrayWnd', 'TTOTAL_CMD', 'Chrome_WidgetWin_1'
Args.ClassName := Edit1.Text;
Args.Windows := Memo1.Lines;
Memo1.Lines.BeginUpdate;
try
Memo1.Lines.Clear;
EnumWindows(@FindWindowsOfClass, LPARAM(@Args));
finally
Memo1.Lines.EndUpdate;
end;
end;
Примечание: вот ещё один пример как не надо делать — не надо делать как в Windows. Если вам нужно просто получить список чего-то — не надо делать функцию обратного вызова, просто верните список в массиве (завернув его в интерфейс или передавая как блок памяти). Функцию обратного вызова нужно использовать только если список создаётся долго, а вам не нужны все элементы. Тогда функция обратного вызова может вернуть признак «стоп», не завершая создание полного списка до конца.
Примечание: неким аналогом user-параметров являются свойства Tag
и Data
, хотя их использование не всегда бывает идеологически верным (правильно: создать класс-наследник).
Из вышесказанного следует вывод: если в вашем API нужно сделать функцию обратного вызова, то она обязана иметь пользовательский параметр размера Pointer
, который не будет использоваться вашим API. Например:
// Неправильно!
type
TNotifyMeProc = procedure; safecall;
IMyDllAPI = interface
// ...
procedure NotifyMe(const ANotifyEvent: TNotifyMeProc); safecall;
end;
// Правильно
type
TNotifyMeProc = procedure(const AUserArg: Pointer); safecall;
IMyDllAPI = interface
// ...
procedure NotifyMe(const ANotifyEvent: TNotifyMeProc; const AUserArg: Pointer = nil); safecall;
end;
А если вы забудете это сделать — вызывающему придётся использовать уродские хаки, чтобы обойти ваш кривой API.
Разумеется, вместо функции + параметр можно использовать просто интерфейс:
// Правильно
type
INotifyMe = interface
['{07FA30E4-FE9B-4ED2-8692-1E5CFEE4CF3F}']
procedure Notify; safecall;
end;
IMyDllAPI = interface
// ...
procedure NotifyMe(const ANotifyEvent: INotifyMe); safecall;
end;
Это — предпочтительней, потому что и обработка ошибок через safecall
в интерфейсах проще, и интерфейс может содержать сколько угодно много параметров, и с объектами (формой) удобнее интегрироваться. Например:
type
TForm1 = class(TForm, INotifyMe)
// ...
procedure Notify; safecall;
private
FAPI: IMyDllAPI;
public
function SafeCallException(ExceptObject: TObject; ExceptAddr: Pointer): HResult; override;
end;
// ...
procedure TForm1.FormCreate(Sender: TObject);
begin
// ... загружаем DLL, получаем API
FAPI.NotifyMe(Self); // просим дёрнуть нас при событии
end;
procedure TForm1.Notify;
begin
ShowMessage('Событие произошло');
end;
Прочие правила
- Если вы не только разрабатываете, но и используете DLL, то загружайте DLL правильно;
- Если по какой-то причине вы не используете
safecall
, то не возвращайте сложные типы черезResult
, делайте out-параметр. Проблема в том, что Delphi и MS Visual C++ расходятся во мнении как трактовать возвращаемый по ссылке stdcall-функцией результат: какvar
или какout
. Соответственно, дляsafecall
такой проблемы нет, посколькуResult
у неё — всегдаInteger
(HRESULT
) — простой тип, для которогоvar
иout
эквивалентны; - Все интерфейсы API должны иметь уникальный IID/GUID (интерфейсы вне API (не упомянутые в заголовочниках) могут не иметь GUID, хотя я бы рекомендовал всегда указывать IID). Создать GUID для использования в качестве IID (Interface ID) вы можете нажав
Ctrl + Shift + G
в редактора кода Delphi — эта комбинация вставит выражение вида['{C5DBE4DC-B4D7-475B-9509-E43193796633}']
(разумеется, каждый раз с уникальным GUID) прямо под курсор в редакторе; - Как только вы опубликовали какой-то тип (интерфейс), т.е. выпустили «на волю» вашу DLL с этим интерфейсом — вы не должны его изменять. Если вам нужно его расширить или изменить — вы вводите новый интерфейс (новую версию интерфейса), но не меняете старый
// Было:
type
IMyDLL = interface
['{C5DBE4DC-B4D7-475B-9509-E43193796633}']
procedure InitDLL(AOptional: IUnknown = nil); safecall;
procedure DoneDLL; safecall;
function GetDynData(const AFlags: DWORD): IData; safecall;
end;
// После публикации - так нельзя:
type
IMyDLL = interface
['{C5DBE4DC-B4D7-475B-9509-E43193796633}']
procedure InitDLL(AOptional: IUnknown = nil); safecall;
procedure DoneDLL; safecall;
procedure DoSomething; safecall; // добавили
function GetDynData(const AFlags: DWORD): IData; safecall;
end;
// Так - можно:
type
IMyDLLv1 = interface
['{C5DBE4DC-B4D7-475B-9509-E43193796633}']
procedure InitDLL(AOptional: IUnknown = nil); safecall;
procedure DoneDLL; safecall;
function GetDynData(const AFlags: DWORD): IData; safecall;
end;
IMyDLLv2 = interface(IMyDLLv1)
['{69E77989-64DC-4177-975C-487818598C70}']
procedure DoSomething; safecall; // добавили
end; - Если функция или метод возвращает интерфейс, то не надо делать так:
// Неправильно!
function GetSomething: ISomething; safecall;
// ...
var
Something: ISomething;
begin
Something := GetSomething;Конечно, в начале это удобное решение: вы можете вызывать функции «как обычно» и даже сцеплять их в цепочки вида
Control
.GetPicture
.GetImage
.GetColorInfo
.GetBackgroundColor
. Однако такое положение дел будет существовать только в самой первой версии системы. Как только вы начнёте развивать систему, у вас начнут появляться новые интерфейсы. В не столь отдалённом будущем у вас будет куча продвинутых интерфейсов, а базовые интерфейсы, которые были в программе изначально, в момент её рождения, будут реализовывать лишь тривиально-неинтересные функции. Итого, очень часто вызывающему коду будут нужны новые интерфейсы, а не оригинальные. Что это значит? Это значит, что коду нужно вызвать оригинальную функцию, получить оригинальный интерфейс, затем запросить у него новый (черезSupports
/QueryInterface
) и лишь затем использовать новый интерфейс. Получается не так удобно, даже скорее неудобно: имеем тройной вызов (оригинальный + конвертация + нужный). Лучшее решение заключается в том, чтобы вызывающий код указывал бы вызываемой функции, какой интерфейс его интересует: новый или старый:// Правильно
procedure GetSomething(const AIID: TGUID; out Intf); safecall;
// ...
var
Something: ISomething;
begin
GetSomething(ISomething, Something); - Если объект реализует интерфейс, то в вашем коде не должно быть переменных этого класса. Т.е.:
type
TSomeObject = class(TSomeOtherClass, ISomeInterface)
// ...
end;
var
Obj: TSomeObject; // - так нельзя!
Obj: ISomeInterface; // - так можно
begin
Obj := TSomeObject.Create;
// ... - Если вы реализуете расширение интерфейса наследованием — не забудьте явно перечислить все его предки в реализующем объекте. Например:
type
ISomeInterfaceV1 = interface
['{A80A78ED-5836-49C4-B6C2-11F531103FE7}']
procedure A;
end;
ISomeInterfaceV2 = interface(ISomeInterfaceV1) // ISomeInterfaceV2 наследуется от ISomeInterfaceV1
['{EBDD52A1-489B-4564-998E-09FCCF923F48}']
procedure B;
end;
// Неправильно!
TObj = class(TInterfacedObject, ISomeInterfaceV2) // указан ISomeInterfaceV2, но не ISomeInterfaceV1
protected
procedure A;
procedure B;
end;
var
SI1: ISomeInterfaceV1;
SI2: ISomeInterfaceV2;
begin
Supports(SI2, ISomeInterfaceV1, SI1);
Assert(Assigned(SI1)); // утверждение сработает, т.к. SI1 = nil (вызов Supports выше вернул False)
end;Правильно делать так:
// Правильно
TObj = class(TInterfacedObject, ISomeInterfaceV1, ISomeInterfaceV2)
// ... - Не нужно делать реализацию методов интерфейса виртуальными:
type
ISomeInterfaceV1 = interface
['{C25F72B0-0BC9-470D-8F43-6F331473C83C}']
procedure A;
end;
TObj = class(TInterfacedObject, ISomeInterfaceV1)
protected
// Неправильно!
procedure A; virtual;
end;Делайте так:
TObj = class(TInterfacedObject, ISomeInterfaceV1)
protected
// Правильно
procedure A;
end; - Не помечайте интерфейсные параметры модификатором
const
:// Неправильно!
procedure DoSomething(const AArg: ISomething); safecall;
// Правильно
procedure DoSomething(AArg: ISomething); safecall; - Если в будущем вы будете расширять API DLL, то вам следует следовать правилам расширения и обратной совместимости;
- Прочие негласные правила.
- Check-список для проверки вашего API.
Заключение
Скачать пример DLL API можно тут. В архиве — группа из двух проектов (DLL и использующее её приложение). DLL реализует пример API с функциями-примерами. В папке SDK лежит SDK, состоящий из:
- Заголовочника
SampleDLLHeaders.pas
; - Документации в формате CHM и PDF (а также исходники в виде проекта Help&Manual);
- А также специфичного для Delphi файла поддержки
DelphiSupport.pas
.
Разработчики на других языках программирования могут использовать SampleDLLHeaders.pas
, а разработчики Delphi — SampleDLLHeaders.pas
и DelphiSupport.pas
.
Заголовочники представлены только в виде Delphi кода. Перевод на другие языки программирования оставлен в качестве домашнего задания. Автоматизировать перевод можно с помощью библиотеки типов (TLB), как описано здесь.
Модуль DelphiSupport.pas
может подключаться как в DLL, так и в приложения, её использующие. Он содержит:
- Обработку
safecall
(вместе с исправлением RSP-24652); - Базовый объект
TBaseObject
для реализации интерфейсов с поддержкой обработкиsafecall
и отладочными проверками; - Готовые классы: аллокатор
TMalloc
и врапперTNotify
; - Функцию
RegisterInitFunc
для регистрации инициализации модулей в DLL; - Функцию
LoadDLL
для правильной загрузки DLL.
В API DLL имеются функции-примеры:
- Возвращающая массив строк
GetData
; - Возвращающая динамическую память
GetMemory
; - Функция обратного вызова, устанавливаемая
NotifyMe
; - Тест ошибок/исключений
TryAbort
,TryAccessViolation
и т.д.
Вызывающее приложение показывает как «загрузку-использование-выгрузку», так и «загрузку, использование, выгрузку».