Или: не создавайте своих 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".
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.
Типы данных
Если вы хотите получить "универсальную 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
внутри и/или вы хотите передавать большие строки, то вам придётся явно передавать длину строки вместе со строкой (два параметра вместо одного).
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, то зачем вам 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;
Чтобы получить результат от такой функции Windows, её надо вызвать дважды. Сначала вы вызываете функцию, чтобы определить размер буфера, затем вы выделяете буфер, и только потом вызываете функцию снова. А если данные поменяются в это время? Функции снова не хватит места. Таким образом, чтобы надёжно получить полные данные, вам придётся писать цикл. Это же ужас. Не надо так делать.Параметры
lpBuffer
Указатель на буфер для получения имени пользователя. Если этот буфер недостаточно велик, чтобы вместить полное имя пользователя, функция завершается ошибкой.
pcbBuffer
На входе эта переменная указывает размер буфераlpBuffer
в символах. На выходе переменная получает количество символов, скопированных в буфер, включая завершающий нулевой символ.
ЕслиlpBuffer
слишком мал, функция завершается ошибкой иGetLastError
возвращаетERROR_INSUFFICIENT_BUFFER
. Тогда этот параметр будет содержать требуемый размер буфера, включая завершающий нулевой символ.
Строки
Со строками всё просто - используйтеBSTR
(который WideString
). Это мы подробно разобрали выше.Обратите внимание, что в некоторых случаях сложные структурированные данные (объекты) вы можете вернуть в виде JSON или аналогичного способа упаковки данных в строку. И если это ваш случай - вы также можете воспользоваться
BSTR
.Во всех прочих случаях вам нужно использовать один из трёх способов ниже.
Системный менеджер памяти
Выполнить правило "кто выделяет память, тот её и освобождает" можно следующим образом: попросить выделять и освобождать память третью сторону, про которую знают и вызываемый и вызывающий. К примеру, такой третьей стороной может быть любой системный менеджер памяти. Именно так и работаетBSTR
/WideString
. Вот несколько вариантов, которые вы можете использовать:
- Системная куча процесса:
HeapAlloc
иHeapFree
, вызываемые дляGetProcessHeap
;GlobalAlloc
иGlobalFree
;LocalAlloc
иLocalFree
.
- COM-подобные менеджеры памяти: И снова: все эти функции сегодня эквивалентны. Несколько вариантов функций появились по историческим причинам.
VirtualAlloc
иVirtualFree
.
Довольно большой список. Что же из этого лучше использовать?
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(const AFlags: DWORD;out AData: Pointer;out ADataSize: DWORD): BOOL; stdcall;ПараметрыAFlags [входной]Тип: DWORDНеобязательные флаги: ...AData [выходной]Тип: PointerУказатель на запрошенные данные размеромADataSize
. После завершения работы с данными вызывающий должен удалить их вызовомCoTaskMemFree
.ADataSize [выходной]Тип: DWORDРазмер данных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); DynDataFree(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 с помощью магии компилятора позволяет легко реализовать правильную обработку ошибок (см. ниже следующий раздел).
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:
Это - типичный способ обработки ошибок в классическом API Windows. При этом используются т.н. Win32 error codes. Это - обычное число типаRegisterClassEx
Регистрирует оконный класс для использования в функцияхCreateWindow
илиCreateWindowEx
.Синтаксисfunction RegisterClassEx(const AClass: 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
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
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
и т.д.
Вызывающее приложение показывает как "загрузку-использование-выгрузку", так и "загрузку, использование, выгрузку".
Чьорт, это может быть интересной темой, но не в двадцатый же раз >‿< (ладно, извиняюсь).
ОтветитьУдалитьС памятью не упомянут один интересный способ, который на самом-то деле очень часто используется (и является упрощённым аналогом IMalloc).
Если DLL решает какую-то одну задачу и является реентерабельной (не имеющей глобального состояния), в ней обычно есть понятие «главного» объекта в рамках этой задачи: в Lua это function lua_newstate(...): lua_State, в Newton — function NewtonCreate(...): PNewtonWorld.
Эти функции имеют параметр-коллбэк realloc: function(p: pointer; sz: SizeUint[; userParam: pointer]): pointer, который DLL обязуется использовать для работы с памятью: как минимум — возвращаемой пользователю, а в идеале — всей.
Да, это усложняет написание DLL, в Delphi и вовсе «идеальный» вариант невозможен для аллокаций, выполняемых под капотом — строк/массивов/классов.
GetMem(sz) — синоним realloc(nil, sz), а FreeMem(p) — синоним realloc(p, 0), так что достаточно одной функции.
Достоинства:
1) Как и с IMalloc, пользователь может спокойно освобождать память, полученную от DLL, через FreeMem (или что он там напишет в реализации realloc).
2) Вид коллбэка может быть заточен под специфику DLL. Например, Lua использует type lua_Alloc = function(userParam: pointer; p: pointer; oldSize, newSize: SizeUint): pointer, где при выделении нового блока (когда p = nil) oldSize кодирует, для какой разновидности объектов выделяется память. Эта информация может использоваться в кастомных аллокаторах и для отладки.
Для ошибок, к слову, можно использовать тот же подход с коллбэком. Со стороны пользователя MyDLL это будет выглядеть примерно так:
procedure CustomThrow(msg: PWideChar; code: uint32); stdcall;
begin
raise Exception.CreateFmt('%s (%u)', [widestring(msg), code]);
end;
state := MyDLL_CreateState(..., @CustomThrow);
MyDLL_DoSomething(state, ...); // при ошибке бросит исключение через CustomThrow
А со стороны разработчика:
procedure MyDLL_DoSomething(state: PMyDLL_State; ...); stdcall;
begin
try
state^.internal.DoSomething(state, ...);
except
on E: Exception do state^.customThrow(E.Message, 42);
end;
end;
(На самом деле сложнее, т. к. такая customThrow потенциально срезает «чужеродные» кадры стека, принадлежащие DLL, в обход нормальной финализации — так что придётся руками скопировать сообщение в локальный array[0 .. max] of widechar и вызывать customThrow уже в самом конце в спокойной обстановке, когда никаких активных блоков обработки исключений и авто-объектов в стеке гарантированно нет. Но это решаемо, и что главное, это проблема разработчика DLL, а не пользователя.)
Впрочем, с таким вот noreturn-коллбэком уже спорно. Я видел похожий приём разве что в libpng, которая далека от идеала проектирования DLL, ну и сам иногда добавляю в сторонние библиотеки, хотя обычно с другой целью — прерывать операцию, бросая что-то типа EAbort, изнутри библиотек, которые такой возможности изначально не предусматривали.
Коммент интересный, спасибо.
УдалитьТак всё таки нужно помечать интерфейсные параметры модификатором const или нет? Я видел обсуждение Вашей статьи на SQL.RU, там утверждается наоборот - const должен быть указан.
ОтветитьУдалитьТак где правда?
Хотите API с защитой "от дурака" - убирайте const. Хотите экономить на спичках - добавляйте const.
УдалитьПару дней бились с проблемой: было приложение на Delphi 7 и dll для этого приложения. Перевели на Delpi XE3. При работе под Win 10 все в порядке. А под Win XP и Win 7 приложение намертво зависает на FreeLibrary. Причем, разрядность ОС не важна. Перепробовал все что можно, выбросил большую часть кода (формочки и т.п.), опасаясь, что где-то там косяки с памятью. Не помогло. Заработало после удаления ShareMem :)
ОтветитьУдалитьДетально разбираться в причинах времени нет, так что просто поставил в памяти галочку: долой ShareMem.
Можно было использовать Threads Snapshot, чтобы по быстрому глянуть, на чём встала программа.
УдалитьСтатья шикарная! Спасибо Вам за такую понятную статью, в которой все последовательно, логично и разложено по полочкам. Узнал для себя много нового и многое стало понятно. Переписал много кода, после прочтения статьи. И жаль, что в последнее время Вы так мало пишите статей. Ваш блог, один из не многих, где можно найти толковую информацию по Делфи на русском языке, а такой детальности и глубину погружения в какую-то область, я не встречал нигде. Пожалуйста, пишите побольше статей.
ОтветитьУдалитьОгромная благодарность за статью! Очень интересно, доступно и позновательно.
ОтветитьУдалитьBSTR, CoTaskMemAlloc, возвращение интерфейсов из DLL, осталось унаследоваться от IDispatch и получится COM DLL.
ОтветитьУдалитьЭто к тому, что надо сразу COM делать? Или наоборот?
УдалитьВ COM там и отложенная выгрузка, и регистрация, и апартаменты, и контексты, и много ещё чего.
почему-то ссылка на архив не работает
ОтветитьУдалитьУ меня работает. Может, браузер не понимает, что от него хотят? Попробуйте открыть новое окно и вставить ссылку вручную: https://files.gunsmoker.ru/files/downloads/SampleDllAPI.zip
УдалитьТам была ссылка http://, я изменил на https:// - возможно, проблема была в этом? Типа, настройки безопасности браузера, не дают открыть небезопасную ссылку с безопасной страницы?
Удалитьв архиве ошибка в SampleDLLHeaders.pas в строке 61 стоит:
ОтветитьУдалитьprocedure NotifyMe(ANotifunction GetMemory(out ADataSize: DWORD): Pointer; safecall;fier: INotify); safecall;
по всей видимости правильно так:
ОтветитьУдалитьprocedure NotifyMe(ANotifier: INotify); safecall;
function GetMemory(out ADataSize: DWORD): Pointer; safecall;
Ага, спасибо, исправил.
Удалить