developing.ru - клуб программистов Здесь может быть и ваша реклама.
developing.ru >технология COM >
Михаил Безверхов
aspid@developing.ru

...И гений, интерфейсов друг...

За свою долгую историю человечество обнаружило, что существуют, как я их называю, "парадоксальные вопросы" - вопросы, в которых всем очевидный ответ на них оказывается неправильным, а правильным как раз оказывается ответ совершенно противоположный, и для того, чтобы в этом убедиться требуется совсем несложное логическое построение. Тем не менее, видимо, "самоочевидность правильного ответа" всегда и заводит совсем в другую сторону. Примеры? В 18-м веке таким вопросом был "Птица ли курица?", в 19-м - "Человек ли женщина?", зато в 20-м уже - "Программист ли тот, кто пишет на VB?". Определённый прогресс, как видим, налицо :)

Тема данной статьи: "Существует ли наследование в COM?" - тоже является парадоксальным вопросом. Многие и многие убеждены, что нет, хотя совсем несложно показать что существует и, даже, - в значительно большем объёме, чем, скажем, в C++.

Начнём, как водится, с самого начала. На протяжении всей серии утверждалось, что COM - полноценная реализация ООП, т.е. в ней сполна присутствуют "инкапсуляция, наследование, полиморфизм". Но для того, чтобы это разглядеть, требуется отступить от привычных стереотипов реализации в более высокую, философскую сторону. Именно тогда, например, можно заметить, что "интерфейс" это более высокоранговое понятие, чем "абстрактный класс", хотя в данном частном случае он именно через абстрактный класс и реализуется. Совершенно аналогично дело обстоит и с понятием "наследование" - оно больше стереотипной возможности просто инкорпорировать в некий класс базовые классы.

Но что считать наследованием в философском смысле? Первоначально слово это происходит из гражданско-правового лексикона и обозначает механизм, который позволяет наследнику воспользоваться достоянием своих предшествеников. Наследнику для этого нужно только состоять в особого рода отношениях с наследодателем - и всё. Техническая трактовка его в ООП не изменяет высказанной выше сути - объект X, состоящий с объектом Y в отношениях "X последователь Y" должен иметь возможность воспользоваться состоянием и поведением, уже реализованным в объекте Y просто по факту состояния в этих отношениях - это действительно то, что выражает термин "наследование", поэтому его употребление здесь совершенно корректно.

А вот как именно и чем именно воспользоваться - здесь термин ничего не говорит. Поэтому и реализации наследования могут быть разными. Стереотипная, конечно, это реализация "как в C++" - наследник в своём объявлении ссылается на предка, в результате чего компилятор начинает считать, что в классе-наследнике как бы объявлены также и всё методы и данные его базовых классов. То, что существуют некоторые ограничения видимости на это наследование не меняет сути - в частном случае этих ограничений может просто не быть и тогда это действительно "все методы и все данные".

Но, как мы рассматривали в первых статьях нашей серии, к сожалению, это только "компилятор считает". Поскольку объект это - реализации методов в сегменте кода и данных в области памяти для данных, поскольку на выходе компилятора порождается монолитный код, то дело и обстоит именно так, что "как бы объявлены" - объект-наследник в исполняемом коде есть простое объединение и своего и наследственного в одну сущность. Поэтому она нисколько не будет отличаться от той сущности, которая получилась бы в результате объединения всех описаний наследующих классов в один вручную. Так как такое объединение компилятор делает автоматически, то для программиста на входном языке это - вполне настоящее наследование.

Тем не менее, обратим своё внимание на то, что описанное выше - только лишь т.н. "наследование поведения". Хоть объект-наследник и пользуется функциональностью базовых классов "просто по факту объявления", в двоичном-то коде они являются его неотъемлемой частью. Каждый двоичный модуль в котором содержится реализация класса-наследника включает в себя и реализации всех его предков. И сделать как-то иначе - не получится. Чтобы в этом убедиться, достаточно попытаться разместить класс-наследник в одном модуле, а базовые его классы - в другом. Если базовые классы не экспортируются, то ничего не выйдет - линкер не найдёт составных компонентов реализации. Т.е. наличие "наследования" на уровне программиста и входного языка не приносит никаких преимуществ на уровне двоичного кода - что с наследованием пиши, что без.

Между тем, для компонентной технологии характерен другой подход, в котором реализации наследника и его предков располагаются в разных модулях. Только в этом случае достигается вся мощь наследования - не только программист мыслит в соответствующих абстракциях, но и сами программные объекты могут быть физически расчленены. К чему это приводит, наверное, повторяться излишне - можно, например, в работающей системе заменить только тот компонент, в котором была обнаружена ошибка, а остальные оставить как есть. Такая возможность называется "наследование реализации", она как раз и означает, что наследнику совершенно излишне знать, как именно и где именно реализованы его предки. Он, образно говоря, просто "присоединяет" их готовую реализацию к себе и всё. Если в дальнейшем реализации предков изменятся, но не изменится интерфейс такого "присоединения", то наследник ничего и не узнает, а будет функционировать так же, как и раньше. Конечно, понятно и то, что в данном подходе наследник и его предки вовсе не обязаны быть написаны на одном языке, что тоже является сильным достоинством наследования реализации.

Существует и третий вид наследования - "наследование интерфейса", о котором будет говориться немного ниже здесь же. Он стоит немного особняком, потому, что интерфейс - категория абстрактная и может быть отвязана от реализации.

В COM, как в компонентной технологии, простое наследование поведения неприменимо. Хотя бы потому, что, по определению, готовый для использования компонент уже должен находиться в двоичном виде. И для его использования не должна требоваться обработка компилятором или линкером. Должна также сохраняться возможность использовать базовые объекты непосредственно клиентом, т.е. они тоже должны быть полноценными COM-объектами. Поэтому всё наследование в COM делается на основе наследования реализации - COM-объект - наследник может "присоединить" к себе готовую функциональность другого COM-объекта.

Делается это в COM двумя способами - "агрегированием" и "включением". Что касается первого, то именно агрегирование и есть наиболее точная возможность "воспользоваться функциональностью предков только по факту состояния в отношениях". Включение же является не совсем точным "наследованием", т.е. оно позволяет унаследовать реализацию, но достигается это не одним только фактом состояния в отношениях, а ещё и некоторыми действиями на уровне кода самого наследника. Кроме того, нужно немедленно отметить, что хотя всё время употребляется термин "объекты", на самом деле и агрегирование и включение производятся на уровне отдельных интерфейсов этих COM-объектов, и можно сделать так, что какая-то функциональность COM-объекта будет исключена из наследования.

Подробные технические детали агрегирования и включения мы рассмотрим в следующей статье, а сейчас обратим внимание на существенные свойства одного и другого способов унаследовать реализацию. В COM нет возможности объявить метод c атрибутом protected или private. Все методы, которые экспонируются vtbl являются методами public. Если объект-наследник желает присоединить к себе всю функциональность некоего интерфейса базового COM-объекта, то он может (и - должен попытаться) использовать агрегирование. Базовый объект может в этом отказать (т.е. не поддерживать агрегирование, поскольку для этого требуется совершение специальных действий со стороны самого агрегируемого объекта). Если это случилось или если наследнику требуется экспонировать не все методы базового интерфейса, то наследнику ничего не остаётся, как использовать включение.

Включение является наиболее очевидным способом использовать готовую реализацию методов. И суть и реализация его в том, что в своей vtbl наследник экспонирует свой метод, весь код которого есть только вызов метода базового объекта, выполняющего необходимые действия. Иными словами, это простое делегирование вызова. Понятно, что именно в данном случае интерфейс-"наследник" может показать не все, а только те методы, которые вызывают лишь нужные методы базового интерфейса. Понятно и то, что включение возможно всегда - для того, чтобы наследник его реализовал не требуется никаких специальных действий базового объекта. Все экспонируемые методы для клиента являются методами интерфейса-наследника и никаких других интерфейсов клиент может и не знать.

Строго говоря, включение не является "наследованием" по факту состояния в отношениях с базовым объектом - для его реализации наследник должен выполнить какие-то действия, бОльшие простой декларации. Однако, включение вполне является использованием уже готовой реализации про которую наследник (который выступает клиентом базового объекта) может ничего и не знать, кроме интерфейса вызова. В языке C++ включение тоже возможно (очевидно!), но не рассматривается, как случай "наследования". Понятно, почему - и наследование по одной только декларации и включение приводят к одному и тому же результирующему коду, но в первом случае программист "ничего не делает", а во втором - должен писать код. А это, с позиций языка C++, никаким "наследованием поведения" не является.

Агрегирование же можно, с определённой долей условности, назвать "включением чужой vtbl в свой интерфейс". При агрегировании дело объектом обставляется так, что агрегируемая vtbl (интерфейс) становится продолжением vtbl (интерфейса) своего. Конечно, "приписывания" одной vtbl в продолжение другой не происходит, но обе vtbl, с точки зрения клиента, будут являться одним и тем же интерфейсом. Например, если клиент имеет дело с двумя разными объектами, то он имеет два указателя на разные интерфейсы IUnknown, вынужден вызывать разные методы QueryInterface и т.д. Если же объекты агрегированы, то они для клиента представляются одним интерфейсом IUnknown у которого имеется только один метод QueryInterface, "знающий" не только "свои" интерфейсы, но и интерфейсы агрегированного COM-объекта. Т.е. внешне, с точки зрения клиента, агрегирование делает из двух интерфейсов один. Как это делается - тема следующей статьи, это - несложно, но довольно изящно по самой идее, которая лежит в основе.

Именно агрегирование, если говорить строго, является в COM "наследованием по одной только декларации". Но, в отличие от того же C++, в котором наследуют друг другу статические типы в COM наследуют экземпляры статических типов - в принципе, во время исполнения, любой COM-объект может агрегироваться с любым другим COM-объектом, независимо от того, в каком отношении эти объекты находились во время, когда их проектировали. И если в C++ наследование - статическая операция времени компиляции, то в COM понятие "время компиляции" отсутствует. Все свои действия COM-объекты производят во "время исполнения", агрегирование в этом смысле - не исключение. Понимание этого - весьма важно, поскольку нигде, кроме как самого фактического кода, который реализует агрегирование, не нужно никакой специальной декларации о том, что один объект агрегирует другой.

Агрегирование требует специального кода на стороне агрегируемого объекта и не требует никакого специального кода на стороне объекта агрегирующего. Именно поэтому всякий объект может попытаться агрегировать другой объект, но не всякий объект может быть агрегирован. Включение же, напротив, требует специального кода на стороне агрегирующего объекта и не требует никакого кода на стороне объекта агрегируемого. Поэтому всякий объект может быть унаследован путём включения, ведь соответствующий код в наследнике всегда можно написать.

Сказанное выше касалось "наследования реализации", т.е. наследования кода, который что-то делает в составе своего объекта. Однако, в ООП известно и "наследование интерфейса" - наследование простого объявления методов базовых интерфейсов, которые должны быть реализованы в интерфейсе-наследнике. Само по себе это наследование - чистая декларация и осуществляется оно в COM наследованием абстрактных классов, описывающих интерфейсы. В этом случае можно говорить о том, что одна vtbl продолжает другую, но следует иметь в виду, что если в объявлении класса не применяется конструкция __declspec(novtable), то компилятор сгенерирует не только результирующую объединённую vtbl, но и все vtbl базовых классов, которые не имеют конструкции __declspec(novtable). Что, соответствующим образом, утяжеляет код - об этом стоит помнить.

Наследование интерфейса - обязательство реализации реализовать не только "свои" методы, но и методы базовых абстрактных классов. Как она будет это делать - декларацию интерфейса не касается. Но очевидно, что каждому методу интерфейса-наследника (и всех методов базовых интерфейсов в него входящих) следует определить реализующий метод обычного класса. Т.е. реализация наследования интерфейсов может быть сделана только путём включения и не может быть сделана путём агрегирования, поскольку на этапе компиляции компилятор заявит, что увидел абстрактный метод, которому в данном коде он не нашёл реализации.

Когда какой способ наследования следует применять? Если интерфейсы X и Y связаны синтаксически, т.е. методы X не имеют никакого смысла без того, чтобы ранее не были определены методы Y, и это обстоятельство не зависит от реализации (т.е. любая реализация будет вынуждена поступать именно так и никак иначе), то в данном случае имеет место быть наследование интерфейса. Требование COM, чтобы всякий интерфейс реализовывал в себе IUnknown есть хороший тому пример - всякий интерфейс обязан наследовать интерфейсу IUnknown, каким бы образом он ни реализовывался.

Если же, напротив, интерфейсы X и Y связаны семантически, т.е. смыслом именно такой, а не какой-то иной реализации, то в данном случае имеет место быть наследование реализации. В каком виде: в виде агрегирования или в виде включения - зависит от обстоятельств. Технических, маркетинговых или иных. Теоретически, оба способа имеют равные выразительные возможности, но несколько разнятся по предъявляемому результату. Например, если базовый интерфейс экспонирует десять методов, а вам в интерфейсе-наследнике следует показать клиенту только один из них, то, вероятно, включение будет более предпочтительным способом, нежели агрегирование - хотя бы из тех соображений, что клиенту не стоит показывать лишнего.

Сказанное выше имеет последствия на уровне проектирования COM-объектов. Ранее мы полагали, что проектирование COM-объекта это проектирование интерфейса(ов) и их реализация наиболее подходящим способом. Теперь можно пересмотреть прежний взгляд - проектирование оказывается проектированием сразу группы COM-объектов, наиболее удачным образом покрывающих заданную техническим заданием область. Оно по-прежнему распадается на "проектирование интерфейсов", которое остаётся проектированием минимального множества способов взаимодействия и "проектирование объектов", которое становится проектированием минимального достаточного множества объектов. Однако - теперь с учётом того, что эти объекты могут быть повторно использованы не только непосредственно клиентом и отнюдь не в одном качестве.

На этом всё. В следующей статье - подробности реализации включения и агрегирования.

предыдущий выпускархив и оглавление
Авторские права © 2001 - 2004, Михаил Безверхов
Публикация требует разрешения автора

разделы сайта

форум

технология COM

оптимизация сайтов


© 2000-2004 Клуб программистов developing.ru