В зависимости от того, кто о нем говорит, множественное наследование представляется то результатом божественного вдохновения, то кознями дьявола. Множественное наследование реализовано в Си++ так, как задумал его создатель. Все технические проблемы были решены при разработке компилятора. И вряд ли отсутствие импликации тел одинаковых методов, при соединении виртуальных базовых классов, является недостатком.

Прочтение статьи Ильи Труба «О проблемах множественного наследования» («Открытые системы», 2001 № 2) вызвало у меня желание испытать себя на поприще критика. Специфика статьи и значимость сделанных в ней выводов заставляют меня начать ее анализ в обратном порядке — со списка литературы.

О списке литературы

Любая критика, как и любой анализ, должны сопровождаться ссылками на уже существующие высказывания по рассматриваемому вопросу. Особенно это касается той предметной области, в которой одновременно роют миллионы старателей. В частности, разделывая под орех множественное наследование, я бы обязательно упомянул работы следующих товарищей:

[1] Буч Г. Объектно-ориентированный анализ и проектирование с примерами приложений на C++, 2-е изд. /Пер. с англ. — М.: «Издательства Бином», СПб: «Невский диалект», 1998

[2] Гамма Э., Хелм Р., Джонсон Р., Влиссидес Дж. Приемы объектно-ориентированного проектирования. Паттерны проектирования. /Пер. с англ. — СПб: Питер, 2001

[3] Страуструп Б. Дизайн и эволюция C++: Пер. с англ. — М.: ДМК Пресс, 2000

[4] Страуструп Б. Язык программирования C++. Третье издание. /Пер. с англ. — СПб.; М.: «Невский диалект» — «Издательство БИНОМ», 1999

[5] Мейерс С. Эффективное использование C++. 50 рекомендаций по улучшению ваших программ и проектов: Пер. с англ. — М.: ДМК, 2000

[6] Мейерс С. Наиболее эффективное использование C++. 35 новых рекомендаций по улучшению ваших программ и проектов: Пер. с англ. — М.: ДМК Пресс, 2000

[7] Голуб А.И. C, C++. Правила программирования. М: Бином. 1996

[8] Роджерсон Д. Основы COM /Пер. с англ. — М.: Издательский отдел «Русская редакция» ТОО «Channel Trading Ltd.», 1997

Большинство книг, правда, вышли на русском языке недавно, но говорить о технике программирования на Cи++ без предварительного прочтения того, что ей посвящено, просто не имеет смысла. То, что написано в них про ООП, стало классикой программирования. А избирательный поиск материалов по множественному наследованию, поразил меня не только большим количеством деталей, но и тем, как учитывались разноречивые мнения при выборе окончательного способа реализации [3].

О заключении

Самым поразительным в критикуемой статье является последний абзац. В очень концентрированной и емкой форме приведены результаты практического опыта, свидетельствующие об искусственности и бесполезности ООП, подтверждаемые литературными примерами. Лично я не считаю такими уж надуманными проекты, описанные Бучем [1]. Примеры использования образцов проектирования [2] тоже позволяют судить о реальности и массовости подхода. Правда, язык не поворачивается назвать образцы термином, использованным при переводе этой книги. Можно также назвать искусственным все, что написано на Smalltalk и Java. А использование стандартной библиотеки шаблонов для формирования надстроечных библиотек, проводимое «Бустерами» (www.boost.org), — вообще детский лепет мальчиков, бесплатно поддерживающих едва теплящийся Cи++. Думаю, что этот список можно увеличить многократно, потому что объектно-ориентированный подход практически вытеснил все прочие при создании больших программных систем. Даже не являясь сторонником объектно-ориентированного программирования, необходимо четко понимать, что оно дает и почему сегодня является доминирующей парадигмой. Этот подход не на словах, а на деле расширяет наши возможности по эволюционному развитию программ и повторному использованию уже написанного кода.

Вызывает также возражение мысль, что Java базируется на Cи++. Базирование предполагает наследование с расширением или заменой поведения. Java наследует, в основном, только синтаксис, а расширяется, прежде всего, за счет концепции интерфейсов. Во всем остальном — это обрубок (по отношению к Cи++), предназначенный для написания «чисто объектных программ» и пропагандируемый, в качестве отпрыска Cи++, в маркетинговых целях. Не хочу обидеть этим Java-программистов. Да, на этом языке можно и надо писать прекрасные программы, равно как и на многих других. Лично мне эпитеты, высказанные в адрес Cи++ («раздутого монстра» по отношению к Java), нисколько не мешают использовать его.

Я согласен с тем, что множественное наследование таит немало «подводных камней» и непросто для реализации и понимания. Даже природа не смогла продумать этот вопрос так, чтобы получить идеальные результаты. Допустимо только парное наследование, возможно кровосмешение и порождение уродов, а поведение наследников столь непредсказуемо, что трудно понять: поддается оно законам дизъюнкции, импликации или является результатом работы генератора случайных чисел. Вместе с тем, следует отметить, что, несмотря на неполноценность, множественное наследование, кроме Cи++, реализовано еще в Eiffel и CLOS [5].

Об импликации

Импликация (исключающее ИЛИ), возможно, выглядит весьма привлекательно, но забавно думать о том, что наши дети по умолчанию имплицируют не только свой пол, но и поведение. Речь же, при анализе методов класса, как раз и идет о поведении. А оно, при объединении моделей объектов, может быть весьма разнообразным и, чаще всего, не подпадает под искусственно навязанные правила.

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

Поэтому я думаю, что принципы, положенные в основу множественного наследования Страуструпом [3], достаточно здравы. Они позволяют использовать как обратные древовидные структуры, опирающиеся на множественные базовые классы, так и ромбовидные схемы на основе виртуальных базовых классов. Явное переписывание виртуальных методов позволяет реализовать любую стратегию поведения без дополнительных правил по умолчанию и исключений из них. Кстати, описанию того, как формировались эти принципы, посвящена страница 21 главы 12 в работе [3]. На мой взгляд, там изложена информация, необходимая для полного понимания возможностей множественного наследования в Си++.

Об использовании множественного наследования

Использовать или не использовать множественное наследование? По этому вопросу есть различные мнения. Да, без него можно обойтись. Об этом пишет Скотт Мейерс [5] в правиле 43, а Ален Голуб [7] в своем правиле 101 рекомендует использовать множественное наследование для подмешивания. Но в целом почти все специалисты утверждают, что ромбовидная схема, часто формируемая при множественном наследовании, является ненадежной, и ее надо избегать. Именно такая схема и используется в рассматриваемой работе. Отсюда вытекают множество проблем и попытка бороться с ними кардинальными методами: переделкой семантики языка программирования. Но и такая схема имеет право на существование — при определенных условиях.

Страуструп [3] приводит ряд ситуаций, когда множественное наследование можно удачно применять:

  • для объединения независимых или почти независимых иерархий;
  • для композиции интерфейсов;
  • для составления класса из интерфейса и реализации.

При этом он считает, что наиболее полезным является применение множественного наследования для определения класса, обладающего суммой (а не наложением!) атрибутов других независимых классов.

Первая ситуация достаточно часто встречается в такой известной программной архитектуре, как «модель — вид — контроллер» (она частично зафиксирована в образце Observer [2]). Построенный на основе множественного наследования класс обеспечивает в дальнейшем поддержку независимого доступа по двум различным интерфейсам, осуществляющим совместную обработку общих данных. Композиция интерфейса применяется в стандартной потоковой библиотеке Cи++ и базируется на ромбовидной схеме. Итак, схема ненадежна, но использоваться может!

Другим примером использования множественного наследования, объединяющим независимые интерфейсы и одновременно обеспечивающим составление класса из интерфейса и реализации, является один из способов создания компонентов COM, описанный Роджерсоном [8]. Этот метод, по моему мнению, не является единственным, но считается одним из самых простых и эффективных.

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

О представленном коде

Лично я считаю неоправданным использование псевдокода в статье, посвященной конкретной проблеме конкретного языка, так как его нельзя непосредственно перебросить в файл и откомпилировать, чтобы проверить правильность высказываний автора. Реальное тестирование выявило синтаксические и семантические ошибки. Укажем, например, на то, как в методе «Ферзь::ход» осуществляется вызов хода ладьи или слона с использованием прототипов. Кроме этого, использование реального конструктора с аргументами в базовом классе требует его переопределения во всех производных классах и включения вызовов всех конструкторов базовых классов в списки инициализации конструкторов производных классов. Далее, если данные базового класса закрыты (private), то доступ к ним из методов производного класса невозможен даже при наследовании. Поэтому, в базовом классе необходимо не закрывать, а защищать (protected) данные.

Возможно, отладка проводилась, но при трансляции фрагментов своего работоспособного псевдокода, выдержанного «в классических традициях объектно-ориентированного анализа и языка Си++», автор пользовался какой-то навороченной версией компилятора. В моем же непосредственном распоряжении оказался только Microsoft VC++ 6.0, поэтому пришлось изрядно попотеть, чтобы добиться выполнения программы. Вот полный текст того, что получилось в результате:

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

enum coord1 {a ,b ,c, d, e, f, g, h};
 // горизонталь
enum  color {black, white};
 // цвет фигуры

// Фигура — общий предок всех остальных
class figure {
protected:
   coord1 letter;    // координата a..h
   int    digit;    // координата 1..8
   color  fig_color; // цвет фигуры
public:
   //конструктор
   figure(coord1 x, int y, color z)  
      : letter(x), digit(y), fig_color(z)
   {}
   //чистая функция «ход»
   virtual bool step (coord1 new_letter,
 int new_digit)=0;
};
// Класс Ладья реализует
  функцию «ход»
class castle: public virtual
 figure {
public:
   castle(coord1 x, int y, color z):
 figure (x, y, z)
   {}
   bool step(coord1 new_letter,
 int new_digit) {
   if ( ((new_letter == letter)
 && (new_digit != digit))
   || ((new_letter != letter)
 && (new_digit == digit)))
   { letter = new_letter;
 digit  = new_digit;
   return true;
   }
   return false;}};
#include  
// Класс Слон реализует свою
 функцию «ход»
class elephant: public virtual
 figure {
public:
   elephant(coord1 x, int y, color z):
 figure (x, y, z)
   {}
   bool step(coord1 new_letter,
 int new_digit) {
   if (abs((new_letter - letter)
 == abs(new_digit - digit)) 
   && (new_letter != letter)) {
   letter = new_letter; digit
 = new_digit;
   return true;
   }
   return false;}};

// Класс Ферзь - наследник
 Ладьи и Слона 
class queen: public elephant,
 public castle {
public:
   //конструктор
   queen(coord1 x, int y, color z)
   : castle (x, y, z), elephant
 (x, y, z), figure (x, y, z)
   {}
   
   bool step(coord1 new_letter,
 int new_digit) {
   return castle::step(new_letter,
 new_digit) 
      || elephant::step(new_letter,
 new_digit);
}};

#include 
using namespace std;

void main(){
   queen q(e, 5, white);
   cout << q.step(h, 8) << endl;
   cout << q.step(e, 8) << endl;
   cout << q.step(h, 8) << endl;
   cout << q.step(h, 5) << endl;
   cout << q.step(a, 8) << endl;
   cout << q.step(d, 8) << endl;
   cout << q.step(c, 5) << endl;
   cout << q.step(a, 1) << endl;
}

Неприятным и неожиданным моментом этой реализации является необходимость дополнительного вызова конструктора виртуального базового класса в списке инициализации конструктора ферзя, но и этот нюанс объяснен в советах Мейерса [5]. Там же рекомендуется избавляться от данных в виртуальных базовых классах, чего, к сожалению, нельзя сделать в этом примере, так как и ладья, и слон повторят наборы своих данных в ферзе.

О проблемах технической реализации

Насколько я понимаю, проблемы реализации заключаются в отображении языковых конструкций на компьютерную архитектуру во время трансляции, они достаточно подробно и, на мой взгляд, интересно отражены у Страуструпа [3] и Мейерса [5, 6]. Страуструп описал анализ различных схем множественного наследования, мнения критиков, несколько вариантов возможной реализации окончательной схемы. Описаны и методы разрешения конфликтов имен. Но он, при этом, всегда и везде пишет, что множественное наследование — не панацея и его надо применять разумно и умеренно по мере необходимости. То же самое пишет и Мейерс.

Вряд ли имеет смысл приводить эти цитаты. Однако следует отметить, что использование множественного наследования с виртуальными базовыми классами ведет к дополнительному расходу ресурсов. Классы, построенные по ромбовидной схеме, имеют дополнительные внутренние указатели. Это вынужденная плата за универсальность, которую необходимо учитывать при формировании иерархии классов. По крайней мере, я бы воздержался в рассматриваемом примере от использования множественного наследования и продублировал бы в ферзе поведение ладьи и слона. Зачем умножать отношения между сущностями и попадать под бритву Оккама [5], если они не используются в дальнейшем? Ведь интерфейс остается таким же, каким он будет и при одинарном наследовании! При этом метод, описывающий ход ферзем можно дополнительно оптимизировать. Вот мой вариант кода:

// Использование одинарного
// наследования и своей функции для
 определения шага ферзя

enum coord1 {a ,b ,c, d, e, f, g, h};
enum  color {black, white};

// Фигура - общий предок всех остальных
// Определяет интерфейс
class figure {
protected:
   coord1 letter;	  // координата a..h
   int    digit;	    // координата 1..8
   color  fig_color;	//цвет фигуры
public:
   //конструктор
   figure(coord1 x, int y, color z): 
      letter(x), digit(y), fig_color(z)
      {}
   //чистая функция «ход»
   virtual bool step
   (coord1 new_letter, int new_digit)=0;
};

// Класс Ладья реализует
  свою функцию «ход»
class castle: public figure {
public:
   castle(coord1 x, int y,
 color z): figure (x, y, z)
   {}

   bool step(coord1 new_letter,
 int new_digit) {
   if ( ((new_letter == letter) 
&& (new_digit != digit)) 
   || ((new_letter != letter) 
&& (new_digit == digit)))
   {
      letter = new_letter; digit
  = new_digit;
      return true;
   }
   return false;
}};

#include  
// Класс Слон реализует
 свою функцию «ход»
class elephant: public figure {
public:
   elephant(coord1 x, int y,
 color z): figure (x, y, z)
   {}

   bool step(coord1 new_letter,
 int new_digit) {
   if (abs((new_letter - letter)
 == abs(new_digit - digit)) 
      && (new_letter != letter)) 
   {
      letter = new_letter;
      digit = new_digit;
      return true;
   }
   return false;
}};

// Класс Ферзь реализует сам
// реализует свою функцию «ход»
class queen: public figure {
public:
   //конструктор
   queen(coord1 x, int y,
 color z): figure (x, y, z)
   {}

   bool step(coord1 new_letter,
 int new_digit) {
   if ( ((new_letter == letter)
 && (new_digit != digit)) 
      || ((new_letter != letter)
 && (new_digit == digit))
      || (abs((new_letter - letter)
 == abs(new_digit - digit)) 
      && (new_letter != letter)))
   {
   letter = new_letter; digit
 = new_digit;
   return true;    }
   return false;
}};

...

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

// Использование одинарного наследования, 
// и внешних общих функций
...
// Внешняя функция, реально
// выполняющая ход ладьей
bool is_castle_step(coord1
 &old_letter, int &old_digit, 
      coord1  new_letter,
 int  new_digit)
{ if ( ((new_letter == old_letter)
 && (new_digit != old_digit)) 
   || ((new_letter != old_letter)
 && (new_digit == old_digit)))
   {
   old_letter = new_letter;
 old_digit  = new_digit;
   return true;
   }
   return false;
}

// Класс Ладья использует
// внешнюю функцию «хода ладьей»
class castle: public figure {
public:
   castle(coord1 x, int y,
 color z): figure (x, y, z)
   {}

   bool step(coord1 new_letter,
 int new_digit) {
   return is_castle_step(letter,
 digit, new_letter, 
new_digit);
   }
};

#include  

// Внешняя функция, реально
// выполняющая ход слоном
bool is_elephant_step(coord1
 &old_letter, int &old_digit, 
      coord1  new_letter,
 int  new_digit)
{ if (abs((new_letter - old_letter)
 == abs(new_digit - old_digit)) 
   && (new_letter != old_letter)) 
   {
   old_letter = new_letter;
 old_digit = new_digit;

   return true;
   }
   return false;
}

// Класс Слон использует внешнюю
// функцию «хода слоном»
class elephant: public figure 
{
public:
   elephant(coord1 x, int y,
 color z): figure (x, y, z)
   {}

   bool step(coord1 new_letter,
 int new_digit) {
   return is_elephant_step(letter,
 digit, new_letter, new_digit);
   }
};

// Класс Ферзь использует внешние
// функции «ход ладьей» и «ход слоном»
class queen: public figure
{
public:
   //конструктор
   queen(coord1 x, int y,
 color z): figure (x, y, z)
   {}

   bool step(coord1 new_letter,
 int new_digit) {
   return is_castle_step(letter,
 digit, new_letter,
new_digit)
      || is_elephant_step(letter, 
digit, new_letter, new_digit);
}};

...

О введении

Вот тут-то меня и прихлопнут! Как!? Внешние процедуры!? Да это же покушение на каноны объектно-ориентированного программирования! Ведь окружающий нас мир слонов, ладей и ферзей стал менее наглядным! Но кто сказал, что я истовый сторонник ООП? Лично мне ближе сочетание процедурного и объектного подходов. Да и не мне одному. Тот же Мейерс считает, что внешние функции улучшают инкапсуляцию классов, а Страуструп ратует за одновременное использование различных парадигм (см. www.softcraft.ru).

Кроме того, я скептически отношусь к «наиболее удачным примерам эффективного применения» ООП как программированию увиденного. Это уже не программирование, а рисование. То, что объект, инкапсулирующий методы, более нагляден, тоже сомнительно. Реально проектируемые классы очень часто описывают такие абстракции, которые увидеть нельзя. Им нет аналогов в реальном мире. Об этом говорят и уже упомянутые образцы проектирования [2] и Буч [1]. Эволюционное развитие уже написанного кода, необходимое при разработке больших программ, часто требует применения таких приемов, о которых нельзя заранее точно сказать, к какому стилю программирования они принадлежат: процедурному или объектно-ориентированному. Одно из главных преимуществ ООП — не адекватное отображение объектов реального мира, а способность поддерживать эволюционное развитие программ за счет сочетания виртуализации и наследования. Правильное же использование внешних процедур только расширяет возможности разработчиков.

Отсебятина

Высказывая противоположное мнение по любому вопросу, всегда рискуешь задеть того, кто сформулировал первоначальную точку зрения. Даже если разговор идет о сугубо отвлеченных технических деталях. Поэтому заранее и искренне приношу извинения автору критикуемой статьи. Я не считаю, что истина рождается в спорах. Мне ближе другое классическое высказывание о том, что практика — критерий истины. И не важно, кто это сказал. Язык программирования Cи++ прошел достаточно долгий путь эволюционного развития и сложился таким, каким его хотел видеть создатель [3]. Поэтому, остается только пользоваться плодами этой работы, применяя на практике средства, которые нас устраивают. Или подыскивать другой язык.

Александр Легалов (lai@softcraft.ru) — сотрудник Красноярского государственного технического университета.

Поделитесь материалом с коллегами и друзьями