В зависимости от того, кто о нем говорит, множественное наследование представляется то результатом божественного вдохновения, то кознями дьявола. Множественное наследование реализовано в Си++ так, как задумал его создатель. Все технические проблемы были решены при разработке компилятора. И вряд ли отсутствие импликации тел одинаковых методов, при соединении виртуальных базовых классов, является недостатком.
Прочтение статьи Ильи Труба «О проблемах множественного наследования» («Открытые системы», 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) — сотрудник Красноярского государственного технического университета.