Как известно, объектно-ориентированное программирование — это методология, основанная на представлении программы в виде совокупности объектов, каждый из которых является экземпляром того или иного класса. Объект — это сущность, содержащая в себе совокупность данных (состояние), совокупность методов или операций (интерфейс) и совокупность алгоритмов для выполнения этих операций (функциональность). Благодаря использованию данной методологии достигается существенное упрощение написания, изменения и понимания программ.

Распределенная система — это совокупность программ, выполняемых одновременно в различных процессах (и, возможно, на различных вычислительных машинах в сети). Программы могут обмениваться данными и вызывать функции других программ. Использование объектного подхода позволяет упростить и формализовать структуру таких систем. Разработано несколько стандартов архитектуры объектно-ориентированных распределенных систем; наиболее известны DCOM и CORBA.

Одно из важнейших понятий объектно-ориентированного программирования — наследование, т. е. создание новых классов с использованием уже существующих [2]. Наследование позволяет реализовать типизированное повторное использование объектов, создание иерархии «тип-подтип» с сохранением подтипом всех свойств своего предка. Можно рассматривать наследование для всех трех составляющих объекта.

  • Наследование интерфейса. Объект-потомок наследует у родителя его интерфейс, возможно, пополняя его новыми методами. Однако потомок не использует никакой функциональности предка и вынужден самостоятельно реализовывать все методы. Такое наследование допускает возможность автоматического преобразования типа от потомка к предку и создание иерархии типов. Наличие подобной иерархии позволяет уменьшить количество интерфейсов в системе и упростить ее структуру.
  • Наследование функциональности. Потомок наследует не только интерфейс, но и код методов. Такое наследование имеет принципиальное значение для повторного использования кода.
  • Наследование состояния. Для того чтобы использовать функциональность методов предка для потомка, необходимо, чтобы структура данных потомка содержала все поля данных предка. Наследование функциональности обычно требует использования наследования состояния, поэтому эти два типа наследования можно объединить в один - наследование реализации. Такой механизм наследования используется для объектов в большинстве современных языков программирования, в том числе, в C++ и Java [1]. Однако в распределенных системах наследование почти никогда не применяется.

На данный момент большинство стандартов построения объектных распределенных систем вообще не предполагает использования наследования или позволяет использовать лишь наследование интерфейсов (например, стандарт CORBA). В то же время в обыкновенных приложениях наследование реализации активно используется, считаясь очень эффективным механизмом.

Таким образом, важной становится задача разработки способов, позволяющих использовать наследование в распределенных системах. При этом желательно придерживаться принципов схемы наследования какого-либо из популярных языков программирования. На мой взгляд, наиболее подходящей для этой цели является схема наследования языка С++. Этот язык очень популярен, а механизм наследования в нем прост и одновременно очень мощен. В качестве примера стандарта построения распределенных систем удобно использовать стандарт CORBA (см. www.omg.org). Этот стандарт развивается, а число разработанных на его основе систем растет; кроме того, CORBA предоставляет разработчикам больше возможностей, чем большинство других стандартов. Вместе с тем, желательно разработать такое решение, которое с незначительными изменениями можно было бы применить для распределенных систем других стандартов.

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

Простейшее наследование реализации

Простейший вариант наследования реализации предполагает использование концепции делегирования: объект имеет ссылку на своего предка (предков), а тело метода, унаследованного от этого предка, состоит из переадресации вызова по ссылке. Таким образом, вызов передается от потомков к предкам, пока не будет найден объект, который способен реально выполнить этот вызов.

Несмотря на всю простоту и очевидность такого подхода, у него есть серьезный недостаток при использовании в распределенной системе: вызов передается от объекта к его предку. Но и этот предок, в свою очередь, может передавать вызов своему предку. Если иерархия наследования достаточно глубока, на один вызов метода приходится несколько последовательных удаленных вызовов. Подобные вызовы требуют значительных ресурсов, скорость работы системы может упасть в несколько раз. Следовательно, рассмотренный способ может оказаться очень неэффективным. Рассмотрим простейший пример. Ниже приведены три интерфейса на языке определения интерфейсов IDL [3].

Допустим, при создании объектов, реализующих данные интерфейсы, использовалось наследование реализации. Тогда вызов метода op_A() из объекта C будет переадресован в объект B, а оттуда — в объект A. Такая последовательность вызовов в распределенной системе потребует значительных затрат ресурсов и времени.

Менеджеры наследования

Чтобы избавиться от указанной проблемы, можно предложить немного иной механизм наследования, похожий на механизм виртуальных функций в C++ [1]. Для каждого объекта создадим служебный объект — так называемый «менеджер наследования». Все вызовы методов объекта должны быть направлены его менеджеру наследования; тот содержит «таблицу методов», по которой определяет, какой объект должен выполнить соответствующий метод, и осуществляет перенаправление вызова. В таблице методов должны быть ссылки на объекты, реально способные выполнить данный вызов, а не унаследовавшие его функциональность от своих предков. (Под «реальным выполнением вызова» мы понимаем обработку вызова методом, не относящимся к менеджеру наследования данного объекта; конечно же, эта обработка также может заключаться в перенаправлении вызова другому объекту, но это перенаправление делается «за пределами» менеджеров наследования и не может быть учтено при их создании.) Таким образом, с помощью предложенной концепции возможно вызвать любой метод объекта через один уровень косвенности вне зависимости от того, насколько «глубоко» в иерархии наследования определена его функциональность.

Таблица методов и ее инициализация.

Служебный интерфейс менеджера

Рассмотрим подробнее структуру таблицы методов. Ее основная задача — возвращение по имени метода объектной ссылки, по которой должен быть переадресован вызов этого метода. Также хотелось бы иметь возможность динамически изменять эту таблицу. Можно разрешить изменять каждый метод таблицы отдельно, устанавливая зависимости вида <имя метода, объектная ссылка>. Однако такой подход ведет к нарушению объектной ориентированности. Появляется возможность так изменить таблицу методов, что методы одного интерфейса (т.е. принадлежащие одному объекту, реализующему данный интерфейс) будут перенаправлены на выполнение в разные объекты распределенной системы. Поэтому имеет смысл ограничить возможности изменения таблицы методов, разрешив динамически устанавливать зависимости вида <имя интерфейса, объектная ссылка>. Как будет показано, такой подход гораздо удобнее и при практической реализации менеджеров наследования.

Рис. 1. Цепочка наследования

Итак, в таблице методов должны храниться ссылки на все объекты, использованные при наследовании реализации (не только на непосредственных, но на всех предков). Так, для приведенного выше примера менеджер наследования объекта B должен содержать ссылку на объект, реализующий интерфейс A, а менеджер наследования C — ссылки на A и B. Инициализация этих ссылок должна проводиться явно, так как в распределенных системах (по крайней мере, в системах стандарта CORBA) нет единого механизма создания новых объектов и невозможно автоматически создать «новых предков» при создании потомка. Вместе с тем, перекладывать на программиста задачу поиска всего множества ссылок не представляется разумным. Скажем, в приведенном примере программист, создающий объект C, наследуя его от объекта B, может вообще не подозревать, что реализация объекта B унаследована от объекта A, и что метод op_A надо направлять не в B, а в объект A, который и способен реально его выполнить. И нельзя требовать от программиста предоставить менеджеру наследования объекта C ссылку на A, так как он может вообще не работать с объектами, реализующими интерфейс A, и взять ему такую ссылку негде.

Но, заметим, менеджеру наследования объекта B известна ссылка на объект A (она была ему предоставлена при инициализации его менеджера). Значит, менеджер наследования объекта C может получить необходимую ему ссылку от своего непосредственного предка. Программисту требуется только предоставить ссылку на непосредственного предка (предков при множественном наследовании), а ссылки на предков более глубокого уровня менеджер наследования получит от менеджера непосредственного предка. Для того чтобы общение между менеджерами было возможно, необходимо ввести специальный интерфейс — служебный интерфейс менеджера.

interface InheritManager {
Object getObjectById( in string ID);
void setInheritObject(in string
 ID,in Object obj);
};

Метод getObjectByID() возвращает ссылку на одного из своих предков из таблицы методов. Для определения того, ссылку на какого именно предка надо выдать в качестве результата, используется параметр ID — имя соответствующего интерфейса. Можно использовать и какой-либо другой идентификатор, однозначно определяющий интерфейс в распределенной системе. Метод setInheritObject() предназначен для динамического изменения таблицы методов. Параметр ID — идентификатор того интерфейса-предка, для которого необходимо изменить ссылку, а obj — новая ссылка на объект-предок. ID может быть только идентификатором непосредственного предка, иначе вызов будет проигнорирован и никакого изменения таблицы методов не произойдет. При изменении ссылки на предка, ссылки на объекты более низких (по дереву наследования) уровней изменяются автоматически, за счет взаимодействия менеджеров наследования разных уровней. С учетом появления служебного интерфейса IDL-описание из примера будет выглядеть так:

interface A {
string op_A (void);
};
interface B : A, InheritManager {
string op_B ( );
};
interface C : B {
string op_C ( );
};

Если объект использует наследование реализации и менеджер наследования, то его интерфейс должен содержать служебный интерфейс.

Использование локального наследования для создания менеджеров

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

Для реализации менеджеров наследования удобно использовать механизм наследования, уже присутствующий в языке. Менеджер наследования при этом будет классом, от которого программист, использующий концепцию менеджеров наследования, должен унаследовать локально (т. е. на используемом языке программирования) свой класс — будущий объект распределенной системы. При этом он должен реализовать методы, которые были объявлены в интерфейсе-потомке, а методы из интерфейсов-предков уже реализованы в менеджере наследования как перенаправление в соответствующий объект-предок из таблицы методов. Также в менеджере наследования реализованы методы из служебного интерфейса. В результате, при объединении менеджера наследования и объекта с использованием механизма наследования языка программирования будет получен полноценный объект, соответствующий интерфейсу-потомку. Для пояснения концепции рассмотрим код на C++ для менеджера наследования объекта B из приведенного выше примера IDL-определения. Данный пример написан для брокера объектных запросов VisiBroker [3].

class B_templated: public virtual POA_B {
// менеджер наследования для объекта В
public:
void InitIM( A_ptr );
 // метод для инициализации менеджера
CORBA::Object_ptr getObjectById(const
 char* id) throw(CORBA::SystemException);
void setInheritObject(const char*
 id,CORBA::Object_ptr obj)
 throw(CORBA::SystemException);
char* op_A ( ) throw(CORBA::SystemException){
// унаследованный от объекта A метод
return _Object1_pointer->op_A( );}
virtual char* op_B ( )
 throw(CORBA::SystemException)=0;
// метод, определенный в интерфейсе B
private:
A_ptr _A_pointer; //
 таблица методов - ссылка на объект A
};

Метод InitIM() выполняет инициализацию таблицы методов. В общем случае он общается с менеджером наследования объекта, ссылка на который им получена, чтобы собрать все необходимые для таблицы методов ссылки на предков. В данном примере он просто заносит полученную в качестве параметра ссылку в таблицу методов, которая и состоит всего из одной ссылки. Метод getObjectById() возвращает ссылку из таблицы методов. В данном случае он, получив строку с именем интерфейса «А», вернет ссылку на объект A из таблицы методов. Метод setInheritObject() выполняет практически те же функции, что и InitIM(), только он входит в интерфейс распределенного объекта и поэтому может быть вызван удаленно. Различия между двумя методами проявляются, если у объекта более одного непосредственного предка: метод InitIM() устанавливает ссылки на всех предков (и требует в качестве параметров ссылки на всех непосредственных предков), а setInheritObject() — только на того, имя которого было ему передано как параметр (а также на его предков). Метод op_A() выполняет перенаправление вызова в объект A по ссылке из таблицы методов. Метод op_B() объявлен как абстрактный, он должен быть реализован программистом при создании самого объекта B как потомка данного менеджера наследования. Не следует забывать, что перед тем, как использовать полученный объект, необходимо обязательно инициализировать менеджер наследования ссылкой на объект А.

Переопределение методов

Для того чтобы наследование реализации стало по-настоящему эффективным и полезным механизмом, необходимо предоставить возможность переопределения методов, позволив потомку изменять функциональность унаследованных методов. Потомок имеет право определить свой метод с таким же названием и параметрами, что и унаследованный им от предка, и все вызовы такого метода будут выполняться так, как описано в методе потомка. Приведем пример:

interface A {
string op_A();
string op_A1();
};
interface B: A {
string op_B();
string op_A1();
};

В этом примере интерфейс (и объект) В унаследовал метод «op_A1()» от своего предка и в то же время определил такой метод сам. Это указывает на то, что при вызове «op_A1()» в объекте В должен быть выполнен метод, описанный в объекте В, а не унаследованный им от А. К сожалению, по стандарту IDL повторное объявление метода недопустимо, и приведенное описание не соответствует стандарту. Пока речь идет только о наследовании интерфейсов, удаление строки «string op_A1();» из описания интерфейса В ничего не меняет: этот метод все равно присутствует в интерфейсе за счет наследования. Но при наследовании реализации это играет принципиальную роль, определяя, будет ли метод «op_A1()» выполняться в объекте А или В. В частности, такое переопределение существенно для использования концепции менеджера наследования.

К сожалению, в стандарте IDL повторное объявление методов запрещено [3]. Многие (но не все) компиляторы IDL считают такое объявление ошибкой. Поэтому может возникнуть необходимость создавать и использовать «исправленное» IDL-описание интерфейсов, соответствующее стандарту. Конечно же, необходимо следить, чтобы такое описание в точности соответствовало описанию, использованному при создании менеджеров наследования. К счастью, при использовании автоматической генерации менеджеров наследования можно автоматически генерировать «исправленное» описание. Это избавляет программиста от необходимости следить за соответствием основного и «исправленного» IDL-описаний интерфейсов.

В принципе, то, что метод переопределен в IDL-описании интерфейса, не обязательно значит, что его надо переопределить в объекте. Может быть и так, что в интерфейсе метод переопределен, а на самом деле используется наследование реализации и вызов передается предку с помощью менеджера наследования. Это позволяет реализовать объекты с разной функциональностью, но с одним интерфейсом. Однако чрезмерное использование этой возможности может привести к потере производительности системы: если метод был явно объявлен в интерфейсе, то менеджеры наследования более высоких уровней (т.е. потомков такого интерфейса) будут предполагать, что метод был реально переопределен. Поэтому, если в действительности метод просто перенаправлен предку, то образуется цепочка вызовов — как при простейшем наследовании реализации.

Возможности автоматической генерации кода

Описанная реализация менеджеров наследования очень удобна для автоматической генерации кода. Действительно, при создании менеджера наследования используется только IDL-описание интерфейсов системы. Менеджер наследования реализован как отдельный класс С++, что необычайно удобно. Уже существует экспериментальный генератор кода менеджеров наследования. Этот генератор способен обрабатывать некоторое подмножество IDL (впрочем, мощности такого подмножества вполне достаточно для того, чтобы описывать IDL-интерфейсы). По описанию интерфейсов генератор создает код для менеджеров наследования на С++ для брокера VisiBroker, а также «исправленное» IDL-описание, в котором удалены повторные объявления методов. Чтобы использовать наследование реализации, программисту достаточно унаследовать создаваемый им объект от соответствующего автоматически сгенерированного менеджера и инициализировать этот менеджер. Необходимо помнить, что для использования менеджеров наследования IDL-интерфейс объекта должен быть унаследован от служебного интерфейса InheritManager (только для таких интерфейсов будет сгенерирован код менеджеров наследования).

Пример применения наследования реализации

Для пояснения концепции и выбранного способа ее реализации полезно рассмотреть пример. Допустим, в распределенной системе уже существовал объект с интерфейсом Storage, предназначенный для хранения строк данных (например, это может быть очередь строк). Его методы «put(in string s)» и «string get()» предназначены для записи и получения данных из этого хранилища. Пусть также в процессе разработки системы потребовалась информация о том, сколько раз за время существования объекта в него помещались данные. Изменить уже существующий объект по какой-либо причине нельзя (скажем, такие объекты используются каким-либо другим приложением, работу которого мы менять не должны). Одним из вариантов было бы написать новый объект, но это может быть крайне сложно: неизвестны детали реализации старого объекта Storage, возможно, он является частью какой-то сложной системы и связан со многими другими объектами. В такой ситуации разумно использовать наследование реализации. Вот IDL-описание подобной системы.

interface InheritManager {
Object getObjectById( in
 string ID);
void setInheritObject(in
 string ID,in Object obj);
};
interface Storage {
string get ();
void put(in string s);
};
interface CountingStorage :
 Storage, InheritManager {
void put(in string s);
long counter();
};

Новый объект CountingStorage создан на основе существующего объекта Storage. При этом переопределен метод «put()» и добавлен новый метод «counter()», позволяющий получить необходимую статистику. Для метода «put()» этого объекта необходимо написать новый код, например, при выполнении метода «put()» увеличить счетчик и вызвать метод «put()» из объекта Storage. На языке С++ это будет выглядеть примерно так:

void CountingStorage:
:put(static char* s) {
internal_counter++;
getObjectById("Storage")->put(s);
}

Переменная internal_counter в этом коде — целочисленный счетчик, а getObjectById(«Storage») — метод из служебного интерфейса, возвращающий ссылку, полученную из таблицы методов, на объект Storage. Рассмотрим подробнее менеджер наследования для CountingStorage. Вот так выглядит соответствующее ему описание класса С++ для брокера VisiBroker, автоматически сгенерированное по IDL-описанию системы:

class CountingStorage_templated:
 public virtual POA_CountingStorage {
public:
void InitIM( Storage_ptr );
CORBA2::Object_ptr
 getObjectById(const char* id)
throw(CORBA::SystemException);
void setInheritObject(const char*
 id,CORBA2::Object_ptr obj)
 throw(CORBA::SystemException);
char* get ( ) throw(CORBA::SystemException);
virtual void put (static char* )
 throw(CORBA::SystemException);
virtual int counter ( )
 throw(CORBA::SystemException)=0;
private:
Storage_ptr _Storage_pointer;
};

Чтобы создать распределенный объект, реализующий интерфейс CountingStorage с использованием менеджера наследования, необходимо описать класса С++, унаследовав его от CountingStorage_templated. В этом классе обязательно надо определить метод «counter()» (иначе класс будет абстрактным) и можно переопределить метод «put()». На рис. 2 показано, как соотносятся менеджер наследования и унаследованный от него (локально, с помощью наследования С++) объект и где определены различные методы.

Рис. 2. Соотношение менеджера наследования и унаследованного от него объекта

В дальнейшем нам может потребоваться опять изменить или дополнить этот объект, создав новый объект — наследник данного. Для этого придется создать интерфейс — потомок CountingStorage. Например, добавим метод, который добавляет строку в Storage и заменяет ее на только что полученную из хранилища:

interface ModifiedStorage:
 CountingStorage {
void putAndGet(inout string s);
};

На основе этого интерфейса опять будет сгенерирован код менеджера наследования, и останется только написать свой класс, унаследовав его от этого менеджера и реализовав необходимые методы. Реализация метода putAndGet() может выглядеть примерно так:

void ModifiedStorage:
:putAndGet(сhar* s) {
put(s);
s=get();
//вообще-то надо позаботиться
 о распределении памяти под строку
};

Инициализация менеджера наследования должна проводиться ссылкой на объект CountingStorage, а менеджер сам получит ссылку на объект Storage. В дальнейшем вызов метода «get()» будет происходить именно по этой ссылке, напрямую в Storage. Следует заметить, что при создании объекта ModifiedStorage от программиста не требовалось никаких знаний об объекте Storage. Он использовал наследование реализации так, как будто бы объект CountingStorage сам реализовывал все свои методы. О том, чтобы вызовы происходили максимально эффективно, позаботился менеджер наследования.

На примере видно, что концепция менеджеров наследования особенно эффективна и удобна для использования при значительной глубине дерева наследования реализации.

Заключение

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

В статье подробно рассмотрено наследование реализации для систем стандарта CORBA с использованием языка С++. Однако концепция менеджеров наследования может применяться и для других языков программирования и даже для других моделей построения объектных распределенных систем.

С Михаилом Кузнецовым можно связаться по адресу (mikle.kuz@mtu-net.ru).

Литература
  1. Бьерн Страуструп, Язык программирования С++. // Третье издание.
  2. Гради Буч, Объектно-ориентированный анализ и проектирование. М.: "Бином", 1999
  3. Александр Цимбал, Технология CORBA для профессионалов. СПб.: "Питер Бук", 2001