В этом посте я попробую собрать в одну кучу все типичные ошибки, особенности и подводные камни, с которыми вы можете встретится при разработке системы плагинов. Этот пост будет динамически пополняться. Некоторые из фактов мы уже рассматривали, так что здесь они будут приведены c целью собрать всё в кучу. Ну а про некоторые особенности я скажу в первый раз.
В общем, эта статья может служить в качестве своеобразного "check-списка". Вы можете сравнить свой код с этим списком и выяснить, насколько он хорош, и не содержит ли он типичных ошибок.
См. также Разработка API (контракта) для своей DLL.
Ошибка: отсутствие выделенных функций инициализации и финализации
Первая ошибка, которую вы можете совершить как разработчик системы плагинов: не предусмотреть отдельные функции инициализации и финализации каждого плагина, а использовать вместо них уведомленияDLL_PROCESS_ATTACH
и DLL_PROCESS_DETACH
calback-функции DllMain
. DllMain
- это специальная функция в DLL, которая автоматически вызывается системой в ответ на некоторые события. И среди них есть DLL_PROCESS_ATTACH
и DLL_PROCESS_DETACH
- это уведомления о загрузке и выгрузке вашей DLL. Если вы используете Delphi, то секции initialization
и finalization
модулей в DLL выполняются именно в ответ на сообщения DLL_PROCESS_ATTACH
и DLL_PROCESS_DETACH
, которые система отправляет в функцию DllMain
вашей DLL. Конечно, вы не видите этот процесс, он происходит под капотом RTL (библиотека поддержки языка Delphi). Вы просто видите, что при загрузке DLL инициализируются все модули, а при её выгрузке они финализируются.В чём здесь проблема?
DllMain
- это не обычная callback-функция. Она вызывается в весьма особый момент времени. Есть крайне мало действий, которые вы можете делать в DllMain
гарантировано безопасно. К примеру, загрузка библиотек, запуск потоков, любая синхронизация потоков, вызовы COM, даже вызовы в другие библиотеки (кроме kernel32
) - всё это, выполненное из DllMain
, может привести к блокировке (зависанию). Если учесть, что программист обычно не задумывается о допустимости действий в секциях initialization
и finalization
своих модулей и пишет туда абсолютно любой код, то надо заметить, что подобное поведение (автоматическая инициализация модулей из DllMain
) - не самое удачное дизайнерское решение.Вот почему в вашей системе плагинов обязательно должны быть функции
Init
и Done
. Каждый, кто загружает плагин, обязан тут же импортировать функцию Init
и вызвать её. Соответственно, он также должен вызвать Done
непосредственно перед выгрузкой плагина. Сам же плагин может использовать функции Init
и Done
как замену секций initialization
и finalization
своих модулей. Ошибка: использование LoadLibrary
для загрузки DLL плагинов
По умолчанию большинство программистов используют простой вызов LoadLibrary для загрузки DLL плагинов. У этого метода есть два недостатка:
- Загружаемая DLL может поменять (глобальные) флаги сопроцессора, что приведёт к проблемам в вашем собственном коде
- Загружаемая DLL может быть статически связана с другой DLL (например, клиентским интерфейсом БД или библиотекой обработки изображений). Если вы складываете DLL плагинов в отдельную папку (например, подпапку
\Plugins
в папке вашей программы или%APPDATA%\MyApp\Plugins
), то системный загрузчик не сможет найти необходимые DLL.
Ошибка: использование древних техник управления памятью и обработки ошибок
В любом API есть два весьма важных момента: как вы будете передавать данные произвольного (динамического) размера и как вы будете сообщать об ошибках выполнения функции. Типичная функция WinAPI содержит в себе такую логику: "вызывающий должен вызвать функцию сlpData
= nil
и cbSize
= 0
, функция вернёт ошибку ERROR_INSUFFICIENT_BUFFER
, а в cbSize
будет возвращено количество памяти в байтах, необходимой для хранения всех данных. Вызывающий может выделить необходимое количество памяти и вызвать функцию снова, передав указатель на блок данных в lpData
, а размер этого блока - в cbSize
". Подобный подход мало того, что сложен сам по себе (два вызова функции вместо одного), так ещё представьте себе, как он будет работать для часто меняющихся данных: что будет, если данные увеличатся в размере как раз между вашими двумя вызовами? Тогда второй вызов снова вернёт ошибку ERROR_INSUFFICIENT_BUFFER
, и вам снова нужно будет выделить больше памяти и повторить вызов. Т.е. чтобы надёжно вызвать функцию, вам придётся писать цикл.Почему большинство функций WinAPI используют такой страшно сложный способ? Ответ: история. В те времена, когда разрабатывались эти функции, во-первых, не было современных де-факто стандартов, во-вторых, разработчики приносили в жертву удобство ради микрооптимизаций.
Аналогично, типичная функция WinAPI сообщает, что она "вернёт True в случае успеха и False при ошибке. Причину ошибки можно получить вызовом
GetLastError
". А некоторые функции сообщают даже "в случае успеха функция вернёт размер данных или 0, если данных нет. В случае ошибки функция вернёт 0. Чтобы определить точную причину, вы должны вызвать GetLastError
, которая вернёт ERROR_SUCCESS
для успешного вызова или же код ошибки". Кошмар! И снова мы видим два вызова функций вместо одного, не говоря уже о сложности расширения (использования своих собственных кодов ошибок) и невозможности передачи дополнительных данных.Соответственно, очень много людей, когда им нужно сделать свой API, поступают вот так: они смотрят как сделано в системе и делают точно так же. "Ну раз так делает сама Microsoft, то и мы так сделаем, потому что, наверное, это правильно, так и надо делать". Но что они при этом не осознают: то, что они видят в WinAPI сегодня, было создано очень давно. Куча функций ведут свою историю ещё из 16-битного кода. Эти функции были спроектированы под требования, которые сегодня просто не существуют.
Нет никакой необходимости использовать древние и неудобные подходы. Используйте современные средства.
Вот, что вы можете использовать для передачи данных переменного размера, не жертвуя удобством вызова (в порядке убывания предпочтительности):
- [Частный случай, только для строк]
BSTR
/WideString
. - Интерфейс со свойствами типа
lpData
иcbSize
. - DLL может экспортировать функцию освобождения памяти.
- Можно использовать стандартные менеджеры памяти системы (чаще всего -
HeapAlloc
/HeapFree
).
А вот список для обработки ошибок (в порядке убывания предпочтительности):
- Стиль COM:
HRESULT
+ICreateErrorInfo
. Delphi может дополнительно воспользоваться преимуществами "волшебной" модели вызоваsafecall
. - Функции возвращают
HRESULT
. - Функции возвращают признак успех/провал, код ошибки получается из
GetLastError
. - Аналогично предыдущему пункту, своя функция управления кодами ошибок.
Более того, нет необходимости использовать функции, если можно использовать интерфейсы. У интерфейсов сплошные плюсы:
- Автоматическое управление памятью - нет проблем с выделением/освобождением.
- Delphi даёт вам в руки
safecall
и виртуальные методы для настройки "магии" компилятора. - Упрощается версионность, т.к. есть строгая идентификация по GUID.
- Группировка данных с методами обработки.
- Не страдает быстродействие.
Ошибка: использование языкозависимых типов данных или иных конструкций языка
Разумеется, если вы создаёте API, которым смогут воспользоваться программы, написанные на других языках программирования, вы не можете использовать конструкции, которые существуют только в вашем языке программирования. Например,string
(а также AnsiString
, UnicodeString
, ShortString
, String[
число]
), array of
(динамические и открытые массивы), TObject
(т.е. любые объекты), TForm
(и уж тем более компоненты) и т.п. Если вы используете тип данных или языковую конструкцию, не имеющую аналога в другом языке, то код на этом самом другом языке просто не сможет воспользоваться вашим API. Ему придётся эмулировать языковые конструкции вашего языка.
Итак, что можно использовать в API:
- целочисленные типы (
Integer
,Cardinal
,Int64
,UInt64
,NativeInt
,NativeUInt
,Byte
,Word
и т.п. - за исключениемCurrency
); - вещественные типы (
Single
иDouble
- за исключениемExtended
,Real
,Real48
иComp
); - статические массивы (
array[
число..
число] of
) из допустимых типов; - множества, перечислимые и subrange-типы (с некоторыми оговорками - см. ниже; предпочтительнее заменять их на целочисленные типы);
- символьные типы (
AnsiChar
иWideChar
, но неChar
); - строки (только в виде
WideString
; строки в видеPWideChar
- допустимы, но не рекомендуются;PAnsiChar
допустим только для ASCII-строк;PChar
строго запрещён; ANSI-строки запрещены); - логический тип (
BOOL
, но неBoolean
; допустимоByteBool
,WordBool
иLongBool
, но не рекомендуется); - интерфейсы (interface), в методах которых используются допустимые типы;
- записи (record) из допустимых типов;
- указатели на данные допустимого типа;
- нетипизированные указатели;
- типы данных из
Winapi.Windows.pas
(или аналогичного модуля для не-Windows платформы); - типы данных из других системных заголовочников (они находятся в папке \Source\RTL\Win вашей Delphi; либо OSX, iOS и т.п. - для других платформ).
Ошибка: использование в API разделяемого менеджера памяти и/или пакетов
Аналогично предыдущему пункту: разделяемый менеджер памяти (любой: ShareMem, FastShareMem, SimpleShareMem и т.д.) - это языкозависимое средство. Он не существует в других языках. Вам никогда не следует использовать его в API (вы можете использовать его для обмена данными вне API, хотя это не рекомендуется).Это же относится и к пакетам времени выполнения (.bpl packages). Эта концепция существует только в Delphi (и C++ Builder). Вы можете использовать пакеты (как мы делали это здесь или здесь), но только вне API. К примеру, вы не можете использовать менеджер памяти rtl140.bpl для управления памятью в рамках API.
Что вообще такое разделяемый менеджер памяти? Для чего он нужен? Разделяемый менеджер памяти - это, в некотором смысле, "хак". Это быстрый и грязный способ решить проблему обмена динамическими данными между модулями - именно о этом говорит комментарий в начале нового DLL проекта (на примере
string
). Это - средство обратной совместимости. Никогда его использование не было верным в новом коде. Если вам был нужен обмен объектами (включая исключения), строками или иными конструкциями языка - вы должны были использовать BPL-пакеты, а вовсе не DLL. Если же вы использовали DLL, то вы не должны были использовать Delphi-типы и, соответственно, разделяемый менеджер памяти.Если вы следуете советам выше, то вы уже умеете без проблем передавать данные переменного размера. Кроме того, вы не используете Delphi-типы. Поэтому нет никакой необходимости использовать разделяемый менеджер памяти (ни отдельный, ни в виде использования пакетов).
Ошибка: отсутствие try/except вокруг кода каждой экспортируемой функции
Из пунктов выше следует, что вы не можете передавать любые объекты между модулями. Исключение - это тоже объект (в Delphi). Сложив два плюс два, мы получаем, что вы не можете возбуждать исключение в одном модуле, а ловить - в другом. Другой модуль понятия не имеет, как работать с объектом, созданном на другом языке программирования.Конечно это означает, что вы должны продумать свой способ сообщения об ошибках. Мы уже обсуждали это выше. Здесь же я говорю только про частный случай. Раз исключение не может (в смысле - не должно) покидать модуль, то вы должны это реализовать: т.е. поставить блок try/except с перехватом всех исключений вокруг кода каждой экспортируемой функции.
Замечания:
- Функция может экспортироваться как явно (
exports
имя-функции), так и неявно (например, callback-функция или иная функция, на которую возвращается указатель в вызывающий код). Оба варианта вам нужно обёртывать в блоки try/except. - "Перехват всех исключений", конечно же, не означает, что вы ставите пустой блок try/except. Вы не должны глушить исключения. Вы должны их поймать (да, все поймать) и преобразовать (а не глушить) - преобразовать во что-то, что предусмотрено вашим протоколом (код ошибки,
HRESULT
и т.п.).
Заметьте, что вы автоматически покрываете этот пункт, если используете рекомендованный выше способ оформления методов: safecall-интерфейсы. Модель вызова safecall гарантирует вам, что каждый ваш метод будет заключён "магией" компилятора в (скрытый) блок try-except, ни одно исключение не выскочит из вашего модуля наружу.
Ошибка: использование ANSI-кодировок в API
Поддержка Unicode появилась в API Windows в 96-м году (в рамках Windows NT 4), а в 2000-м году пришла и в сегмент клиентских ОС (в виде Windows 2000). Хотя Windows 95 и содержали Unicode-функции, но полной его поддержки не было. На рынке же мобильных ОС Unicode был всегда - начиная с Windows CE (96 год), через PocketPC, Windows Mobile и до Windows Phone - все эти системы поддерживали исключительно только Unicode, но не ANSI.Т.е. уже более 13 лет Unicode является основным во всех вариантах Windows. Более 13 лет ANSI функции в API Windows являются не более, чем заглушками, которые не делают ничего, а только конвертируют кодировку строк и вызывают Unicode-варианты самих себя.
Поддержка Unicode в Delphi появилась в Delphi 3 - как часть поддержки COM (97 год). Хотя вплоть до 2008 года (выход Delphi 2009) вся библиотека поддержки языка (RTL) и библиотека компонентов (VCL) работали на ANSI-строках.
Тем не менее, несмотря на абсолютно полную возможность использовать Unicode при построении своего собственного API с 97 года (более 16 лет), большинство Delphi разработчиков не задумываясь использовало "знакомые типы" - т.е. в лучшем случае это был
PChar
(равный PAnsiChar
на системах того времени), в худшем - string
с разделяемым менеджером памяти. Конечно, те, кто посознательнее, использовали PAnsiChar
, а начиная с 2000 года могли использовать и PWideChar
. Но WideString
же практически не использовался - несмотря на его неоспоримые достоинства (нет проблем с межмодульным обменом, авто-конвертация в string
и обратно, поддержку Unicode, встроенный указатель длины). Видимо потому что PWideChar
- достаточно простой способ передачи строк вызываемому, а возврат строк от вызываемого вызывающему требуется значительно реже.Суммируя сказанное: всегда используйте Unicode в вашем API для строк - даже если вы пишете на Delphi 7 и внутри используете ANSI-строки. Не имеет значения: в 2013 году API обязан быть Unicode-ным.
Хорошо, что насчёт ANSI-переходников к Unicode-функциям: нужны ли они? Нет. Вспомните, зачем были нужны -A варианты функций в Windows API: это было средство обратной совместимости для старого кода, не знакомого с Unicode. Эта эра закончилась в 2000 году с выходом Windows 2000. Нет никакой необходимости создавать средства обратной совместимости к чему-то, что никогда ранее не использовалось. Никто не использовал раньше ваш API с ANSI-функциями, поэтому нет необходимости добавлять в API ANSI-заглушки (переходники).
Заметьте, что вы уже покрыли этот пункт, если воспользовались рекомендациями из предыдущих пунктов. К этому моменту вы должны использовать для строк
WideString
(он же - BSTR
) или, в крайнем случае, PWideChar
. Вы не должны использовать PChar
и PAnsiChar
.Подводный камень: перечислимые типы
Перечислимый тип - это удобный способ (вместо использования чисел) для декларации case-типов данных. В чём здесь проблема? Посмотрите на такой код:type TTest = (T1, T2, T3); var T: TTest;Вопрос: какой размер в байтах имеет переменная
T
? Это - важно, поскольку от размера зависит положение полей в записях, передача аргументов в функции и многое другое.Ответ заключается в том, что вы этого не знаете в общем случае. Это зависит от компилятора и его настроек. По умолчанию в Delphi это будет 1 байт, в другом компиляторе это может быть 4 байта.
Вот ещё вопрос: раз в Delphi этот тип занимает 1 байт, то он может вместить 255 значений. Но что, если в вашем перечислимом типе будет больше 255 значений?
Ответ: тогда переменная будет занимать 2 байта.
Чувствуете, к чему всё идёт? Предположим в V1 вы использовали 50 значений и поля этого типа занимали у вас 1 байт. В V2 вы расширили возможные значения до 300 - и поля стали занимать 2 байта. Это не имело бы значения, если бы использовали этот тип только внутри своей программы. Но поскольку вы обмениваетесь им с другим кодом, то изменение размера данных станет для другого кода полной неожиданностью. Перезапись (порча данных) и Access Violation - вот результат.
Примечание: на самом деле, вам необязательно иметь 300 элементов. Достаточно, чтобы в типе был 0-й и 300-й элементы:
type TTest = (T1, T2, T3, T4 = 300); var T: TTest; begin WriteLn(SizeOf(T)); // покажет 2
Решение этой проблемы двояко:
- Во-первых, можно поместить в начало каждого заголовочного файла директиву компилятора
{$Z4}
(или{$MINENUMSIZE 4}
) - это заставит компилятор делать все перечислимые типы размером 4 байта, даже если вам столько не нужно. - Во-вторых, вы можете использовать
LongWord
(илиCardinal
) и набор числовых констант (вида T1 = 0).
Подводный камень: записи
Аналогичные проблемы справедливы для записей:type TTestRec = record A: Byte; B: Int64; end;Чему равен размер этой записи? 9? 10? 12? 16? Неиспользуемое расстояние между полями (заполнитель) также зависит от компилятора и его настроек.
В целом, я бы рекомендовал использовать интерфейсы вместо записей по мере возможностей. Если же вам нужны именно записи, то либо вставьте в начало каждого заголовочника директиву
{$A4}
({$ALIGN 4}
), либо используйте ключевое слово packed
для записей. Второе может быть предпочтительнее - на случай, если правила выравнивания в Delphi окажутся отличными от правил выравнивания в другом языке (т.е. на случай проблем, аналогичным этому багу).Подводный камень: множества
И с множествами актуален тот же вопрос: сколько байт занимает множество? Здесь, правда, всё несколько сложнее, т.к. множество может занимать до 32-х байт и нет никакой директивы компилятора по контролю размера множеств.В целом, множество является синтаксическим ограничителем на флаги. Поэтому вместо множеств вы можете использовать целочисленный тип (опять же:
LongWord
или Cardinal
) и набор числовых констант, комбинируя их через OR
для включения в "множество" и проверяя их вхождение в "множество" через AND
.Ошибка: отсутствие user-параметров в callback-функциях
Callback-функция (англ. call — вызов, англ. back — обратный) или функция обратного вызова в программировании — передача исполняемого кода в качестве одного из параметров другого кода. Например, если вы хотите найти все окна на рабочем столе, вы можете использовать функцию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
. Эти параметры никак не используются самими функциями и просто передаются напрямую в callback-функции без изменений. Именно поэтому мы можем использовать эти параметры, чтобы передать произвольные данные.Если вы не предусмотрите user-параметры в своём API, то вызывающий код не сможет ассоциировать callback-функцию с данными. Мы рассмотрим этот вопрос более подробно в следующей статье.
Подводный камень: смешивание ручного и автоматического управления временем жизни
В целом, вы должны стараться использовать автоматическое управление временем жизни. С ним у вас меньше шансов напортачить, поскольку за жизнью объектов следит машина, а человеку остаётся меньше деталей. Но в любом случае у вас будут места, где необходимо ручное управление временем жизни. Вот на стыке двух механизмов управления и возникают проблемы.Посмотрите на такой код:
type ISomething = interface procedure DoSomething; end; TSomething = class(TComponent, ISomething) procedure DoSomething; end; var Comp: TSomething; function GetSomething: ISomething; begin Result := Comp; end; begin Comp := TSomething.Create(nil); try GetSomething.DoSomething; finally FreeAndNil(Comp); end; end;Как известно,
TComponent
не использует автоматический подсчёт ссылок и управляет временем жизни вручную. Проблема в вышеуказанном коде заключается в том, что в строке GetSomething.DoSomething
создаётся временная (скрытая) переменная интерфейсного типа (для хранения результата вызова GetSomething
), которая очищается в последней строке end
- уже после того, как объект был освобождён. Само собой, вызов очистки для интерфейсной ссылки от TComponent
в любом случае не приводит к вызову деструктора (TComponent
же использует ручное управление и не реагирует на очистку ссылок), но для очистки всё же необходимо вызвать метод _Release
TComponent
- т.е. метод уже удалённого объекта. Что и приведёт к Access Violation.Примечание: к Access Violation эта ошибка приводит не всегда, что делает её особо опасной.
Мы уже встречались с этим принципом в нашей статье, с примерно таким кодом:
begin Lib := LoadLibrary(...); Win32Check(Lib <> 0); try Func := GetProcAddress(Lib, ...); Intf := Func(...); // ... какие-то действия с Intf Intf := nil; finally FreeLibrary(Lib); end; end;Между
LoadLibrary
и FreeLibrary
могут находится временные переменные, которые хранят ссылки на интерфейсы из DLL. Поэтому, даже если мы очистим явно все видимые ссылки перед выгрузкой DLL, скрытые переменные будут освобождаться уже после выгрузки DLL и, следовательно, вызывать уже выгруженный код (привет, Access Violation).Конечно, мы (как разработчики) не обладаем орлиным зрением, чтобы найти все места, где компилятор использовал скрытые переменные и ввести явные переменные вместо скрытых, явно освобождая их. Напомню, что решение будет заключаться в использовании того факта, что все временные скрытые переменные освобождаются в момент выхода из процедуры. Поэтому мы должны явно разграничить код, работающий с ручным и автоматическим управлением:
procedure DoDangerousStuff(Comp: TComp); begin // ... какие-то действия с Comp, включающие использование типов с автоматическим управлением end; begin Comp := TSomething.Create(nil); try DoDangerousStuff(Comp); finally FreeAndNil(Comp); end; end;
procedure DoDangerousStuff(Lib: HMODULE); begin // ... какие-то действия с Lib, включающие использование типов с автоматическим управлением end; begin Lib := LoadLibrary(...); Win32Check(Lib <> 0); try DoDangerousStuff(Lib); finally FreeLibrary(Lib); end; end;
Подводный камень: идентификация интерфейсов
Интерфейсы отличаются от остальных типов данных тем, что имеют два уровня идентификации. На уровне языка программирования интерфейс идентифицируется по имени идентификатора (такому какIUnknown
, IApplication
и т.п.) и не отличается в этом плане от остальных типов данных в Delphi. Два интерфейса с одинаковым описанием, но разным идентификатором типа компилятор считает разными типами.С другой стороны, интерфейсы также могут идентифицироваться не только на уровне языка программирования, но и в run-time (на уровне машинного кода) по мета-информации: GUID интерфейса (IID). Два абсолютно разных описания, но с одинаковым IID машинный код сочтёт идентичными (со всеми вылезающими отсюда проблемами).
Мы уже видели этот принцип в предыдущей части, где говорили про то, как смена названия типа интерфейса ни на что не влияет - важен GUID (IID), а не имя.
Подводный камень: неизменность интерфейсов
Этот момент мы тоже уже не раз обсуждали: как только вы опубликовали интерфейс ("опубликовали" = публично выпустили версию системы плагинов с описанием интерфейса), вы уже не можете его менять (IID и структуру, но можете - имя типа): потому что интерфейс уже будет использовать другой код. Изменив объявление интерфейса, вы покрэшите другой (чужой) код.Вместо изменения интерфейса вам нужно создать новый интерфейс с новым GUID. Вы можете создать автономный интерфейс (чаще всего - предпочтительно) или наследовать его от старого (допустимо).
Более подробно мы обсуждали это в прошлый раз.
Подводный камень: особенности расширения интерфейсов
Подводный камень тут вот в чём, посмотрите на такой код:type IColorInfo = interface {ABC} function GetBackgroundColor: TColorRef; safecall; ... end; IGraphicImage = interface {XYZ} ... function GetColorInfo: IColorInfo; safecall; end;Предположим, что вы хотите добавить новый метод в интерфейс
IColorInfo
:
type IColorInfo = interface {DEF} // <- новый GUID function GetBackgroundColor: TColorRef; safecall; ... procedure AdjustColor(const clrOld, clrNew: TColorRef); safecall; // <- новый метод end; IGraphicImage = interface {XYZ} ... function GetColorInfo: IColorInfo; safecall; end;Вы изменили интерфейс, но вы также поменяли и IID, так что всё должно быть в порядке, да?
Вообще-то - нет.
Интерфейс
IGraphicImage
зависит от интерфейса IColorInfo
. Когда вы изменили интерфейс IColorInfo
, вы неявно изменили и метод IGraphicImage.GetColorInfo
- поскольку его возвращаемое значение стало теперь другим: интерфейсом IColorInfo
версии v2.0.Посмотрите на такой код, написанный с заголовочными файлами версии v2.0:
procedure AdjustGraphicColorInfo(pgi: IGraphicImage; const clrOld, clrNew: TColorRef); var pci: IColorInfo; begin pci := pgi.GetColorCount(pci); pci.AdjustColor(clrOld, clrNew); end;Если этот код запускается на версии v1.0, то вызов
IGraphicImage.GetColorCount
вернёт IColorInfo
версии v1.0, а у этой версии нет метода IColorInfo.AdjustColor
. Но вы всё равно его вызываете. Результат: проходим до конца таблицы методов интерфейса и вызываем мусор, который лежит за ней.Быстрое решение проблемы - изменение IID для
IGraphicImage
, чтобы учесть изменения в IColorInfo
:
type IGraphicImage = interface {UVW} ... function GetColorInfo: IColorInfo; safecall; end;
Этот путь весьма трудоёмок, поскольку вам нужно отслеживать все ссылки на изменяемый интерфейс. Более того, вы не можете просто изменить GUID - вы обязаны создать второй интерфейс
IGraphicImage
с новым GUID и управлять двумя интерфейсами (даже хотя они идентичны с точностью до возвращаемого значения). При нескольких подобных изменениях и большом дереве использования ситуация очень быстро выйдет из под контроля с бесконечным клонированием интерфейсов на каждый чих. Мы посмотрим на правильное решение в следующем пункте.
Подводный камень: возвращаемое значение функций
Функции или методы, возвращающие интерфейс (как в предыдущем пункте), представляют собой проблему для расширения. Конечно, в начале это удобное решение: вы можете вызывать функции "как обычно" и даже сцеплять их в цепочки видаControl.GetPicture.GetImage.GetColorInfo.GetBackgroundColor
. Однако такое положение дел будет существовать только в самой первой версии системы. Как только вы начнёте развивать систему, у вас начнут появляться новые интерфейсы. В не столь отдалённом будущем у вас будет куча продвинутых интерфейсов, а базовые интерфейсы, которые были в программе изначально, в момент её рождения, будут реализовывать лишь тривиально-неинтересные функции. Итого, очень часто вызывающему коду будут нужны новые интерфейсы, а не оригинальные. Что это значит? Это значит, что коду нужно вызвать оригинальную функцию, получить оригинальный интерфейс, затем запросить у него новый (через Supports
/QueryInterface
) и лишь затем использовать новый интерфейс. Получается не так удобно, даже скорее неудобно: имеем тройной вызов (оригинальный + конвертация + нужный).Давайте посмотрим ещё раз на предыдущий пункт: модификация одного интерфейса приводит к необходимости создания копий всех интерфейсов, использующих его в качестве возвращаемого значения - даже, если сами они не меняются.
Лучшее решение для обоих случаев заключается в том, чтобы вызывающий код указывал бы вызываемой функции, какой интерфейс его интересует: новый или старый. Делается это, конечно же, указанием IID:
type IGraphicImage = interface {XYZ} ... procedure GetColorInfo(const AIID: TGUID; out AColorInfo); safecall; end;Обратите внимание, что теперь вы не можете использовать результат функции, т.к. результат не имеет типа (конечно он его не имеет - ведь мы должны возвращать интерфейсы разных типов), вот почему мы используем нетипизированный выходной параметр.
Тогда, вы можете писать такой код:
var Image: IGraphicImage; ColorInfo: IColorInfoV1; begin ... Image.GetColorInfo(IColorInfoV1, ColorInfo); Color := ColorInfo.GetBackgroundColor; ...
var Image: IGraphicImage; ColorInfo: IColorInfoV2; begin ... Image.GetColorInfo(IColorInfoV2, ColorInfo); // выбросит исключение "no interface", если запустить на V1 ColorInfo.AdjustColor(OldColor, NewColor); ...Вам нет нужды работать с IID напрямую: Delphi автоматически подставит GUID вместо имени идентификатора.
Ошибка: возврат сложных типов через Result
Вообще, в целом это хорошее правило: если вам нужно вернуть что-то сложное, отличное от целочисленного типа (включая автоуправляемые типы: интерфейсы и WideString
) - всегда используйте out-параметр вместо результата функции. Тогда вы избежите багов в Delphi вроде такого, такого и вроде бы есть аналогичная проблема с вещественными числами, но я могу ошибаться (по-моему, Delphi и MS C++ расходятся во мнении через стек сопроцессора или процессора возвращать вещественный результат функции, но в этом я не уверен на 100%, поскольку ссылку на баг не нашёл).Проблема во всех вышеперечисленных случаях заключается в том, что Delphi и C++ расходятся в интерпретации модели вызова касаемо возврата сложных типов. Из документации Delphi следует, что она трактует такой код:
function Test: IInterface; stdcall;как:
procedure Test(var Result: IInterface); stdcall;В то время как C++ буквально следует синтаксису и возвращает интерфейс напрямую (в
EAX
для x86-32).Таким образом, вместо объявления функций вида (например):
function Test1: IInterface; stdcall; function Test2: WideString; stdcall; function Test3: TSomeRecord; stdcall;Всегда используйте либо (
out
можно заменить на var
для улучшения производительности):
procedure Test1(out Rslt: IInterface); stdcall; procedure Test2(out Rslt: WideString); stdcall; procedure Test3(out Rslt: TSomeRecord); stdcall;Либо:
function Test1: IInterface; safecall; function Test2: WideString; safecall; function Test3: TSomeRecord; safecall;Последнее допустимо по той простой причине, что подобный код эквивалентен:
function Test1(out Rslt: IInterface): HRESULT; stdcall; function Test2(out Rslt: WideString): HRESULT; stdcall; function Test3(out Rslt: TSomeRecord): HRESULT; stdcall;
Обратите внимание, что вы автоматически избавляетесь от этой проблемы в нашем случае, поскольку мы используем модель вызова
safecall
. Тем не менее, сказанное в предыдущем пункте всё ещё остаётся в силе: с точки зрения версионности интерфейсы лучше не возвращать безусловно (как результат функции), а использовать конструкции вида:
procedure Test1(const IID: TGUID; out Rslt); safecall;
Ошибка: отсутствие IID у интерфейсов API
Все интерфейсы из вашего API безусловно должны снабжаться GUID (идентификатором интерфейса - IID). У вас может быть соблазн пропустить объявление IID для интерфейсов, которые явно возвращаются функциями, а не запрашиваются по IID. Но, как мы увидели выше, вы должны проектировать API так, чтобы у вас не было явно возвращаемых интерфейсов - потому что это крайне затрудняет дальнейшее расширение системы.Ошибка: отсутствие объявления реализуемого интерфейса в классе
Как вы уже знаете, реализовать интерфейс на объекте можно двумя способами:- Автоматически. Вы просто объявляете
TMyClass = class(базовый-класс, список-интерфейсов)
. Как только вы задеклалировали поддержку интерфейса, ничего больше делать не надо. - Вручную. Вы должны переопределить виртуальный метод класса
QueryInterface
, анализировать параметры и возвращать/конструировать интерфейс.
Можно подумать, что с автоматическим способом у нас точно не будет проблем, но посмотрите на такой код (ключевые моменты отмечены в комментариях):
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; // необходим, т.к. объект реализует ISomeInterfaceV1. Иначе - ошибка компиляции procedure B; end; procedure TForm1.Button1Click(Sender: TObject); var SI1: ISomeInterfaceV1; SI2: ISomeInterfaceV2; begin SI2 := TObj.Create; Supports(SI2, ISomeInterfaceV1, SI1); Assert(Assigned(SI1)); // утверждение сработает, т.к. SI1 = nil (вызов Supports выше вернул False) SI1.A; end;Получается, что даже хотя объект реализует интерфейс, но он не сообщает "наружу" о том, что он его реализует.
Т.е. если два интерфейса связаны наследованием, то включение наследника в список интерфейсов, реализуемых объектом, ещё не означает включение в этот список предка этого интерфейса. Иными словами, чтобы объект реализовывал бы интерфейс по автоматической схеме, необходимо, чтобы этот интерфейс хотя бы раз появился в строчке "список-интерфейсов" для
class(базовый-класс, список-интерфейсов)
(не обязательно в этом классе, можно и в его предке, но он должен быть хоть где-то). Появление там наследника интерфейса не достаточно.По этой причине вы можете увидеть, что я часто в коде системы плагинов перечисляю все интерфейсы, которые реализует объект (в том числе и по соображениям наглядности кода).
Заметьте, что код
type ISomeInterface = interface ['{A80A78ED-5836-49C4-B6C2-11F531103FE7}'] procedure A; end; IAnotherInterface = interface ['{EBDD52A1-489B-4564-998E-09FCCF923F48}'] procedure B; end; TObj1 = class(TInterfacedObject, ISomeInterface) protected procedure A; end; TObj2 = class(TObj1, IAnotherInterface ) protected procedure B; end; procedure TForm1.Button1Click(Sender: TObject); var SI1: ISomeInterface; SI2: IAnotherInterface; begin SI2 := TObj2.Create; Supports(SI2, ISomeInterface, SI1); Assert(Assigned(SI1)); SI1.A; end;и
type ISomeInterfaceV1 = interface ['{A80A78ED-5836-49C4-B6C2-11F531103FE7}'] procedure A; end; ISomeInterfaceV2 = interface(ISomeInterfaceV1) ['{EBDD52A1-489B-4564-998E-09FCCF923F48}'] procedure B; end; TObj1 = class(TInterfacedObject, ISomeInterfaceV1) protected procedure A; end; TObj2 = class(TObj1, ISomeInterfaceV2) protected procedure B; end; procedure TForm1.Button1Click(Sender: TObject); var SI1: ISomeInterfaceV1; SI2: ISomeInterfaceV2; begin SI2 := TObj2.Create; Supports(SI2, ISomeInterfaceV1, SI1); Assert(Assigned(SI1)); SI1.A; end;не являются проблемой.
Подводный камень: реализация и полиморфизм интерфейсов
При наследовании на базе классов (объектов) полиморфное поведение достигается виртуальными методами. Но когда вы используете интерфейсы, все методы интерфейса - уже виртуальные (по определению). Поэтому нет никакой нужды использовать виртуальные методы класса для реализации интерфейсов (если только виртуальность метода не нужна вам для других целей - например, при наследовании функциональности).Например:
type ISomeInterfaceV1 = interface ['{C25F72B0-0BC9-470D-8F43-6F331473C83C}'] procedure A; procedure B; end; TObj1 = class(TInterfacedObject, ISomeInterfaceV1) protected procedure A; procedure B; end; TObj2 = class(TObj1, ISomeInterfaceV1) protected procedure B; end; procedure TForm1.Button1Click(Sender: TObject); var SI: ISomeInterfaceV1; begin SI := TObj2.Create; SI.A; // вызывает TObj1.A SI.B; // вызывает TObj2.B end;Указание
ISomeInterfaceV1
для TObj2
означает, что метод TObj2.B
будет реализовывать ISomeInterfaceV1.B
. Ключевой момент здесь - именно указание интерфейса у объекта-наследника. Обратите внимание, что:
- Метод
B
не обязан быть виртуальным - Интерфейс
ISomeInterfaceV1
дляTObj2
собирается "по кускам": методB
берётся уTObj2
, но методA
берётся уTObj1
.
Впрочем, как уже было сказано, иногда вы можете захотеть использовать и такой код:
type ISomeInterfaceV1 = interface ['{C25F72B0-0BC9-470D-8F43-6F331473C83C}'] procedure A; procedure B; end; TObj1 = class(TInterfacedObject, ISomeInterfaceV1) protected procedure A; virtual; procedure B; virtual; end; TObj2 = class(TObj1) protected procedure B; override; end; procedure TForm1.Button1Click(Sender: TObject); var SI: ISomeInterfaceV1; begin SI := TObj2.Create; SI.A; // вызывает TObj1.A SI.B; // вызывает TObj2.B end;С точки зрения интерфейсов этот вариант кода будет работать эквивалентно предыдущему, но с точки зрения классов (объектов) результат будет немного различен. Под конец этой главы мне крайне лениво разжёвывать это отличие, так что я оставляю его вам в качестве домашнего задания. Тем более, что это вопрос на виртуальные методы в объектах и не имеет отношения к теме плагинов или интерфейсов.
Замечу только, что из-за этого отличия крайне рекомендуется следовать такому правилу: если ваш объект реализует интерфейс и это его единственная задача (это - важно), то ваш (и сторонний) код не должен использовать объект этого класса - он должен использовать только интерфейс.
Ошибка: неочевидная особенность учёта ссылок интерфейсов (конструктор в const-параметре)
Предположим у вас есть функция/метод с параметром типа интерфейс, объявленном как const:procedure DoSomething(const AArg: ISomething);и предположим, что вы передаёте в него интерфейс так:
Obj.DoSomething(TSomething.Create);Что при этом произойдёт?
Модификатор
const
говорит компилятору, чтобы он не вызывал методы _AddRef
и _Release
интерфейса. С другой стороны, мы создаём новый объект. Чему равен счётчик ссылок нового объекта? Он равен нулю. Счётчик увеличивается методом _AddRef
при использовании объекта (например, присвоении в переменную). В итоге, мы создаём объект со счётчиком равным 0, передаём его в метод, который не изменяет счётчик ссылок - в результате счётчик ссылок никогда не опускается до 0 (просто потому, что он никогда и не поднимается с нуля), а, значит, и не вызывается деструктор объекта. В итоге мы получаем утечку объекта.Решение заключается в использовании переменной:
var Arg: ISomething; begin Arg := TSomething.Create; Obj.DoSomething(Arg); end;Введение переменной заставляет счётчик ссылок меняться и в итоге приводит к вызову деструктора, т.к. теперь счётчик ссылок упадёт до нуля, когда переменная
Arg
выйдет из области видимости.Замечание: конечно, вы можете "решить" эту проблему, убрав
const
у параметра, но только при этом надо понимать, что правильно сформированный аргумент - это задача вызывающего, а не вызываемого.В целом же, руководствуясь правилом "уступи дураку дорогу", я бы рекомендовал не использовать модификаторы
const
для параметров-интерфейсов и, конечно же, не использовать создание объектов на месте аргументов при вызове других функций.Ошибка: двойное освобождение интерфейсов
Деструкторы классов, реализующих интерфейсы, являются очень хрупкими функциями. Если вы попробуете делать там слишком много - у вас могут быть неприятности. К примеру, если ваш деструктор передаёт ссылки на себя другим функциям, то эти функции могут решить вызывать ваши_AddRef
и _Release
во время своей работы. Посмотрите на этот код:
function TMyObject._Release: Integer; begin Result := InterlockedDecrement(FRefCount); if Result = 0 then Destroy; end; destructor TMyObject.Destroy; begin if FNeedSave then Save; inherited; end;Это не выглядит очень уж страшным, не так ли? Объект просто сохраняет себя перед разрушением.
Но метод
Save
мог бы выглядеть примерно так:
function TMyObject.Save: HRESULT; var spstm: IStream; spows: IObjectWithSite; begin Result := GetSaveStream(spstm); if SUCCEEDED(hr) then begin Supports(spstm, IObjectWithSite, spows); if Assigned(spows) then spows.SetSite(Self); Result := SaveToStream(spstm); if Assigned(spows) then spows.SetSite(nil); end; end;Сам по себе он выглядит нормально. Мы получаем поток (stream) и сохраняем себя в него, дополнительно устанавливая сведения о контексте (site) - на случай если потоку нужна будет дополнительная информация.
Но этот простой код в сочетании с тем фактом, что он запущен из деструктора, даёт нам рецепт для катастрофы. Посмотрите что при этом происходит:
- Метод
_Release
уменьшает счётчик ссылок до нуля и выполняет удалениеSelf
. - Деструктор пытается сохранить объект.
- Метод
Save
получает поток для сохранения и устанавливаетSelf
в качестве контекста. Это увелививает счётчик ссылок с нуля до единицы. - Метод
SaveToStream
сохраняет объект в поток. - Метод
Save
очищает контекст потока. Это приводит к уменьшению счётчика ссылок нашего объекта с единицы до нуля. - Поэтому метод
_Release
вызывает деструктор объекта второй раз.
Поэтому, как минимум, вы должны вставить
Assert
в ваш метод _AddRef
, чтобы гарантировать, что вы не увеличиваете счётчик ссылок с нуля во время выполнения деструктора:
function TMyObject._AddRef: Integer; begin Assert(FRefCount >= 0); Result := InterlockedIncrement(FRefCount); end; function TMyObject._Release: Integer; begin Result := InterlockedDecrement(FRefCount); if Result = 0 then Destroy; end; procedure TMyObject.BeforeDestruction; begin if RefCount <> 0 then System.Error(reInvalidPtr); FRefCount := -1; end;Примечание: подобная проверка отсутствует в
TInterfacedObject
. TInterfacedObject
позволит вашему коду выполняться и вызовет деструктор дважды. Заметьте, что в нашей системе плагинов мы наследуем все классы, реализующие интерфейсы, от TCheckedInterfacedObject
- специального класса, в который мы встроили дополнительные проверки.Эта проверка поможет вам легко отлавливать "случаи загадочного двойного вызова деструктора объекта". Но когда вы идентифицируете проблему, то что же вам с ней делать? Вот один из рецептов.
В комментариях к 6-ой части (UI в плагинах) мы уже начинали разговор о проблемах с Action-ами в плагинах.
ОтветитьУдалитьСейчас я сделал демо-приложение. На форму в плагине добавлены действия из StdActns.
В архиве плагин собран в Delphi 2007, а приложение в Delphi 2007 и в Delphi XE. Можно проверить, что в одном приложении плагин работает, а в другом - нет.
Кроме того, Action-ы из StdActns не работают в любом случае. В общем случае в плагине не будут работать Action-ы которые используют HandlesTarget и UpdateTarget.
PluginAPI_Actions.zip
Отличная статья, много полезного! Хотелось бы более развернуто про hresult, как про самый рекомендуемый способ возврата ошибок. И чем этот способ принципиально отличается от Success/Fail flag и GetLastError.
ОтветитьУдалитьПодробно.
ОтветитьУдалитьКратко:
HRESULT:
+ Позволяет производить проверку успешности сразу, без необходимости делать дополнительный вызов функции (GetLastError).
+ Допускает несколько "успешных" результатов (например, S_FALSE и, в целом, любые S_что-то).
+ Имеет поддержку компилятора Delphi (safecall).
+ Есть стандартный механизм передачи произвольной дополнительной информации вместе с кодом ошибки (см. IErrorInfo/SetErrorInfo).
+ Новые функции, вводимые в Windows сегодня, имеют вид интерфейсов COM (читай: используют HRESULT). Если же они являются именно чистыми функциями, то это (как правило) будут функции, возвращающие HRESULT, а не работающие с GetLastError/SetLastError.
- Из-за дополнительных полей (Facility, тип успеха, зарезервированные поля) HRESULT допускает хранение всего 65 тысяч кодов на один источник (facility) вместо 2^31 для SetLastError/GetLastError (1 бит потрачен на признак MS/не-MS) - из которых, впрочем, сегодня используется всего 16 тысяч кодов.
SetLastError/GetLastError - соответственно, наоборот.
Добрый день,
ОтветитьУдалитьцикл статей интересный, в 6 части цикла статей было рассказано про формы в плагинах, вроде с созданием формы проблем нет, а вот как сделать чтобы при выгрузке плагина форма удалялась? Намекните плиз. Спасибо.
Подскажите пожалуйста относительно управления памяти для плагинов.
ОтветитьУдалитьГде можно подробней почитать о вариантах, с хоть минимальными примерами:
* Интерфейс со свойствами типа lpData и cbSize.
* DLL с экспортированием функции освобождения памяти.
Заранее спасибо!
>>> в 6 части цикла статей было рассказано про формы в плагинах, вроде с созданием формы проблем нет, а вот как сделать чтобы при выгрузке плагина форма удалялась
ОтветитьУдалитьНу кто-то должен хранить список форм плагина. По логике, это должен делать плагин. Вот создали вы в плагине форму - запомните её в список. Выгружается плагин - удаляйте все формы, что вы запомнили в списке.
>>> Где можно подробней почитать о вариантах, с хоть минимальными примерами
Посмотрите.
1) Вместо типа Boolean вы рекомендуете использовать тип BOOL (допустимо ByteBool, WordBool и LongBool), но тип BOOL объявляется в модуле (Delphi XE2) Winapi.Windows как: BOOL = LongBool; при этом LongBool определяется уже в System (то есть вшит в комплиятор). Таким образом, получается, что вместо BOOL можно использовать LongBool?
ОтветитьУдалить2) В своих программах начал использовать синонимы для стандартных типов вида:
TCheBoolean = Boolean;
PCheBoolean = PBoolean;
TCheLibraryBoolean = LongBool;
PCheLibraryBoolean = ^TCheLibraryBoolean;
TCheString = string;
TCheLibraryString = WideString;
так как считаю, что (типы с Library использую только в экспортных подпрограммах библиотек) в случае чего лего будет заменить на иной тип. Насколько это необходимо и правильно? Нет ли в этом подводных камней, которые сложно уловить в будущем, при эксплуатации программы?
3) Допустим имею функции:
function IsEqualStrings
(const aStr1, aStr2 : TCheString;
const aToLowerCase : TCheBoolean) : TCheBoolean;
function IsEqualStringsW
(const aStr1, aStr2 : TCheLibraryString;
const aToLowerCase : TCheLibraryBoolean) : TCheLibraryBoolean;
stdcall;
при этом вторая функция имеет следующий код:
function IsEqualStringsW
(const aStr1, aStr2 : TCheLibraryString;
const aToLowerCase : TCheLibraryBoolean) : TCheLibraryBoolean;
stdcall;
begin
Result :=
TCheLibraryBoolean
(IsEqualStrings
(TCheString(aStr1), TCheString(aStr2), TCheBoolean(aToLowerCase)));
end;
При вызове IsEqualStrings внутри подпрограммы IsEqualStringsW не происходит ли какой-либо потери символов (точности, разрядности и тд)?
Спасибо, с уважением Иван!
> вместо BOOL можно использовать LongBool?
УдалитьМожно, но не нужно.
> Насколько это необходимо и правильно? Нет ли в этом подводных камней, которые сложно уловить в будущем, при эксплуатации программы?
Понятия не имею. Поскольку я крайне тщательно подхожу к выбору базовых типов, мне ни разу не приходилось их менять.
Но в своей системе наименований есть однозначный плюс: можно ввести правило, что все экспортируемые типы обязательно должны иметь определённый префикс (TCheLibrary - в вашем примере). Соответственно, все типы с этим префиксом должны быть объявлены как "безопасные для экспорта" (т.е. совместимые между языками). Тогда можно легко проверять правильность выбора типов в прототипах экспортируемых функций - они обязаны иметь один и тот же префикс.
> При вызове IsEqualStrings внутри подпрограммы IsEqualStringsW не происходит ли какой-либо потери символов (точности, разрядности и тд)?
Нет - при использовании Delphi 2009 и выше.
Да - при использовании Delphi 7 и ниже.
И в обоих случаях будет конвертация типов и копирование данных (в run-time).
Всё это - нормально.
Здравствуйте!
УдалитьСпасибо, за ответ. То есть вместо TCheLibraryBoolean = LongBool лучше использовать TCheLibraryBoolean = BOOL?? В одной из статей, посвященной плагинам было сказано, что в них не следует использовать array of, например, то вместо TCheLibraryStringArray = array of TCheLibraryString что лучше использовать, чтобы реализовать работу с массивом внутри DLL (плагина) и приложением??
Заранее спасибо.
Я бы использовал интерфейс для передачи любых сложных и динамических данных. Но при желании можно использовать можно указатель на статический массив + целочисленная длина.
Удалить> интерфейс для передачи любых сложных и динамических данных.
УдалитьА можно привести пример реализации?
> А можно привести пример реализации?
УдалитьВ тексте статьи есть ссылка на примеры кода.
В данном случае в интерфейсе должно быть свойство Count и индексированное Items[], а в private реализовывающего класса будет динамический массив, инициализируемый из параметров конструктора.