Программирование на языке Delphi. Глава 6. ИнтерфейсыИсточник: RSDN
При программировании нередко возникает необходимость выполнить обращение к объекту, находящемуся в другом загрузочном модуле, например EXE или DLL. Для решения поставленной задачи компания Microsoft разработала технологию COM (Component Object Model) - компонентную модель объектов. Технология получила такое название благодаря тому, что обеспечивает создание программных компонентов - независимо разрабатываемых и поставляемых двоичных модулей. Поскольку объекты различных программ разрабатываются на различных языках программирования, например Delphi, C++, Visual Basic и др., технология COM стандартизирует формат взаимодействия между объектами на уровне двоичного представления в оперативной памяти. Согласно технологии COM взаимодействие между объектами осуществляется посредством так называемых интерфейсов. Рассмотрим, что же они собой представляют и как с ними работают. 6.1. Понятие интерфейсаИз предыдущих глав вы уже знаете, что собой представляет объект. Представьте, что получится, если из объекта убрать поля и код всех методов. Останется лишь интерфейс - заголовки методов и описания свойств. Схематично понятие интерфейса можно представить в виде формулы: Интерфейс = Объект - Реализация В отличие от объекта интерфейс сам ничего "не помнит" и ничего "не умеет делать"; он является всего лишь "разъемом" для работы с объектом. Объект может поддерживать много интерфейсов и выступать в разных ролях в зависимости от того, через какой интерфейс вы его используете. Совершенно различные по структуре объекты, поддерживающие один и тот же интерфейс, являются взаимозаменяемыми. Не важно, есть у объектов общий предок или нет. В данном случае интерфейс служит их дополнительным общим предком. 6.2. Описание интерфейсаВ языке Delphi интерфейсы описываются в секции type глобального блока. Описание начинается с ключевого слова interface и заканчивается ключевым словом end. По форме объявления интерфейсы похожи на обычные классы, но в отличие от классов:
Приведем пример интерфейса и сразу заметим, что интерфейсам принято давать имена, начинающиеся с буквы I (от англ. Interface):
Интерфейс ITextReader предназначен для считывания табличных данных из текстовых источников. В главе 3 мы уже создавали объекты, которые умеют это делать, поэтому назначение методов и свойств должно быть вам понятно. Непонятно пока другое - зачем вообще нужен интерфейс для доступа к табличным данным, если уже есть готовый класс TTextReader с требуемой функциональностью. Объяснение состоит в следующем. Не определив интерфейс ITextReader, невозможно разместить класс TTextReader в DLL-библиотеке и обеспечить доступ к нему из EXE-программы. Создавая DLL-библиотеку, мы с помощью оператора uses должны включить модуль ReadersUnit в проект библиотеки. Создавая EXE-программу, мы должны включить модуль ReadersUnit и в нее, чтобы воспользоваться описанием класса TTextReader. Но тогда весь программный код класса попадет внутрь EXE-файла, а это именно то, от чего мы хотим избавиться. Решение проблемы обеспечивается введением понятия интерфейса. Чтобы вам было легче разобраться с интерфейсом ITextReader, мы привели его незаконченный вариант. Компиляция интерфейса в таком виде приведет к ошибкам: для свойств не указаны методы чтения и записи. Полное описание интерфейса выглядит так:
Поскольку интерфейс не может содержать поля, все его свойства отображены на его методы. 6.3. Расширение интерфейсаНовый интерфейс можно создать с нуля, а можно создать путем расширения уже существующего интерфейса. Во втором случае в описании интерфейса после слова interface указывается имя базового интерфейса:
Определенный таким образом интерфейс включает все методы и свойства своего предшественника и добавляет к ним свои собственные. Несмотря на синтаксическое сходство с наследованием классов, расширение интерфейсов имеет другой смысл. В классах наследуется реализация, а в интерфейсах просто расширяется набор методов и свойств. В языке Delphi существует предопределенный интерфейс IInterface, который служит неявным базовым интерфейсом для всех остальных интерфейсов. Это означает, что объявление
эквивалентно следующему:
Мы рекомендуем использовать вторую, более полную форму записи. Описание интерфейса IInterface находится в стандартном модуле System:
Непонятная последовательность нулей и других цифр в квадратных скобках - это так называемый глобально-уникальный идентификатор интерфейса. Мы к нему еще вернемся, а сейчас рассмотрим методы. Методы интерфейса IInterface явно или неявно попадают во все интерфейсы и имеют особое назначение. Метод QueryInterface нужен для того, чтобы, имея некоторый интерфейс, запросить у объекта другой интерфейс. Этот метод автоматически вызывается при преобразовании одних интерфейсов в другие. Метод _AddRef автоматически вызывается при присваивании значения интерфейсной переменной. Метод _Release автоматически вызывается при уничтожении интерфейсной переменной. Последние два метода позволяют организовать подсчет ссылок на объект и автоматическое уничтожение объекта, когда количество ссылок на него становится равным нулю. Вызовы всех трех методов генерируются компилятором автоматически, и вызывать их явно нет необходимости, однако программист должен позаботиться об их реализации. 6.4. Глобально-уникальный идентификатор интерфейсаИнтерфейс является особым типом данных: он может быть реализован в одной программе, а использоваться из другой. Для этого нужно обеспечить идентификацию интерфейса при межпрограммном взаимодействии. Понятно, что программный идентификатор интерфейса для этого не подходит - разные программы пишутся разными людьми, а разные люди подчас дают одинаковые имена своим творениям. Поэтому каждому интерфейсу выдается своеобразный «паспорт» - глобально-уникальный идентификатор (Globally Unique Identifier - GUID). Глобально-уникальный идентификатор - это 16-ти байтовое число, представленное в виде заключенной в фигурные скобки последовательности шестнадцатеричных цифр:
В среде Delphi глобально-уникальный идентификатор описывается типом данных TGUID:
Константы с типом TGUID разрешено инициализировать строковым представлением глобально-уникального идентификатора. Компилятор сам преобразует строку в запись с типом TGUID. Пример:
Если глобально-уникальный идентификатор назначается интерфейсу, то он записывается после ключевого слова interface и заключается в квадратные скобки, например:
В будущем нашему интерфейсу ITextReader понадобится глобально-уникальный идентификатор. Но как его выбрать так, чтобы он оказался уникальным? Очень просто - нажмите в редакторе кода комбинацию клавиш Ctrl+Shift+G.
Генерация глобально-уникальных идентификаторов осуществляется системой Windows по специальному алгоритму, в котором задействуется адрес сетевого адаптера, текущее время и генератор случайных чисел. Можете смело полагаться на уникальность всех получаемых идентификаторов. Наличие глобально-уникального идентификатора в описании интерфейса не является обязательным, однако использование интерфейса без такого идентификатора ограничено, например, запрещено использовать оператор as для преобразования одних интерфейсов в другие. Если у интерфейса есть глобально-уникальный идентификатор, то программный идентификатор интерфейса можно использовать там, где ожидается тип данных TGUID, например:
6.5. Реализация интерфейсаИнтерфейс бесполезен до тех пор, пока он не реализован. Реализацией интерфейса занимается класс. Если класс реализует интерфейс, то интерфейс может использоваться для доступа к объектам этого класса. При объявлении класса имя реализуемого интерфейса записывается через запятую после имени базового класса:
Такая запись означает, что класс TTextReader унаследован от класса TObject и реализует интерфейс ITextReader (см. рисунок 6.1).
Класс, реализующий интерфейс, должен содержать код для всех методов интерфейса. Класс TTextReader в модуле ReadersUnit (см. главу 3) вроде бы содержит код для всех методов интерфейса ITextReader, и все, что нужно сделать, - это добавить имя интерфейса в заголовок класса. Сделайте это в модуле ReadersUnit:
Если класс содержит только часть методов интерфейса, то недостающие методы придется добавить. Так в интерфейсе ITextReader описан метод GetActive, а в классе TTextReader такого метода нет. Добавьте метод GetActive в класс TTextReader:
Но это еще не все. Мы совсем забыли о методах QueryInterface, _AddRef и _Release, которые тоже должны быть реализованы. К счастью, вам нет необходимости ломать голову над реализацией этих методов, поскольку разработчики системы Delphi уже позаботились об этом. Стандартная реализация методов интерфейса IInterface находится в классе TInterfacedObject. Мы его рассмотрим ниже, а сейчас просто унаследуем класс TTextReader от класса TInterfacedObject - и он получит готовую реализацию методов QueryInterface, _AddRef и _Release.
Теперь реализация интерфейса ITextReader полностью завершена и можно переходить к использованию объектов класса TTextReader через этот интерфейс. 6.6. Использование интерфейсаДля доступа к объекту через интерфейс нужна интерфейсная переменная:
Интерфейсная переменная занимает в оперативной памяти четыре байта, хранит ссылку на интерфейс объекта и автоматически инициализируется значением nil. Перед использованием интерфейсную переменную инициализируют значением объектной переменной:
После инициализации интерфейсную переменную Intf можно использовать для вызова методов объекта Obj:
Через интерфейсную переменную доступны только те методы и свойства объекта, которые есть в интерфейсе:
6.7. Реализация нескольких интерфейсовОдин класс может содержать реализацию нескольких интерфейсов. Такая возможность позволяет воплотить в классе несколько понятий. Например, класс TTextReader - "считыватель табличных данных" - может выступить еще в одной роли - "считыватель строк". Для этого он должен реализовать интерфейс IStringIterator:
Интерфейс IStringIterator предназначен для последовательного доступа к списку строк. Метод Next возвращает очередную строку из списка, метод Finished проверяет, достигнут ли конец списка. Реализуем интерфейс IStringIterator в классе TTextReader таким образом, чтобы последовательно считывались значения из ячеек таблицы. Например, представьте, что в некотором файле дана таблица:
Чтение этой таблицы через интерфейс IStringIterator вернет следующую последовательность строк:
Ниже приведен программный код, обеспечивающий поддержку интерфейса IStringIterator в классе TTextReader:
Теперь объекты класса TTextReader совместимы сразу с тремя типами данных: TInterfacedObject, ITextReader, IStringIterator.
В одном случае объект класса TTextReader рассматривается как считыватель табличных данных, а в другом случае - как обычный список строк с последовательным доступом. Например, если есть две процедуры:
то объект класса TTextReader можно передать в обе процедуры:
6.8. Реализация интерфейса несколькими классамиНесколько совершенно разных классов могут содержать реализацию одного и того же интерфейса. С объектами таких классов можно работать так, будто у них есть общий базовый класс. Интерфейс выступает аналогом общего базового класса. Рассмотрим пример. Представьте, что есть два класса: TTextReader и TIteratableStringList:
Схематично полученную иерархию классов можно представить так (рисунок 6.2):
Объекты классов TTextReader и TIteratableStringList несовместимы между собой. Тем не менее, они совместимы с переменными типа IStringIterator. Это значит, что если есть процедура:
то вы можете передавать ей объекты обоих упомянутых классов в качестве аргумента:
6.9. Связывание методов интерфейса с методами классаМетод интерфейса связывается с методом класса по имени. Если имена по каким-то причинам не совпадают, то можно связать методы явно с помощью специальной конструкции языка Delphi. Например, в классе TTextReader добавлены методы Next и Finished для поддержки интерфейса IStringIterator. Согласитесь, что существование в одном классе методов Next и NextLine вносит путаницу. По названию метода Next не понятно, что для этого метода является следующим элементом. Поэтому уточним название метода в классе TTextReader и воспользуемся явным связыванием методов, чтобы сохранить имя Next в интерфейсе IStringIterator:
При работе с объектами класса TTextReader через интерфейс IStringIterator вызов метода Next приводит к вызову метода NextItem:
Очевидно, что связываемые методы должны совпадать по сигнатуре (списку параметров и типу возвращаемого значения). 6.10. Реализация интерфейса вложенным объектомСлучается, что реализация интерфейса содержится во вложенном объекте класса. Тогда не требуется программировать реализацию интерфейса путем замыкания каждого метода интерфеса на соответствующий метод вложенного объекта. Достаточно делегировать реализацию интерфейса вложенному объекту с помощью директивы implements:
В этом примере интерфейс ITextReader в классе TTextParser реализуется не самим классом, а его внутренней переменной FTextReader. Очевидно, что внутренний объект должен быть совместим с реализуемым интерфейсом. 6.11. Совместимость интерфейсовСовместимость интерфейсов подчиняется определенным правилам. Если интерфейс создан расширением уже существующего интерфейса:
то интерфейсной переменной базового типа может быть присвоено значение интерфейсной переменной производного типа:
Но не наоборот:
Правило совместимости интерфейсов чаще всего применяется при передаче параметров в процедуры и функции. Например, если процедура работает с переменными типа ITextReader,
то ей можно передать переменную типа IExtendedTextReader:
Заметим, что любая интерфейсная переменная совместима с типом данных IInterface - прародителем всех интерфейсов. 6.12. Совместимость класса и интерфейсаИнтерфейсной переменной можно присвоить значение объектной переменной при условии, что объект (точнее его класс) реализует упомянутый интерфейс:
Такая совместимость сохраняется в производных классах. Если класс реализует некоторый интерфейс, то и все его производные классы совместимы с этим интерфейсом (см. рисунок 6.3):
Однако, если класс реализует производный интерфейс, то это совсем не означает, что он совместим с базовым интерфейсом (см. рисунок 6.4):
Для совместимости с базовым интерфейсом нужно реализовать этот интерфейс явно:
Теперь класс TExtendedTextReader совместим и с интерфейсом ITextReader, поэтому следующее присваивание корректно:
Исключением из только что описанного правила является совместимость всех снабженных интерфейсами объектов с интерфейсом IInterface:
6.13. Получение интерфейса через другой интерфейсЧерез интерфейсную переменную у объекта всегда можно запросить интерфейс другого типа. Для этого используется оператор as, например:
Если объект действительно поддерживает запрашиваемый интерфейс, то результатом является ссылка соответствующего типа. Если же объект не поддерживает интерфейс, то возникает исключительная ситуация EIntfCastError. В действительности оператор as преобразуется компилятором в вызов метода QueryInterface:
Напомним, что метод QueryInterface описан в интерфейсе IInterface и попадает автоматически во все интерфейсы. Стандартная реализация этого метода находится в классе TInterfacedObject. 6.14. Механизм подсчета ссылокМеханизм подсчета ссылок на объект предназначен для автоматического уничтожения неиспользуемых объектов. Неиспользуемым считается объект, на который не ссылается ни одна интерфейсная переменная. Подсчет ссылок на объект обеспечивают методы _AddRef и _Release интерфейса IInterface. При копировании значения интерфейсной переменной вызывается метод _AddRef, а при уничтожении интерфейсной переменной - метод _Release. Вызовы этих методов генерируются компилятором автоматически:
Стандартная реализация методов _AddRef и _Release находится в классе TInterfacedObject. Она достаточно проста и вы легко разберетесь с ней, читая комментарии в исходном тексте.
Заметим, что функции InterlockedIncrement и InterlockedDecrement просто увеличивают значение целочисленной переменной на единицу. В отличие от обычного оператора сложения, они обеспечивают атомарное изменение значения переменной, что очень важно для правильной работы распараллеленных (многопоточных) программ. Приведенную выше реализацию методов _AddRef и _Release автоматически получают все наследники класса TInterfacedObject, в том числе и классы TTextReader, TDelimitedReader и TFixedReader. Поэтому неиспользуемые объекты классов TDelimitedReader и TFixedReader тоже автоматически уничтожаются при работе с ними через интерфейсные переменные:
Обратите внимание, что объектные переменные не учитываются при подсчете ссылок. Поэтому мы настоятельно рекомендуем избегать смешивания интерфейсных и объектных переменных. Если вы планируете использовать объект через интерфейс, то лучше всего результат работы конструктора сразу присвоить интерфейсной переменной:
Если интерфейс является входным параметром подпрограммы, то при вызове подпрограммы создается копия интерфейсной переменной с вызовом метода _AddRef:
Копия не создается, если входной параметр описан с ключевым словом const:
Интерфейсная переменная уничтожается при выходе из области действия переменной, а это значит, что у нее автоматически вызывается метод _Release:
6.15. Представление интерфейса в памятиГлубокое понимание работы интерфейсов требует знания их технической реализации. Поэтому вам необходимо разобраться в том, как представляется интерфейс в оперативной памяти компьютера, и что стоит за операторами Intf := Obj и Intf.NextLine. Интерфейс по сути выступает дополнительной таблицей виртуальных методов, ссылка на которую укладывается среди полей объекта (рисунок 6.5). Эта таблица называется таблицей методов интерфейса . В ней хранятся указатели на методы класса, реализующие методы интерфейса. Интерфейсная переменная хранит ссылку на скрытое поле объекта, которое содержит указатель на таблицу методов интерфейса. Когда интерфейсной переменной присваивается значение объектой переменной,
к адресу объекта добавляется смещение до скрытого поля внутри объекта и этот результат заносится в интерфейсную переменную. Чтобы убедиться в сказанном, посмотрите в отладчике значения Pointer(Obj) и Pointer(Intf) сразу после выполнения оператора Intf := Obj. Эти значения будут разными! Причина в том, что объектная ссылка указывает на начало объекта, а интерфейсная ссылка - на скрытое поле внутри объекта.
Алгоритм вызова метода интерфейса такой же, как алгоритм вызова метода класса. Когда через интерфейсную переменную выполняется вызов метода,
реализуется следующий алгоритм:
Обычными средствами процедурного программирования этот алгоритм реализуется так:
Вся эта сложность скрыта в языке Delphi за понятием интерфейса. Причем несмотря на такое количество операторов в примере, вызов метода через интерфейс в машинном коде выполняется весьма эффективно (всего несколько инструкций процессора), поэтому в подавляющем большинстве случаев потерями на вызов можно пренебречь. 6.16. Применение интерфейса для доступа к объекту DLL-библиотекиЕсли вы поместите свой класс в DLL-библиотеку, то при необходимости использовать его в главной программе столкнетесь с проблемой. Подключение модуля с классом к главной программе приведет к включению в нее кода всех методов класса, т.е. задача выделения класса в DLL-библиотеку не будет решена. Если же не подключить модуль с описанием класса, главная программа вообще не будет знать о существовании класса, и воспользоваться классом будет невозможно. Эта проблема решается с помощью интерфейсов. Покажем это на примере модуля ReadersUnit. Сначала вынесем описание интерфейса ITextReader в отдельный модуль (например, ReaderIntf), чтобы этот модуль в дальнейшем можно было подключить к главной программе:
Затем удалим описание интерфейса из модуля ReadersUnit, а вместо него подключим модуль ReaderIntf:
Наконец включим скорректированный модуль ReadersUnit в DLL-библиотеку, которую назовем ReadersLib:
Вроде бы все готово, и теперь в главной программе достаточно подключить модуль ReaderIntf и работать с объектами через интерфейс ITextReader (рисунок 6.6).
Но постойте! А как в программе создавать объекты классов, находящихся в DLL-библиотеке? Ведь в интерфейсе нет методов для создания объектов! Для этого определим в DLL-библиотеке специальную функцию и экспортируем ее:
В главной программе импортируйте функцию GetDelimitedReader, чтобы с ее помощью создавать объекты класса TDelimitedReader:
Теперь вы знаете, как разместить объекты в DLL-библиотеке. Смело пользуйтесь динамически загружаемыми библиотеками, не теряя преимуществ ООП. 6.17. ИтогиВы прочитали и усвоили весь материал всех предыдущих глав? Тогда спешим вас поздравить! Можете смело утверждать, что знаете язык программирования Delphi. Что же дальше? Вас ждет новая высота - среда программирования Delphi. Сейчас вы имеете лишь поверхностное представление о ее возможностях. Настало время подготовить себя к профессиональной работе в среде Delphi. |