Множественное наследование в C++
Так же, как язык CLOS представляет собой объектно-ориентированное расширение языка LISP, так и язык C++ создан на основе широко известного языка С и сохранил все его возможности, добавив к ним средства объектно-ориентированного программирования. Если отвлечься от того факта, что CLOS и C++ основаны на разных языках-прототипах, то основное отличие между ними заключается в реализации механизма наследования, в частности множественного наследования. В языке C++ множественное наследование трактуется совсем не так, как мы это делали в предшествующих разделах настоящей главы, а потому этот вопрос заслуживает подробного обсуждения.
В языке C++ родовые операции реализуются в виде виртуальных функций. Виртуальная функция, объявленная в классе X, это функция, которая может быть перегружена (переопределена) в классе, производном от X. При объявлении в классе X виртуальная функция вообще может не иметь тела, т.е. программного кода реализации. В таком случае функция называется чисто виртуальной, а класс, имеющий одну или более чисто виртуальных функций, является абстрактным базовым классом, экземпляры которого создать невозможно. В любом случае ключевое слово virtual говорит компилятору, что программный код функции будет уточнен в производных классах.
Те методы, которые вызываются на выполнение, являются невиртуальными членами-функциями, т.е. функциями, имеющими определенный программный код, который не перегружается в производных классах. В этом смысле C++ существенно отличается от языка CLOS, в котором практически все функции суперкласса в большей или меньшей степени модифицируются механизмом наложения методов. Поэтому в C++ существует множество синтаксических тонкостей, в которых не нуждается CLOS. Например, во всех классах иерархии виртуальная функция должна иметь квалификатор virtual до тех пор, пока в некотором производном классе не будет представлена ее конкретная реализация.
В чисто иерархической структуре классов, когда каждый производный класс имеет единственного "родителя", передача методов по наследству выполняется совершенно очевидным способом.
Единственная тонкость в реализации этого механизма в C++ состоит в использовании квалификаторов наследования public и private. Если не вдаваться в подробности, то наследование вида public представляет собой отношение "is а" (является), которое мы использовали при обсуждении фреймов. Наследование вида private ближе к отношению "реализовано в терминах", которое позволяет скрыть определенные детали реализации интерфейсов объектов. Такое полезное разделение "выпало" в языке CLOS, в котором каждое отношение "класс-подкласс" несет семантический смысл.
Но если обратиться к множественному наследованию, то механизмы его реализации в C++ и CLOS существенно отличаются. Поскольку в языке C++ не существует такого понятия, как порядок предшествования классов, то даже такой простой случай, как в рассмотренном выше примере "Алмаз Никсона", приводит к неоднозначности. Будем считать, что отношения между классами Person, Quaker, Republican и Republican_Quaker, как и прежде, имеют вид, представленный на рис. 7.8.
Объявление классов Person, Quaker, Republican и Republican Quaker на языке C++ показано в листинге 7.2 (программный код объявления включен в файл nixon.h).
Рис. 7.8. Отношения между классами в примере "Алмаз Никсона"
Листинг 7.2. Файл nixon.h. Объявление классов, версия 1
// Объявление классов для задачи "Алмаз Никсона" finclude <iostream.h>
class Person
{ public:
Personf) {};
virtual "Person() {};
virtual void speak() = 0; };
class Republican : public Person
{ public:
Republican)) {};
virtual ~Republican)) {};
virtual void speak() { cout « "War";} };
class Quaker : public Person
{ public:
Quakerf) {};
virtual ~Quaker)) {};
virtual void speak)) { cout « "Peace";} };
class Republican_Quaker : public Republican,
public Quaker
{ public:
Republican_Quaker() {};
virtual ~Republican_Quaker() {};
};
Создадим экземпляр richard класса Republican_Quaker.
#include "nixon.h" void main))
Republican_Quaker richard; richard.speak));
При обработке этого программного кода компилятор C++ обнаружит, что вызов richard.speak)) содержит неоднозначную ссылку. Оно и понятно, поскольку нельзя однозначно заключить, скажет ли Ричард "War" (война) или "Peace" (мир).
Если мы решим, что метод speak)) класса Republican_Quaker должен "брать пример" с класса Quaker, то проблему можно решить, определив этот метод одним из двух способов:
void S::speak(){ cout << "Peace"; }
или
void S::speak)({Quaker::speak)); }
Первый вариант просто перегружает оба наследованных определения метода, а второй в явном виде вызывает один из них, а именно тот вариант, который реализован в классе Quaker.
Однако совершенно незначительное на первый взгляд изменение в файле определения классов может разительно изменить поведение объекта. Предположим, решено удалить объявления методов speak)) из всех классов, кроме Person, как это показано в листинге 7.3.
Листинг 7.3. Файл nixon.h. Объявление классов, версия 2
class Person
public:
Person)) {};
virtual "Person)) {};
virtual void speak)){ cout « "Beer";}
};
class Republican : public Person
public:
Republican)) {}; virtual ~Republican)) {};
class Quaker : public Person
public:
Quaker)) {};
virtual ~Quaker)) {};
class Republican Quaker : public Republican, public Quaker
{
public:
Republican_Quaker( ) {} ;
virtual ~Republican_Quaker( ) {};
}
При обработке такого файла определения компилятор опять выдаст сообщение о неоднозначности ссылки на метод speak ( ). Это произойдет по той причине, что компилятор сформирует две копии объявления класса Person — по одной для каждого пути наследования, а это приведет к конфликту имен. Чтобы устранить эту неоднозначность, нужно объявить Person как виртуальный базовый класс и для Republican, и для Quaker. Тогда оба производных класса будут ссылаться на единственный объект суперкласса (листинг 7.4).
Листинг 7.4. Файл nixon.h. Объявление классов, версия 3
class Person
{ public:
Per son () {};
virtual "Person)) {};
virtual void speak(){ cout << "Beer";} И
class Republican : virtual public Person
{ public:
Republican)) {};
virtual ~Republican)) {};
};
class Quaker : virtual public Person
{
public:
Quaker)) {};
virtual ~Quaker)) .{};
}
class Republican_Quaker : public Republican, public Quaker
{
public:
Republican_Quaker { ) { } ;
virtual "Republican_Quaker( ) {};
}
Объявление Person в качестве виртуального базового класса для Republican и Quaker имеет и еще одно преимущество. Предположим, что нам нужно сделать так, чтобы класс Republican_Quaker отдавал предпочтение стилю поведения квакеров, а все другие были индифферентны к вопросам войны и мира и следовали линии поведения, определенной классом Person. Тогда, поскольку Person является виртуальным базовым классом, можно заставить доминировать Quaker::speak)) над Person::speak)) для класса Republican_Quaker (листинг 7.5).
Листинг 7.5. Файл nixon.h. Объявление классов, версия 4
class Person
public:
Person)) {};
virtual ~Person)) {};
virtual void speak)){ cout « "Beer";}
class Republican : virtual public Person
public:
Republican)) {}; virtual ~Republican)) {};
class Quaker : virtual public Person
public:
Quaker)) {};
virtual ~Quaker() {};
virtual void speak)) { cout « "Peace";}
class Republican_Quaker : public Republican, public Quaker
public:
Republican_Quaker() {};
virtual "Republican_Quaker() {};
}
При создании языка C++ преследовалась цель не усложнять механизм множественного наследования по сравнению с единственным и разрешать все неоднозначности на стадии компиляции [Stromtrup, 1977]. В этом существенное различие между C++ и SmallTalk. В последнем такого рода конфликты разрешаются на стадии выполнения программы. Это также отличается и от метода, основанного на списке предшествования классов, который используется в CLOS.
Кроме того, в языке CLOS конфликта имен, подобного тому, который мы наблюдали с классом Person, быть просто не может, поскольку все базовые классы с одинаковыми именами считаются идентичными.
Таким образом, за высокую эффективность языка C++ приходится платить, тщательно продумывая передачу свойств и поведения от классов родителей к производным классам с учетом всех нюансов функционирования механизма наследственности в C++.
В этом отношении C++ напоминает свой прототип — язык С, который требует гораздо более близкого знакомства с работой компьютера, чем язык LISP, поскольку позволяет напрямую обращаться к памяти компьютера, манипулировать адресами, формировать собственный механизм выделения памяти и т.д. Какую стратегию предпочесть — зависит от индивидуальных предпочтений разработчика, но если главным требованием к продукту является высокая производительность, то чем большими возможностями управления ресурсами обладает разработчик, тем лучше, тем более эффективную программу можно создать.
Суммируя все сказанное о языке C++, отметим, что он вполне может послужить базовым программным инструментом для создания экспертных систем. Если потребуется интерпретатор порождающих правил, то можно либо разработать его самостоятельно (хотя это и далеко не тривиальная задача), либо воспользоваться одним из имеющихся на рынке, которые допускают внедрение в среду C++. Если вам удастся избежать описанных выше сложностей в реализации множественного наследования, вы сможете в полной мере воспользоваться многочисленными преимуществами этого языка — проверкой статических типов, разделением между закрытым и общедоступным наследованием, множеством средств защиты данных от случайных изменений.