До сих пор ни в одном из примеров наследования базовый класс не содержал конструкторов, использующих параметры. До тех пор, пока конструкторы не имеют аргументов, от производного класса не требуется каких-либо специальных действий. Однако когда базовый класс имеет конструктор с аргументами, производные классы должны явным образом обрабатывать эту ситуацию путем передачи базовому классу необходимых аргументов. Для этого используется расширенная форма конструкторов производных классов, в которые передаются аргументы конструкторам базовых классов. Эта расширенная форма показана ниже:
Здесь под базовый1, ... базовыйN обозначены имена базовых классов, наследуемые производным классом. Обратим внимание, что с помощью двоеточия конструктор производного класса отделяется от списка конструкторов базового класса. Список аргументов, ассоциированный с базовыми классами, может состоять из констант, глобальных переменных или параметров конструктора производного класса. Поскольку инициализация объекта происходит во время выполнения программы, можно использовать в качестве аргумента любой идентификатор, определенный в области видимости класса.
Следующая программа иллюстрирует, как передаются аргументы базовому классу из производного класса:
Обратим внимание, что конструктор класса Z фактически не использует параметры прямо. Вместо этого в данном примере они просто передаются конструкторам классов X и Y. Вместе с тем при необходимости класс Z может использовать эти и другие аргументы непосредственно.
Прежде чем переходить к виртуальным функциям и полиморфизму, следует объяснить один из их важнейших атрибутов. Начнем с указателей. В общем случае указатель одного типа не может указывать на объект другого типа. Из этого правила, однако, есть исключение, которое относится только к производным классам. В C++ указатель на базовый класс может указывать на объект производного класса, полученного из этого базового класса. Предположим, например, что имеется базовый класс B_class и его производный класс D_class. В C++ любой указатель типа B_class* может также указывать на объект типа D_class. Например, если имеются следующие объявления переменных:
B_class *p; // указатель на объект типа B_class
B_class B; // объект типа B_class
D_class D; // объект типа D_class
то следующие присваивания абсолютно законны:
р = &B; // р указывает на объект типа B_class
р = &D; /* р указывает на объект типа D_class, являющийся объектом, порожденным от B_class */
Используя указатель р, можно получить доступ ко всем членам D, которые наследованы от B. Однако специфические члены D не могут быть получены с использованием указателя р (по крайней мере до тех пор, пока не будет осуществлено приведение типов). Это является следствием того, что указатель «знает» только о членах базового типа и не знает ничего о специфических членах производных типов.
В следующей короткой программе иллюстрируется использование указателей на базовый класс. В ней определен класс CName, от которого порожден класс CBook. Этот производный класс реализует функции простой автоматической телефонной книги.
/* поскольку put_phone и show_phone не являются частью базового класса, они не доступны через указатель на базовый класс и доступ должен осуществляться или напрямую, или, как показано ниже, через указатель на порожденный класс */
p_book = &book;
p_book->put_phone("555 555-1234");
p_name->show_name(); // в данной строке могут использоваться р и dp
p_book->show_phone();
return 0;
}
В этом примере указатель p_name определен как указатель на класс CName. Однако он может указывать также на объект производного класса CBook и может использоваться для доступа к членам производного класса, которые были определены в базовом классе. Вместе с тем следует запомнить, что этот указатель не может использоваться для доступа к членам, специфическим для производного класса, до тех пор, пока не выполнено приведение типов. Именно поэтому доступ к функции show_phone() осуществляется с использованием указателя p_book, являющегося указателем на производный класс.
Если необходимо получить доступ к элементам производного класса с помощью указателя, имеющего тип указателя на базовый класс, необходимо воспользоваться приведением типов. Например, в следующей строке кода осуществляется вызов функции show_phone() класса CBook:
((CBook*)p_name)->show_phone();
Внешние скобки необходимы для того, чтобы ассоциировать приведение типа именно с указателем p_name, а не с возвращаемой величиной функции show_phone(). Хотя ничего неправильного с технической точки зрения в таком приведении типов нет, лучше исключить его использование, так как оно может служить дополнительным источником ошибок в коде.
Хотя указатель на базовый класс может использоваться в качестве указателя на производный объект, обратное не имеет места. Это означает, что указатель на производный класс не может использоваться для доступа к объектам базового типа. И еще одно замечание. Инкремент и декремент указателя выполняются по отношению к его базовому типу. Таким образом, если указатель на базовый класс указывает на объект производного класса, инкремент или декремент его не даст указатель на следующий объект производного класса. Поэтому нельзя использовать операции инкремента и декремента указателей, когда они указывают на объект производного класса.
Ссылки на базовый класс могут быть использованы для ссылок на объект производного типа. Такая техника наиболее употребительна при работе с функциями. Если параметр является ссылкой на базовый класс, то он может принимать значение ссылки как на объект базового класса, так и на объекты производных типов. Ниже будет приведен пример подобной техники.
Полиморфизм времени выполнения обеспечивается за счет использования производных классов и виртуальных функций. Виртуальная функция — это функция, объявленная с ключевым словом virtual в базовом классе и переопределенная в одном или в нескольких производных классах. Виртуальные функции являются особыми функциями, потому что при вызове объекта производного класса с помощью указателя или ссылки на него C++ определяет во время исполнения программы, какую функцию вызвать, основываясь на типе объекта. Для разных объектов вызываются разные версии одной и той же виртуальной функции. Класс, содержащий одну или более виртуальных функций, называется полиморфным классом (polymorphic class).
Виртуальная функция объявляется в базовом классе с использованием ключевого слова virtual. Когда же она переопределяется в производном классе, повторять ключевое слово virtual нет необходимости, хотя и в случае его повторного использования ошибки не возникнет.
В качестве первого примера виртуальной функции рассмотрим следующую короткую программу:
#include <iostream.h>
class Base
{
public:
virtual void who() { cout << “Base\n”; }
};
class first: public Base
{
public:
void who() { cout << "First\n"; }
};
class second: public Base
{
public:
void who() { cout << "Second\n"; }
};
int main()
{
Base base_object, *p;
first first_object;
second second_object;
p = &base_object;
p->who(); // доступ к who класса Base
p = &first_object;
p->who(); // доступ к who класса first
p = &second_obect;
p->who(); // доступ к who класса second
return 0;
}
Программа выдаст следующий результат:
Base
First
Second
Проанализируем подробно эту программу, чтобы понять, как она работает. Как можно видеть, в классе Base функция who() объявлена как виртуальная. Это означает, что эта функция может быть переопределена в производных классах. В каждом из классов first и second функция who() переопределена. В функции main() определены три переменные. Первой является объект base_object класса Base. После этого объявлен указатель р на класс Base, затем объекты first_object и second_object, относящиеся к двум производным классам. Далее указателю p присвоен адрес объекта base_object и вызвана функция who(). Поскольку эта функция объявлена как виртуальная, то C++ определяет на этапе исполнения, какую из версий функции who() употребить, в зависимости от того, на какой объект указывает указатель р. В данном случае им является объект типа Base, поэтому исполняется версия функции who(), объявленная в классе Base. Затем указателю p присвоен адрес объекта first_object. (Как известно, указатель на базовый класс может быть использован с любым производным классом.) После того, как функция who() была вызвана, C++ снова анализирует тип объекта, на который указывает p, для того, чтобы определить версию функции who(), которую необходимо вызвать. Поскольку p указывает на объект класса first, то используется соответствующая версия функции who(). Аналогично, когда указателю p присвоен адрес объекта second_object, то используется версия функции who(), объявленная в классе second.
Наиболее распространенным способом вызова виртуальной функции служит использование параметра функции. Например, рассмотрим следующую модификацию предыдущей программы:
#include <iostream.h>
class Base
{
public:
virtual void who() { cout << "Base\n"; }
};
class first: public Base
{
public:
void who() { cout << "First\n"; }
};
class second: public Base
{
public:
void who() { cout << "Second\n"; }
};
void show_who(Base &r)
{
r.who();
}
int main()
{
Base base_object;
first first_object;
second second_object;
show_who(base_object); // доступ к who класса Base
show_who(first_object); // доступ к who класса first
show_who(second object); // доступ к who класса second
return 0;
}
Эта программа выводит на экран те же самые данные, что и предыдущая версия. В данном примере функция show_who() имеет параметр типа ссылки на класс Base. В функции main() вызов виртуальной функции осуществляется с использованием объектов типа Base, first и second. Вызываемая версия функции who() в функции show_who() определяется типом объекта, на который ссылается параметр при вызове функции.
Ключевым моментом в использовании виртуальной функции для обеспечения полиморфизма времени выполнения служит то, что используется указатель именно на базовый класс. Полиморфизм времени выполнения достигается только при вызове виртуальной функции с использованием указателя или ссылки на базовый класс. Однако ничто не мешает вызывать виртуальные функции, как и любые другие «нормальные» функции, однако достичь полиморфизма времени выполнения при этом не удается.
На первый взгляд переопределение виртуальной функции в производном классе выглядит как специальная форма перегрузки функции. Но это не так, и термин перегрузка функции не применим к переопределению виртуальной функции, поскольку между ними имеются существенные различия. Во-первых, функция должна соответствовать прототипу. Как известно, при перегрузке обычной функции число и тип параметров должны быть различными, а при переопределении виртуальной функции интерфейс функции должен в точности соответствовать прототипу. Если же такого соответствия нет, то такая функция просто рассматривается как перегруженная и она утрачивает свои виртуальные свойства. Кроме того, если отличается только тип возвращаемого значения, то выдается сообщение об ошибке. (Функции, отличающиеся только типом возвращаемого значения, порождают неопределенность.) Другим ограничением является то, что виртуальная функция должна быть членом, а не другом класса, для которого она определена. Тем не менее, виртуальная функция может быть другом другого класса. Хотя деструктор может быть виртуальным, но конструктор виртуальным быть не может.
В силу различий между перегрузкой обычных функций и переопределением виртуальных функций будем использовать для последних термин переопределение (overriding).
Если функция была объявлена как виртуальная, то она и остается таковой вне зависимости от количества уровней в иерархии классов, через которые она прошла. Например, если класс second получен из класса first, а не из класса Base, то функция who() останется виртуальной и будет вызываться корректная ее версия, как показано в следующем примере:
class second: public first
{
public:
void who() { cout << "Second derivation\n"; }
};
Если в производном классе виртуальная функция не переопределяется, то тогда используется ее версия из базового класса. Например, запустим следующую версию предыдущей программы:
#include <iostream.h>
class Base
{
public:
virtual void who() { cout << "Base\n"; }
};
class first: public Base
{
public:
void who() { cout << "First\n”; }
};
class second: public Base {};
int main()
{
Base base_object, *p;
first first_object;
second second_object;
p = &base_object; p->who();
p = &first_object; p->who();
p = &second_object; p->who();
return 0;
}
Эта программа выдаст следующий результат:
Base
First
Base
Надо иметь в виду, что характеристики наследования носят иерархический характер. Чтобы проиллюстрировать это, предположим, что в предыдущем примере класс second порожден от класса first вместо класса Base. Когда функцию who() вызывают, используя указатель на объект типа second (в котором функция who() не определялась), то будет вызвана версия функции who(), объявленная в классе first, поскольку этот класс — ближайший к классу second. В общем случае, когда класс не переопределяет виртуальную функцию, C++ использует первое из определений, которое он находит, идя от потомков к предкам.
5.8 Для чего нужны виртуальные функции?
Как отмечалось в начале главы, виртуальные функции в комбинации с производными типами позволяют языку C++ поддерживать полиморфизм времени исполнения. Этот полиморфизм важен для объектно-ориентированного программирования, поскольку он позволяет переопределять функции базового класса в классах-потомках с тем, чтобы иметь их версию применительно к данному конкретному классу. Таким образом, базовый класс определяет общий интерфейс, который имеют все производные от него классы, и вместе с тем полиморфизм позволяет производным классам иметь свои собственные реализации методов. Благодаря этому полиморфизм часто определяют фразой «один интерфейс — множество методов».
Успешное применение полиморфизма связано с пониманием того, что базовые и производные классы образуют иерархию, в которой переход от базового класса к производному отвечает переходу от большей к меньшей общности. Поэтому при корректном использовании базовый класс задает все элементы, которые производные классы могут непосредственно использовать, плюс набор функций, которые производные классы должны реализовать путем их переопределения.
Наличие общего интерфейса и его множественной реализации является важным, поскольку помогает программистам разрабатывать сложные программы. Например, доступ ко всем объектам, порожденным от некоторого базового класса, осуществляется одинаковым способом, даже если реальные действия этих объектов отличаются при переходе от одного производного класса к другому. Это означает, что необходимо запомнить только один интерфейс, а не несколько. Более того, отделение интерфейса от реализации позволяет создавать библиотеки классов, поставляемые независимыми разработчиками. Если эти библиотеки реализованы корректно, то они обеспечивают общий интерфейс, и их можно использовать для вывода своих собственных специфических классов.
Чтобы понять всю мощь идеи «один интерфейс — множество методов», рассмотрим следующую короткую программу. Она создает базовый класс figure. Этот класс используется для хранения размеров различных двумерных объектов и для вычисления их площадей. Функция set_dim() является стандартной функцией-членом, поскольку ее действия являются общими для всех производных классов. Однако функция show_area() объявляется как виртуальная функция, поскольку способ вычисления площади каждого объекта является специфическим. Программа использует класс figure для вывода двух специфических классов square и triangle.
#include <iostream.h>
class figure
{
protected:
double x, y;
public:
void set_dim(double i, double j) { x = i; У = j; }
virtual void show_area() { cout << "No area computation defined for this class.\n"; }
};
class triangle: public figure
{
public:
void show_area()
{
cout << "Triangle with height " << x << " and base " << y << " has an area of " << x * 0.5 * у;
}
};
class square: public figure
{
public:
void show_area()
{
cout << "Square with dimensions “ << x << "x" << y << " has an area of " << x * у;
}
};
int main()
{
figure *p; /* создание указателя базового типа */
triangle t; /* создание объектов порожденных типов */
square s;
p = &t; p->set_dim(10.0, 5.0); p->show_area();
p = &s; p->set_dim(10.0, 5.0); p->show_area() ;
return 0;
}
Как можно видеть на основе анализа этой программы, интерфейс классов square и triangle является одинаковым, хотя оба обеспечивают свои собственные методы для вычисления площади каждой из фигур. На основе объявления класса figure можно вывести класс circle, вычисляющий площадь, ограниченную окружностью заданного радиуса. Для этого необходимо создать новый производный класс, в котором реализовано вычисление площади круга. Вся сила виртуальной функции основана на том факте, что можно легко вывести новый класс, разделяющий один и тот же общий интерфейс с другими родственными классами. В качестве примера здесь показан один из способов реализации:
class circle: public figure
{
public:
void show_area()
{
cout << "Circle with radius " << x << " has an area of " << 3.14 * x * x;
}
};
Прежде чем использовать класс circle, посмотрим внимательно на определение функции show_area(). Обратим внимание, что она использует только величину х, которая выражает радиус. Как известно, площадь круга вычисляется по формуле pR2. Однако функция set_dim(), определенная в базовом классе figure, требует не одного, а двух аргументов. Поскольку класс circle не нуждается во второй величине, то как нам быть в данной ситуации?
Имеются два пути для решения этой проблемы. Первый заключается в том, чтобы вызвать set_dim(), используя в качестве второго параметра фиктивный параметр, который не будет использован. Недостатком такого подхода служит необходимость запомнить этот исключительный случай, что по существу нарушает принцип «один интерфейс — множество методов».
Лучшее решение данной проблемы связано с использованием параметра y в set_dim() со значением по умолчанию. В таком случае при вызове set_dim() для круга необходимо указать только радиус. При вызове set_dim() для треугольника или прямоугольника укажем обе величины:
#include <iostream.h>
class figure
{
protected:
double x, y;
public:
void set_dim(double i, double j=0) { x = i; У = j; }
virtual void show_area() { cout << "No area computation defined for this class.\n"; }
};
int main()
{
figure *p; /* создание указателя базового типа */
circle с;
p = &с;
p->set_dim(9.0); p->show_area();
return 0;
}
Этот пример также показывает, что при определении базового класса важно проявлять максимально возможную гибкость. Не следует налагать на программу какие-то ненужные ограничения.