У кого и как добиться получения COM-объекта
Для завершения подробной иллюстрации как COM-объекты находят друг-друга при помощи операционной системы нам осталось описать всего два пункта - системную функцию, осуществляющую разыскание, запуск и предоставление адреса объекта запросившему его клиенту и то, как сам COM-сервер обрабатывает запросы от этой функции.
Возможно, что на данном этапе это пока и не слишком интересно, но я хочу повторить свои слова - точное знание философии взаимодействия объектов, знание средств и инструментов, используемых на разных этапах этого процесса неизбежно. В COM нет возможности "перекомпилировать всё" или запустить отладчик и наблюдать любую часть вашей программы - часть программного комплекса будут составлять "чужие" объекты, с которыми вы будете обращаться так же, как и со своими. А выяснить "что у них внутри" и что-то "подправить" вы не сможете, вам останется полагаться только на точное соблюдение протокола взаимодействия…
Мы начнём с последнего оставшегося пункта - как взаимодействуют операционная система и COM-сервер, когда клиент запросил адрес объекта. Поскольку наше изложение - только иллюстрация важнейших аспектов, мы будем рассматривать лишь самый простой случай - взаимодействие системы и DLL-сервера.
Для этого нам понадобится знание некоторых подробностей "DLLестроения". Ранее говорилось, что операционная система неким стандартным образом обращается к модулю, именуемому COM-сервер, и побуждает его выдать ссылку на COM-объект. Вопрос, на который хочется получить ответ - как?
Если обратиться к техническим подробностям, то вы, вероятно, слышали, что DLL - не просто кусок кода. Внутри DLL существуют специальные таблицы, которые описывают функции, "внешние" с точки зрения границы этой DLL. Первая таблица называется IMPORTS - это функции, которые требуются DLL, но которые в самой DLL не реализованы. Вторая таблица называется EXPORTS, она описывает функции, которые реализованы в DLL и предлагаются всем желающим.
Эти таблицы - важная часть DLL, поскольку системный загрузчик, производящий "позднее связывание", использует их для правильной настройки адресов вызывающей и вызываемой процедур. Другими словами, все функции, которые можно вызвать находясь снаружи DLL, перечислены в её таблице EXPORTS. Поэтому, если система как-то обращается к DLL, то она может это сделать только "по правилам" - иного способа вызвать какую-то функцию из DLL нет. Следовательно, присутствие этой функции должно обнаруживаться инструментально.
В поставке Visual Studio существует очень полезный инструмент исследования двоичных файлов - программа dumpbin.exe. Она располагается в каталоге "…\Program Files\Microsoft Visual Studio\VC98\Bin" и управляется интерфейсом командной строки. Назначение этой программы - выдавать содержимое таблиц, содержащихся в двоичных файлах в человекочитаемом виде. Используем её в применении к заведомому COM-серверу, обнаруженному нами в прошлой статье - к DAO350.DLL, который на моей машине располагается в каталоге "C:\Program Files\Common Files\Microsoft Shared\DAO".
Запустим dumpbin следующей командой из командной строки:
dumpbin.exe /EXPORTS C:\Program Files\Common Files\Microsoft Shared\DAO\DAO350.DLL > tttt.txt
Переназначение вывода dumpbin в файл tttt.txt сделано удобства ради. По умолчанию программа выводит на консоль, а мне хочется получить вывод так, чтобы его можно было исследовать и после её завершения. Посмотрим, что мы в этот файл получили. А получили мы вот (с небольшими сокращениями) что:
Microsoft (R) COFF Binary File Dumper Version 6.00.8447
Copyright (C) Microsoft Corp 1992-1998. All rights reserved.
Dump of file DAO350.DLL
File Type: DLL
Section contains the following exports for DAO350.dll
ordinal | hint | RVA | name |
2 | 0 | 00008D28 | DllCanUnloadNow |
1 | 1 | 00008A31 | DllGetClassObject |
3 | 2 | 00009199 | DllRegisterServer |
5 | 3 | 000090DB | DllRegisterServerEx |
4 | 4 | 00008E51 | DllUnregisterServer |
Это в точности то, что мы хотели - таблица EXPORTS. У этой DLL всего пять экспортируемых функций! Забегая вперёд скажу - все они используются исключительно для функционирования COM, т.е. перед нами чистый COM-сервер, не предназначенный делать ничего больше.
И из этих пяти функций в настоящий момент речь идет только об одной - DllGetClassObject. Она - та самая функция, вызывая которую система сообщает серверу ссылку на какой объект сервер должен изготовить и предоставить системе. Обращение к MSDN дает следующий прототип этой функции:
STDAPI | ||
REFCLSID rclsid, | //CLSID объекта класса | |
REFIID riid, | //Ссылка на идентификатор интерфейса, | |
LPVOID * ppv | //Адрес выходной переменной, которая принимает | |
); |
т.е. система при вызове данной функции готовит список аргументов <CLSID>-<ссылка на интерфейс>-<адрес переменной, куда вернуть результат>. Функция DllGetClassObject принадлежит DLL, т.е., видимо, как-то умеет производить объекты запрошенного при помощи CLSID имени. Адрес произведенного объекта система ожидает получить посредством указателя ppv. Напомним, что CLSID - не что иное, как имя COM-объекта, это - CLaSs IDentifier.
Откуда приходят все эти параметры?
Они приходят от COM-клиента, который захотел получить экземпляр объекта, именуемого данным CLSID. Для того, чтобы система выяснила какой сервер необходимо активизировать (просмотрев системный реестр), загрузила его, и вызвала у этого сервера функцию DllGetClassObject клиент на своей стороне вызывает "широко известную в узких кругах" функцию Win32 API CoCreateInstance - создать экземпляр объекта. MSDN дает о ней следующую справку:
STDAPI | ||
REFCLSID rclsid, | //CLSID объекта класса | |
LPUNKNOWN pUnkOuter, | ||
DWORD dwClsContext, | ||
REFIID riid | //Ссылка на идентификатор интерфейса, | |
LPVOID * ppv | //Адрес выходной переменной, которая принимает | |
); |
Аргументы pUnkOuter и dwClsContext в данный момент нас не интересуют, они указывают системе среди DLL или EXE-серверов искать интересующий нас объект и агрегировать ли его. А вот три других аргумента - в точности те, которые передаются системой функции сервера DllGetClassObject. И - функцию CoCreateInstance клиент вызывает из своего кода. Иными словами, вызов клиентом функции CoCreateInstance "транслируется" системой в вызов функции DllGetClassObject на стороне сервера. DllGetClassObject возвращает адрес объекта, система передает его CoCreateInstance, которая возвращает его клиенту. Всё, наша экскурсия в философию закончена - пункты с первого по четвёртый проиллюстрированы сущностями операционной системы.
Но вот откуда берется аргумент riid и что он означает? И сам этот вопрос и ответ на него старательно обходились с начала нашего изложения, а между тем компонентное программирование вообще-то начинается с ответа на этот вопрос. riid есть идентификатор интерфейса. В пункте пятом нашего философского изложения стояло - "как взаимодействовать между собой объекты и сами знают…". На самом деле, в COM объекты взаимодействуют друг с другом посредством интерфейсов и - никак иначе
Интерфейс или протокол?
Ну вот, наконец мы и подобрались к тому, с чего обычно начинаются популярные и сумбурные учебники типа "ОLE за 21 день". Всё вышеизложенное можно считать своего рода прелюдией к этому важному понятию. Компонентное программирование немыслимо без концепции интерфейса, оно основано на ней и пронизано ее идеями сверху донизу.
Итак, что такое интерфейс? Если вдуматься в звучание этого слова, то это что-то inter face, т.е. находящееся между взаимодействующими субъектами. И это действительно так. Интерфейс есть спецификация способа взаимодействия двух сущностей понятным для них обеих образом. [И всегда - двух и только двух! Пару могут составлять всё время разные объекты в разном сочетании, но спецификация распространяется только на inter face...]
Концепция интерфейса (а это именно концепция) является абстракцией не меньшего, а может даже - и большего, уровня, чем абстракция объекта. Она требуется, когда мы от взаимодействия объектов переходим к конструкции какого-то одного из них. В таком случае интерфейс выступает, как существенные исключительно для взаимодействия стороны второго, не рассматриваемого в данный момент, объекта. Например, во взаимодействии телефонного аппарата и телефонной станции интерфейсом, очевидно, является вид и последовательность сигналов, которыми обмениваются аппарат и станция. Станцию не интересует цвет, вес, форма и прочие свойства аппарата. Станция считает телефонным аппаратом любой объект, который в состоянии "по проводам" передавать сигналы установленной спецификации. Аналогичная картина наблюдается и со стороны телефонного аппарата - для него станция тоже приводится только к сигналам, которые он получает из линии, а где она расположена, какое у нее здание и что у нее есть еще - аппарат не знает, да ему это и не нужно.
Другой пример интерфейса показывает обычная пара - штепсельная вилка и штепсельная розетка. Вместе они составляют "разъёмное соединение", но на стороне каждого из взаимодействующих объектов имеется своя спецификация - штыри или гнезда определенного размера и на определенном расстоянии. Розетка вполне может считать вилкой любой предмет, который имеет штыри определенного диаметра, расположенные на определённом расстоянии, а вилка может считать розеткой любые гнезда, способные вместить её штыри.
Из этих примеров должно быть понятно, что интерфейс является вполне точной категорией, но не имеет никакого особенного вещного выражения. Весь интерфейс выражается только в спецификации, как должны взаимодействовать объекты и что они должны иметь и делать для этого взаимодействия.
Концепция интерфейса замечательна тем, что она позволяет разделить способность объекта к взаимодействию и другие свойства данного объекта. Если вернуться к примеру телефонного аппарата, то его весьма специфическое восприятие телефонной станцией приводит к тому, что и факсимильная машина и модем и вообще всё, что угодно, способное произвести в линию заданную последовательность сигналов требуемого уровня и вида будут способны воспользоваться услугами телефонной станции. А это даёт определенную свободу в конструировании этих устройств без переделки конструкции телефонной станции. Либо, напротив - свободу в переделке конструкции телефонной станции без потери ею способности обслуживать телефонные соединения.
Интерфейсами пропитана вся наша окружающая жизнь. Мы используем это понятие как совершенно естественное, часто не отдавая себе сознательного отчета в том, что оно называется "интерфейс". Скажем, многие ли вспомнят, что обычный телефонный аппарат имеет и второй интерфейс? Интерфейс между человеком и аппаратом! Этот интерфейс выражается в приемопередающей звукопреобразующей аппаратуре (микрофон и телефон), устройстве, генерирующем вызывные сигналы (номеронабиратель), а ещё - в последовательности действий, которые нужно произвести, чтобы установить соединение с удалённым абонентом. Если не будет хотя бы чего-то одного, если последовательность действий будет нарушена - соединение не состоится. А это - это и есть самый настоящий интерфейс, поскольку "что у аппарата внутри" в данном случае никак не влияет на возможность установления связи.
С другой стороны, интерфейс - действительно философское понятие. То что, является самостоятельным объектом на одном уровне рассмотрения, на другом уровне вполне может оказаться только лишь другим интерфейсом, а то и его частью. Например, когда вы звоните в билетную кассу, чтобы заказать билет в театр, то "весь телефон" для вас окажется просто частью большего интерфейса между вами и билетным кассиром с той стороны. И у вас будет "способ взаимодействия", в котором "вхождение в телефонную связь" будет рассматриваться только как часть общего алгоритма взаимодействия. И не состояться это взаимодействие может по совсем другим причинам, например, кассир с той стороны говорит на языке, которого вы не понимаете, хотя - в связь при помощи телефона вы оба входите совершенно правильно.
Поскольку интерфейс - категория философская, то и в программировании можно найти его примеры на каждом шагу. Хотя они и не называются так, тем не менее, ничем, кроме интерфейса они и не являются. Самый распространённый пример называется "прототип функции". Если припомнить как описывается разбиение программы на "вызывающую" и "вызываемую" функции, то там фигурируют такие понятия, как "вызов функции", "определение функции" и "описание прототипа функции". При этом дело обстоит так, что для компиляции вызова функции не требуется её определения, достаточно "описания прототипа". Прототип так же полезно показать компилятору и при определении функции - тогда компилятор будет способен заметить расхождения между "прототипом" и фактическим определением. Что же есть в данном случае одна строчка "прототип функции"? Да интерфейс же! Ведь он ничего не делает, кроме как описывает существенные для взаимодействия с этой функцией свойства - имя функции, тип возвращаемого значения, порядок следования и типы аргументов.
Следует заметить, что при описании взаимодействия в computer sciences разделяют два понятия - "интерфейс" и "протокол". Первое из них обозначает статику этого взаимодействия, т.е. что именно, в каком формате и на каком месте должен предоставлять объект. Второе обычно обозначает динамику взаимодействия - чем именно и в какой последовательности должны обмениваться взаимодействующие объекты. Это разделение существует, оно традиционно, хотя… хотя и интерфейс и протокол относятся к одному и тому же явлению. И когда их иногда смешивают в одну сущность (которую называют то "интерфейс", то "протокол") в этом нет особенной ошибки. В самом деле, чем "спецификация в пространстве" (интерфейс) отличается в своей сущности от "спецификации во времени" (протокол)?
Итак, первым китом компонентного программирования является понятие объекта, Понятие явно определенного интерфейса - второй его кит. О том, как именно это делается, мы поговорим в следующий раз.
Скажите, все ли письма телезрителей вы придумываете сами?
(один из вопросов, заданный автору рассылки на интервью для дружественной редакции)
В этой рассылке мы должны были бы рассмотреть что такое интерфейс с точки зрения его реализации в computer sciences. Сегодня в тексте должны бы были появиться пусть пока небольшие, но уже фрагменты кода. Но "в редакцию пришли письма". В них авторы называют меня разными хорошими словами (оставим это на их совести, возможно, они просто пока до конца ещё не разобрались :-) и задают несколько важных вопросов:
· Если наша рассылка - практическое введение, то на каких языках программирования будут приводиться примеры?
· Какие технические средства и инструменты будут использоваться?
· По COM/OLE существуют книжки. Что это за книжки и которые из них можно рекомендовать для самостоятельного изучения COM?
· Чем моя рассылка будет отличаться от тех самых книжек?
Поскольку сформулированные вопросы - мои упущения в изложении, то в этой рассылке я постараюсь исправиться. Так, наверное, нужно делать и дальше - как только вопросов накапливается на самостоятельный выпуск, на них нужно ответить. В общем, я предлагаю такой механизм обратной связи. Вы, безусловно, можете присылать свои вопросы и предложения мне, мой адрес - vasilisk@nm.ru Эти вопросы и предложения будут влиять на дальнейшее изложение, возможно, и не в виде конкретного ответа на конкретный вопрос - плавность повествования тоже забывать нельзя. Если же вы хотите получить конкретный ответ на конкретный вопрос, то на сайте http://clubpro.spb.ru/ открыт Форум, на котором вы можете его сформулировать. Форум не требует регистрации, и на нём "тусуется" целая группа программистов, не только один я. Наша "группа профессиональных программистов" будет рада, если вы к нам присоединитесь и поможете нам отвечать на вопросы, или, наоборот, их задавать.
Начнём с ответа на первый вопрос - про языки программирования. COM - двоичная технология, т.е. всё равно на каком языке написаны клиент и сервер. Но действительно существуют предпочтения в отношении языков, на которых они на самом деле пишутся. И "средневзвешенная норма" выглядит здесь следующим образом. COM-серверы, преимущественно, пишутся на языках C и C++. Дело в том, что С и C++ - пожалуй, единственные языки, которые прямо и эффективно транслируются в машинные команды. "Машинно-эффективнее", чем C/C++ может быть только Ассемблер, но на Ассемблере невозможно писать большие программы и область его применения совсем другая.
На C/C++ труднее писать программы, чем на VB или Java, но COM - технология, предназначенная для повторного использования кода, т.е. эффективность реализации сервера, пусть даже ценой бóльших затрат программиста - оправданна в полном цикле "создание - использование". Ведь "написано однажды - работает везде". А на C/C++ программист в самом буквальном смысле слова может делать всё и очень коротко. Так как основные трудности и проблемы COM находятся на стороне сервера, а не клиента, то и возможность C/C++ закодировать всё, что угодно оказывается весьма полезной. Более того, проектные абстракции C++ прямо отображаются в абстракции COM, поэтому программист может мыслить в одной системе понятий, для него конструкции COM будут просто специализациями конструкций C++. Для нас в методическом плане это обстоятельство исключительно важно - практически всю "анатомию COM" мы сможем изложить не выходя за рамки C++ и не плодя сущностей сверх меры.
Сказанное не означает невозможности реализовать COM-сервер с использованием других средств. Например, современный VB позволяет создать проект, который будет являться COM-сервером. Это можно сделать быстро и просто, собственно, ничего "от самого COM" программист и не увидит, а сервер действительно будет работать. Но … машинная эффективность такого сервера будет невелика, а размер - огромен. Поэтому для индустриального производства COM-компонент такой подход не применяется, хотя в конкретном частном стечении обстоятельств вполне может быть использован. Случай Delphi, как VB-подобного инструмента, но - с настоящей компиляцией кода, является своего рода очень приятным исключением, которое, к сожалению, только подтверждает правило.
COM-клиенты, напротив, преимущественно пишутся на VB и скриптовых языках, типа VBScript. Конструкция COM-клиента - намного проще конструкции COM-сервера и в "общую неэффективность COM" клиент вносит весьма скромный вклад. Поэтому при разработке клиента важным обычно является скорость самой разработки. Это тоже соотносится с общей идеей - повторное использование кода клиента значительно менее распространено, чем повторное использование кода сервера.
И опять - сказанное не означает, что COM-клиент невозможно написать на C++. Можно, хотя, по сравнению с написанием его на VB это - ещё то удовольствие. Но в конкретном стечении обстоятельств, когда вам, скажем, требуется какая-то предельная вычислительная эффективность клиента (сортировка, поиск и т.д.) и в нем есть один-два вызова COM-объектов, вы, конечно, закодируете эти вызовы на C++ - эффективность остальной части клиента будет важнее.
В целом же, серверы обычно пишутся на C++, а клиенты - на VB. При выборе своего проектного решения об этом стоит помнить. Поэтому для нашей рассылки рабочими языками примеров будут C/C++ и VB/VBA. Уметь на них программировать - весьма желательно; если вы хотите быть профессиональным COM-программистом - совершенно необходимо, но для понимания нашего изложения качество владения языком программирования не очень существенно.
Какие технические средства и инструменты будут использоваться? Для С++ и VB - Microsoft Visual Studio 6.0a, как самый распространенный в этом деле инструмент. Это не означает, что примеры не могут быть реализованы с использованием других компиляторов, просто готовые проекты примеров будут сделаны именно в этом средстве. Естественно, что мы будем пользоваться и утилитами, входящими в комплект поставки Visual Studio. В качестве носителя VBA мы будем использовать Microsoft Excel - тоже достаточно распространенную программу.
В отношении же среды исполнения можно сказать следующее. Технология COM (тогда ещё называвшаяся OLE) была отлажена еще в Windows 3.1. И, будучи включенной в Windows 95, уже была свободной от ошибок в достаточной степени. Все дальнейшие операционные системы с платформой Win32 имеют встроенные средства поддержки COM хорошей степени отлаженности, и, главное, спецификация COM на них - в точности одна и та же. Поэтому гарантируется, что примеры COM будут работать одинаково во всех операционных системах Microsoft, начиная от Windows 95 OSR2. Впрочем, за Windows CE я поручиться не берусь, потому, что ни разу для нее компонентов не писал. Кроме того, ошибки операционной системы в уровне поддержки COM маловероятны еще и потому, что сама операционная система - компонентное изделие. В этом можно убедиться, заглянув в системный реестр в раздел HKCR\CLSID сразу после инсталляции только системы, до инсталляции всех других программ. CLSID, экспонируемых самой системой, там более, чем достаточно.
Какие существуют книги по COM/OLE и вообще, какие могут быть ещё источники информации? Я начинал изучение OLE в далёком теперь уже 1995 году по книге "OLE за 21 день", к сожалению, не помню сейчас её автора. Других книг на русском языке в то время не было, а эта книга доставила мне в высшей степени сумбурные представления. Во всяком случае, научиться по ней написать хотя бы один, но полностью свой, работающий объект я не смог. Основным источником информации для меня выступила Сеть и собственная работа.
Сейчас положение изменилось - с тех пор выпущена не одна хорошая книга. Лучшей я считаю книгу Дейла Роджерсона "Основы COM" - более толковой книги я не видел, во всяком случае в ней я нашел ответы на очень многие вопросы, которые у меня были. Неплохой для тех, кто пишет свои программы на C++, является и книга Эндрю Трельсена "Модель COM и применение ATL 3.0". Ну а классика жанра, конечно, книга Крейга Брукшмидта Inside OLE. В английском варианте она полностью входит в MSDN.
В собственной практике важным источником информации о том, как работает COM для меня явились исходные тексты ATL 1.1 - набора темплетных классов, который тогда только-только разработала Microsoft для облегчения тяжкой жизни программиста COM. Читая Дейла Роджерсона я вручную декомпилировал темплеты и изучал как реализуются те или иные конструкции COM. Лучшего примера кода я привести не могу! Сейчас ATL существует уже в третьей версии, так что примеров продвинутого кода только добавилось.
Чем рассылка будет отличаться от книг? Я думаю, что здесь могу задать и встречный вопрос вам, мои читатели. Количество читателей нашей рассылки достигло по состоянию на момент написания этих строк 2912 человек - для рассылки которой нет ещё и месяца это очень много. Рассылка популярна... вот и вопрос - почему? Почему вы меня читаете? Чем и как моя рассылка отличается от моря информации по нашей теме, которое сейчас есть и в Сети и в книжных магазинах? Нужно ли повысить уровень изложения, понизить, или оставить как есть? Что нужно было бы рассмотреть ещё, а рассмотрение чего есть пустая трата времени? Поскольку смысл моей работы по сочинению как раз и есть вы, то информация о точной настройке на ваш интерес будет исключительно важна для меня. Напишите мне!
Сам я считаю, что дефектом многих существующих учебников по технологиям программирования (это относится не только к COM, но и вообще к любой технологии излагаемой в современных учебниках) является то, что они начинаются "сверху" - "вызовите Wizard, отметьте в нём… поставьте… Wizard сгенерировал вам код…". Но в учебнике очень невнятно объясняется, почему Wizard сгенерировал именно такой код! Что означают те или иные макросы по ходу текста, как выглядит и сам протокол к которому Wizard строит реализацию. Словом, современные учебники пытаются обучить сложению используя в качестве наглядного пособия калькулятор - а как калькулятор выполняет сложение учебник не объясняет. С моей точки зрения это - тяжелейший порок, поскольку квалификация программиста определяется прежде всего пониманием философии, основ, концепций. Писать реально работающие программы, конечно, нужно с применением соответствующих инструментов. Писать вручную - анахронизм, часто выдающий дремучесть программиста. Но и владея распрекрасным инструментом всё равно нужно знать, как работает механизм, потому, что в какой-то момент времени может обнаружиться ошибка в самом инструменте, произойти несчастливое стечение обстоятельств и параметров, а тогда программист должен суметь, пользуясь своим общим знанием, отыскать то самое место, в котором возникает ошибка и исправить её.
Конечно, и COM имеет такие инструменты. Самых известных два - MFC и ATL. При умелом использовании они здорово облегчают жизнь. Но мы пока не будем ими пользоваться никак. Наша ближайшая задача несколько иная - понять, как функционирует COM на уровне конструкций своей программы и вызовов операционной системы. Весь код первого, самого примитивного, примера будет написан руками на "голом C++" с одной-единственной целью - понять и откомментировать каждую деталь, которую он реализует. В дальнейшем мы двинемся в двух направлениях - мы будем усложнять наш первый примитивный пример вводя в него всё новые конструкции, соответствующие нашему продвижению вперёд, и мы будем переписывать наш пример с использованием инструментальных средств, т.е. мы будем одновременно выяснять какие конструкции COM в себе инкапсулируют стандартные средства и как именно.
Кроме того - написание программ в компонентной модели не есть просто приделывание к готовой программе дополнительного служебного кода COM и только. "Компонентность" влияет на саму архитектуру модуля. Как и в каких случаях - это тоже тема нашей рассылки.
В любом случае, я прошу не полагаться как на источник знания только на рассылку. Читайте другую литературу по COM, сопоставляйте, ищите противоречия и неясные места. Задавайте вопросы.
А "Откуда суть пошли интерфейсы программные" мы рассмотрим в следующий раз...
Откуда суть пошли интерфейсы программные?
Итак, мы продолжаем наше изложение. Что такое интерфейс в его программном исполнении? Ответ на него может быть и длинным и коротким - мы видели, что даже объявление прототипа функции уже можно считать интерфейсом. И программирование пользуется этим очень давно.
В действительности же интерфейс стали называть отдельным специальным словом тогда, когда стало очевидно - сложность программных конструкций стала такой, что программист уже не может удержать во внимании оба взаимодействующих объекта. Пока это удавалось "объявление функции" выполняло страхующую роль - а вдруг где забыто чего? Но программист не чувствовал ни малейших неудобств в произвольном изменении списка аргументов функции - если по ходу развития программы требовалось этот список изменить, то он менял его в определении функции и в её объявлении. После чего запускал компилятор и тот показывал ему список мест, где нужно поправить.
Проблемы начинались тогда, когда, образно говоря, "задачи стали занимать более десяти тысяч строк". Эти задачи уже не помещались в один "программный проект". Классическим же примером этого опять является "клиент-сервер" - его наиболее удобная реализация как раз и выглядит в виде двух самостоятельных программных проектов - проекта клиента и проекта сервера. А границу между проектами компилятор преодолевать не умеет…
Это, конечно, случилось совсем не вдруг - разработчики языков программирования видели эту проблему и пытались предложить для нее адекватное своему времени решение. Например, в языке C++ есть понятие чисто абстрактного класса, которое совершенно явно вводит понятие интерфейса между объектами. Оно в самом буквальном смысле явилось предтечей интерфейса COM и на нём стóит остановиться подробнее. Давайте кратко рассмотрим откуда возникла эта проблема и в чём она состоит.
Рассмотрим класс (статический тип), описывающий объект сервера:
class Foo{
int a;
float b;
};
и рассмотрим код клиента:
Foo Cls;
Cls.a = 12;
Cls.b = 13.2;
Это, как теперь канонически признаётся - нехороший код. Нехорошо иметь прямой доступ к данным класса помимо самого класса. Что вот будет, если нам придётся переименовать a - уж больно это невнятный идентификатор? "По науке" нам нужно делать только так:
classFoo{
private:
int a;
float b;
public:
void SetA(int i){a = i;}
void SetB(float f){b = f;}
void SetAB(int i, float f){a = i; b = f;}
};
Теперь исполнить предложение в теле клиента:
Cls.a = 12;
не даст компилятор - данные-то у нас - private. Клиент придётся переписать:
Foo Cls;
Cls.SetA(12);
Cls.SetB(13.2);
В таком случае клиент наш может ничего и не знать о том, что такие переменные, как a и b в классе есть. Но для того, чтобы клиент мог быть откомпилирован, чтобы компилятор сумел правильно организовать вызов методов SetA и SetB, при его компиляции нам всё равно придется подавать на вход компилятору определение класса. Допустим, что мы поместили его в заголовочный файл myobj.h, который мы будем подавать компилятору и при компиляции кода сервера и, естественно, при компиляции кода клиента. Теперь у нас есть три файла исходного текста:
Первый:
//myobj.h - определение объекта Foo
classFoo{
private:
int a;
float b;
public:
void SetA(int i);
void SetB(float f);
void SetAB(int i, float f);
};
Второй:
//myobj.cpp - реализация методов объекта сервера
#include <myobj.h>
…
void Foo::SetA(int i){a = i;}
void Foo::SetB(float f){b = f;}
void Foo::SetAB(int i, float f){a = i; b = f;}
Третий:
//myclient.cpp - реализация кода клиента
#include <myobj.h>
…
Foo Cls;
Cls.SetA(12);
Cls.SetB(13.2);
Видите ли вы здесь неудобство, которое при этом возникает? А неудобство-то вот какое - при компиляции сервера нам действительно нужно знать всё про класс - и про его данные и про его методы. А вот при компиляции клиента нас интересуют только методы - данные-то сервера нам недоступны. И если в процессе развития сервера мы, скажем, добавили в класс еще одну, сугубо внутреннюю, переменную или внутренний метод, то понятно, что нам нужно будет перекомпилировать сервер. Но ведь нам также придется и перекомпилировать клиент - файл myobj.h ведь изменился! А вот сам клиент - не изменялся. А перекомпилировать клиент - придётся… Здесь это неудобство маленькое, но вот если и клиент и сервер - большие проекты, то эти сугубо локальные изменения могут вылиться и всегда выливаются в очень большие глобальные затраты. Ведь при изменении одного сервера приходится перекомпилировать всех его клиентов. Которые, при каком-то ощутимом их размере, уже не помещаются в единый проект и должны быть распределены по нескольким. Как быть? По сути, нам всего-то надо - разделить описания методов и данных, причем так, чтобы описание методов влияло и на клиент и на сервер, но полное описание класса - не влияло на клиент. Ведь из клиента мы видим только методы!
Это можно сделать воспользовавшись аппаратом абстрактных классов С++. Этот аппарат специально для этой цели и был сконструирован. Абстрактный класс, это класс который вводит только точные описания методов, причём их реализация откладывается до тех пор, пока от абстрактного класса кто-то не унаследуется. Вот тогда-то абстрактный класс заставит наследника в точности эти методы и реализовать! Попробуем усовершенствовать наше творение:
//myobjint.h - описание методов Foo
classFooInterface{
public:
void SetA(int i) = 0;
void SetB(float f) = 0;
void SetAB(int i, float f) = 0;
};
для плохо знающих C++ - нотация <объявление функции> = 0; как раз и есть нотация объявления абстрактного метода. Она означает, что метод в данном классе не реализуется и компилятору об этом можно не беспокоиться. Но вот в каком-то производном классе компилятор должен найти реализацию этого метода - именно такого и именно с таким объявлением параметров.
Далее, переписываем наши файлы:
Первый:
//myobj.h - определение объекта Foo
#include <myobjint.h> //включили описание от которого наследуемся
class Foo : public FooInterface{
private:
int a;
float b;
public:
void SetA(int i);
void SetB(float f);
void SetAB(int i, float f);
};
Второй:
//myobj.cpp - реализация методов объекта сервера
#include <myobj.h> //включили описание всего объекта
…
void Foo::SetA(int i){a = i;}
void Foo::SetB(float f){b = f;}
void Foo::SetAB(int i, float f){a = i; b = f;}
Третий:
//myclient.cpp - реализация кода клиента
#include <myobjint.h> //включили только описание как вызывать объект
…
Foo Cls;
Cls.SetA(12);
Cls.SetB(13.2);
Мы запускаем третий наш файл myclient.cpp на компиляцию и… файл не компилируется! Компилятор сообщает, что класс Foo компилятору неизвестен. Верно. Нет у нас такого класса. У нас вместо него теперь - FooInterface, заменяем, компилируем. Стало ещё хуже - компилятор заявляет, что он вообще не может создать объект такого класса, т.к. класс… абстрактный… Это - очень интересное заявление! В чём же дело?
А дело вот в чём. FooInterface - действительно абстрактный класс. У него нет ничего, кроме объявления как вызывать методы. Методы чьи? Да наследника же, своих-то нет! Поэтому мы и не можем создать объект абстрактного типа - нет в нем ничего, что вызывать - неизвестно. Зато - совершенно точно описано - как вызывать. И, если мы получим каким-то образом указатель на наследника, то пользуясь спецификацией "как вызывать" предоставляемой абстрактным классом, мы все сделаем отлично:
//myclient.cpp - реализация кода клиента
#include <myobjint.h> //включили только описание как вызывать объект
…
FooInterface * pCls;
//здесь нужно как-то получить значение для указателя pCls
pCls->SetA(12);
pCls->SetB(13.2);
И теперь, как бы ни развивался объект сервера, если спецификация абстрактного класса не изменялась нам ничего не нужно в клиенте изменять - ни в тексте, ни в его коде. Всё будет работать! Здорово? Осталось только-то получить указатель. Например, можно использовать оператор new:
pCls = new Foo();
Но… статический тип Foo нам на стороне клиента неизвестен. И пока мы не включим в состав своего клиента описание myobj.h нам не исполнить этого new. А ведь именно включения этого файла в код на стороне клиента мы и хотели избежать. Выходит, хотели как лучше, а получилось - как всегда? И что делать?
Прежде чем продолжить, я хочу особо отметить - описанное обстоятельство "свернуло шею" не одному программисту! Разнести описания клиента и сервера по разным файлам - очевиднейшее решение, оно недостойно даже особого упоминания. Вот только что делать с этим потом?
А вот этот вопрос не имеет прямого и однозначного ответа! Например, если наш код клиента - функция, то можно передать этот указатель как аргумент при вызове, только где-то этот указатель первично получать всё равно придётся. Что вообще можно сделать? К сожалению, нужно признать, что в данном случае и сделать ничего нельзя. Причина этого - исключительно философская. Причина в том, что объект сервера Foo мы пытаемся создать в коде клиента - ведь new исполнятся на клиентской стороне? А поэтому абстрактные классы здесь нам помогут очень слабо - где-то, где будет выполняться new, всё равно потребуется иметь описание статического типа Foo, т.е. если тотальной перекомпиляции всех файлов проекта (-ов) ещё можно избежать, то вот тотальной перелинковки - никогда.
Именно поэтому аппарат абстрактных классов введённый в C++ и не стал действительно аппаратом абстракции. Он - очень полезен при проектировании большой иерархии классов, но его власть над реализацией всё равно не может выйти за пределы одного проекта.
Вот если бы сохранив преимущества абстрактного класса - точное описание "как вызывать" ещё и как-то избавиться от его недостатков - от необходимости на клиентской стороне знать "что вызывать" мы бы получили… мы бы получили интерфейс. Ведь абсолютное безразличие к "что", но с точным определением "как" это и есть интерфейс! И об этом - следующая рассылка.
Клиенту - клиентово, а серверу - серверово
Из прошлой рассылки мы узнали, каким именно образом абстрактные классы послужили прообразом и предтечей интерфейсов. А так же - почему они полноценными интерфейсами стать так и не смогли. Углубим изложенное и попробуем найти выход из того противоречия, что мы обнаружили.
Мы обнаружили - абстрактный класс очень хорошо, просто замечательно, описывает технологию взаимодействия с объектом любого производного статического типа, выстроенного на базе этого абстрактного класса. Да только вот всё равно - для полноценного обращения с объектом знания абстрактного класса недостаточно, нам обязательно нужно знать ещё и полное описание этого класса.
Как избавиться от этого недостатка? Давайте подумаем. И подумаем не так, как принято в стандартном "по-проектном мышлении", а - именно с позиций подхода "клиент-сервер".
Во-первых, очевидно, что абстрактный класс нисколько не мешает обращаться к объекту, какого бы типа он ни был. Нужно просто иметь указатель на объект (это будет "что") и пользуясь абстрактным классом вызывать его методы (это будет "как"). Поскольку получить указатель - совершенно рядовая операция, трудность находится не в указателе. А как раз в том, на что он указывает. Мы не можем сделать объект оператором new потому, что оператор new должен знать не абстрактный базовый класс, а полное описание. Стоп! "…не можем…" где? Мы не можем это сделать на стороне клиента, т.к. мы и хотим избежать на стороне клиента именно полного описания класса! Но ведь на стороне сервера мы и не собирались этого избегать… Нас вполне устраивает, что неполное описание класса на стороне клиента, позволяет клиенту только лишь с объектом взаимодействовать. Вот, если бы еще сервер сам и делал этот объект, а потом передавал клиенту только указатель на него..?
Это - правильная идея. Вся наша проблема только и заключается в том, что мы не можем породить объект сервера на стороне клиента. Но ведь это не означает, что мы не можем из клиента попросить сервер сделать нам объект указанного типа? Делаем в составе сервера специальную особую функцию, передаем ей тип объекта, который мы хотим получить. Сервер - заведомо знает все объекты, которые он реализует. Следовательно, сервер запрошенный объект создаст, передаст указатель клиенту, клиент применит к нему правила обращения, описываемые абстрактным классом - и проблема-то решена. Верно?
Нет! В C++ статический тип исчерпывающе описывается классом. А абстрактный класс - "не тот тип", иначе его бы не отказался использовать и сам оператор new. А new - отказывается. Поэтому для "функции сервера, умеющей созвать объекты" абстрактный класс будет всё так же неприменим, как и для "функции клиента".
Но и это - одолимая беда. Сервер же не реализует бесконечное множество статических типов объектов? Перенумеровать их - и всего-то. И запросы ставить к нему - "сделать объект типа номер один", "сделать объект типа номер три"… Только здесь возникает вот какое обстоятельство - чтобы корректно использовать "перенумерование" мы, фактически, должны использовать пары "статический тип на стороне сервера - абстрактный тип на стороне клиента" и перенумеровывать уже пары. А это - еще хуже, поскольку малейший сбой в соответствии частей останется незамеченным никем, но в большом проекте приведет к тому, что будет непонятно даже где ошибку и искать… Тупик?
Пройдемся ещё раз - по шагам. Мы не можем создать объект даже "внутри сервера" потому, что "внутри клиента" не знаем описания его статического типа … "внутри сервера". Это мы так захотели - мы не хотим на клиенте об объекте сервера знать больше, чем тот абстрактный тип, который позволяет с этим объектом взаимодействовать. Меньше знаешь - реже перекомпилируешься. И отказываться от этого требования мы не собираемся. Но, с другой стороны, сервер-то как раз знает и полное описание статического типа, и тот абстрактный тип, на базе которого этот статический тип построен. Почему бы нам не отправлять на сервер запросы примерно такого вида - "создать объект статического типа номер четыре, построенный на базе абстрактного типа XXX"? У сервера эта информация есть. Пусть не мы на клиентской стороне, но сам сервер отслеживает соответствует ли номер статического типа абстрактному типу и может ли он такой объект создать. Поэтому, похоже, выход находится здесь - на стороне клиента нумеровать (именовать не описанием классов) статические типы и отправлять на сервер запросы из пар "номер объекта - абстрактный тип". Когда сервер "сделает из номера" настоящий объект и передаст указатель на него клиенту, то клиенту будет уже всё равно, какой там у этого статического типа был номер, он будет применять при доступе по этому указателю исключительно абстрактный тип, который он заказывал. Эврика? Эврика…
Можно, конечно, и пофилософствовать на эту тему. Но, по-моему, всё было сказано значительно раньше. Это - в точности та схема, тот протокол взаимодействия, который мы рассматривали в рассылках №№ 5 и 6 - "С чего начинается COM?". И это - всё та же самая проблема, только уже при взгляде "изнутри", а не "снаружи". И способ её решения, фактически принятый в системе, тогда же и был показан. Только вот тогда ничего не говорилось ни об абстрактном классе, ни о том, что его надо как-то передавать серверу… Вылез там только какой-то riid… И лишь сейчас стало понятно почему он вылез - потому, что тогда мы как само собой разумеющееся считали, что "как объекты могут взаимодействовать друг с другом - они и сами знают". А на самом-то деле это было весьма голословное утверждение! Ведь способ их взаимодействия должен быть как-то описан компилятору на клиентской стороне, чтобы он мог правильно откомпилировать вызовы? И абстрактный класс для этого описания - очень подходящая кандидатура!
Всё бы хорошо, да вот... Абстрактный класс - понятие времени компиляции. А создание экземпляра статического типа - понятие времени выполнения, когда никакого абстрактного класса уже и в помине нет. Способ борьбы понятен - перенумеровать и абстрактные типы так же, как перенумеруются сами статические типы, и передавать серверу пару "номер статического типа - номер абстрактного типа". Только способ этот не сработает - потому, что абстрактный класс есть понятие времени компиляции. В откомпилированном модуле нет никаких классов, а есть "данные + код". Пока связывание было "ранним" - на этапе компиляции и линковки, такой способ годился. Потому, что все описания классов приводились компилятором к таблицам смещений, которые он сам же и подставлял в код. А сейчас у нас связывание "позднее" и те самые таблицы компилятора уже давно не существуют… Иными словами наш славный абстрактный класс не годится только потому, что он существует лишь "в воображении компилятора" и не существует во время исполнения. И "номер абстрактного типа" - номер сущности, которой нет. А есть ли "абстрактные структуры", существующие и во время исполнения тоже?
Есть! Есть такие структуры и самое парадоксальное, что они как раз и были придуманы в языке C++ для решения именно той самой проблемы, которую мы с таким трудом пытаемся решить - как сказать компилятору "как" не говоря "что". Как до поры разделить построение и использование объекта.
Для "посвящённых" здесь нужно сказать только два слова - "виртуальные функции" и они сразу всё поймут, что и зачем нужно сказать дальше. Для "менее посвященных" - экскурс в два абзаца в "инкапсуляцию, наследование, полиморфизм".
С++ - мощный язык. Мощный в том смысле, что он позволяет "по человечески естественно" выражать генетические отношения между статическими типами. Это - азбука C++ и на эту тему давно написано много, со вкусом, и в разных стилях. Так что повторять это здесь даже как-то и неудобно. И есть в C++ одна интересная особенность - в иерархии наследования можно определить такую конструкцию, что программа, располагающая объектом базового класса во время исполнения будет вызывать методы не самого этого, но производного от него класса. Традиционно этот случай иллюстрируется примером, когда определяется класс "геометрическая фигура", от него наследуются классы "круг", "треугольник", "квадрат" и в базовом классе определяется метод "рисовать фигуру". Но - какую именно фигуру класс "геометрическая фигура" не знает, зато это знают класс "круг" и другие - ведь они-то себя как раз рисовать и умеют. Как сделать по человечески совершенно естественную вещь - сделать так, чтобы один и тот же метод "рисовать фигуру", принадлежащий классу "геометрическая фигура", ничего не знающему о том, какие классы ещё только будут от него произведены, начал "рисовать фигуры" производных классов?
Столь сложная, на первый взгляд, проблема решается до неприличия просто - вместо простого внутреннего смещения метода "рисовать фигуру" в базовом классе компилятор генерирует настоящий указатель. И генерирует к нему пометочку - адрес метода производного класса поместить в этот указатель, принадлежащий базовому классу. Так что, когда от данного базового производится класс-потомок, компилятор видит, что адрес метода потомка нужно занести и в этот самый указатель тоже. Когда программа будет вызывать такой метод базового класса она, фактически, совершит косвенный переход по указателю на метод не базового класса, но - его потомка. Это - классика, о ней подробно можно прочитать в любом учебнике по C++, куда желающие отсылаются за бóльшими подробностями. Для нас же важно понять, что компилятор генерирует указатель, т.е. структуру, которая в таком случае существует как раз во время исполнения. А это - именно то, что нам требовалось - если сделать абстрактный класс виртуальным, то мы получим нужную нам абстрактную структуру существующую и после окончания компиляции.
Поэтому общее решение возникшей перед нами проблемы как разделить сервер и клиент будет таким:
· описывать интерфейс абстрактным виртуальным классом;
· иметь в составе сервера специальную "внеклассовую" функцию, которая создавала бы объекты запрошенного типа с возвращением клиенту только указателя на них;
· передавать этой функции запросы, состоящие из пар "номер статического типа - номер интерфейса";
· взаимодействовать на клиентской стороне с объектом сервера только по ссылке на указатель.
Это - очень важное обстоятельство. Это - фундамент нулевого уровня на котором построен COM. Все ли читатели понимают, почему абстрактный класс обязательно должен быть виртуальным и почему невиртуальный абстрактный класс непригоден для наших построений? Понимающие - пропускают следующую рассылку, не понимающие в следующий раз увидят всё это в картинках. В следующей рассылке - "Введение в теорию компиляции..."
0 коммент.:
Отправить комментарий