Можливо багатьом це здасться дивним, але давайте розглянемо альтернативне рішення: функція-член похідного класу має доступ до приватних членів свого базового класу. Тоді саме поняття частки (закритого) члена втрачає всякий зміст, оскільки для доступу до нього досить просто визначити похідний клас. Тепер уже буде недостатньо для з'ясування, хто використає приватні члени класу, переглянути всі функції-члени й друзів цього класу. Прийдеться переглянути всі вихідні файли програми, знайти похідні класи, потім досліджувати кожну функцію цих класів. Далі треба знову шукати похідні класи від уже знайдених і т.д. Це, принаймні, утомливо, а швидше за все нереально. Потрібно всюди, де це можливо, використати замість приватних членів захищені (protected).
Як правило, саме надійне рішення для похідного класу - використати тільки загальні члени свого базового класу:
void manager:: print () const
{
employee:: print (); // друк даних про службовців
// друк даних про керуючих
}
Відзначимо, що операція:: необхідна, оскільки функція print () перевизначена в класі manager. Таке повторне використання імен типово для С++. Необережний програміст написав би:
void manager:: print () const
{
print (); // печатка даних про службовців
// печатка даних про керуючих
}
У результаті він одержав би рекурсивну послідовність викликів manager:: print ().
Для деяких похідних класів потрібні конструктори. Якщо конструктор є в базовому класі, то саме він і повинен викликатися із вказівкою параметрів, якщо такі в нього є:
class employee {
// ...
public:
// ...
employee (char* n, int d);
};
class manager: public employee {
// ...
public:
// ...
manager (char* n, int i, int d);
};
Параметри для конструктора базового класу задаються у визначенні конструктора похідного класу. У цьому змісті базовий клас виступає як клас, що є членом похідного класу:
manager:: manager (char* n, int l, int d)
: employee (n,d), level (l), group (0)
{
}
Конструктор базового класу employee:: employee () може мати таке визначення:
employee:: employee (char* n, int d)
: name (n), department (d)
{
next = list;
list = this;
}
Тут list повинен бути описаний як статичний член employee.
Об'єкти класів створюються знизу вверх: спочатку базові, потім члени й, нарешті, самі похідні класи. Знищуються вони у зворотному порядку: спочатку самі похідні класи, потім члени, а потім базові. Члени й базові створюються в порядку опису їх у класі, а знищуються вони у зворотному порядку.
Похідний клас сам у свою чергу може бути базовим класом:
class employee {/*... */ };
class manager: public employee {/*... */ };
class director: public manager {/*... */ };
Така безліч зв'язаних між собою класів звичайно називають ієрархією класів. Звичайно вона представляється деревом, але бувають ієрархії з більш загальною структурою у вигляді графа:
class temporary {/*... */ };
class secretary: public employee {/*... */ };
class tsec
: public temporary, public secretary { /*... */ };
class consultant
: public temporary, public manager { /*... */ };
Бачимо, що класи в С++ можуть утворювати спрямований ациклічний граф.
Щоб похідні класи були не просто зручною формою короткого опису, у реалізації мови повинно бути вирішено питання: якому з похідних класів ставиться об'єкт, на який дивиться вказівник base*? Існує три основних способи відповіді:
[1] Забезпечити, щоб вказівник міг посилатися на об'єкти тільки одного типу;
[2] Помістити в базовий клас поле типу, що зможе перевіряти функції;
[3] використати віртуальні функції.
Вказівники на базові класи, звичайно, використаються при проектуванні контейнерних класів (вектор, список і т.д.). Тоді у випадку [1] ми одержимо однорідні списки, тобто списки об'єктів одного типу.
Способи [2] і [3] дозволяють створювати різнорідні списки, тобто списки об'єктів декількох різних типів (насправді, списки вказівників на ці об'єкти).
Спосіб [3] - це спеціальний надійний у сенсі типу варіант спосіб [2]. Особливо цікаві й потужні варіанти дають комбінації способів [1] і [3].
Спочатку обговоримо простий спосіб з полем типу, тобто спосіб [2]. Приклад із класами manager/employee можна перевизначити так:
struct employee {
enum empl_type { M, E };
empl_type type;
employee* next;
char* name;
short department;
// ...
};
struct manager: employee {
employee* group;
short level;
// ...
};
Маючи ці визначення, можна написати функцію, що друкує дані про довільного службовця:
void print_employee (const employee* e)
{
switch (e->type) {
case E:
cout << e->name << '\t' << e->department << '\n';
// ...
break;
case M:
cout << e->name << '\t' << e->department << '\n';
// ...
manager* p = (manager*) e;
cout << "level" << p->level << '\n';
// ...
break;
}
}
Надрукувати список службовців можна так:
void f (const employee* elist)
{
for (; elist; elist=elist->next) print_employee (elist);
}
Це цілком гарне рішення, особливо для невеликих програм, написаних однією людиною, але воно має істотний недолік: транслятор не може перевірити, наскільки правильно програміст поводиться з типами. У більших програмах це приводить до помилок двох видів. Перша - коли програміст забуває перевірити поле типу. Друга - коли в перемикачі вказуються не всі можливі значення поля типу. Цих помилок досить легко уникнути в процесі написання програми, але зовсім нелегко уникнути їх при внесенні змін у нетривіальну програму, а особливо, якщо це велика програма, написана кимось іншим. Ще сутужніше уникнути таких помилок тому, що функції типу print () часто пишуться так, щоб можна було скористатися спільністю класів:
void print (const employee* e)
{
cout << e->name << '\t' << e->department << '\n';
// ...
if (e->type == M) {
manager* p = (manager*) e;
cout << "level" << p->level << '\n';
// ...
}
}
Оператори if, подібні наведеним у прикладі, складно знайти у великій функції, що працює з багатьма похідними класами. Але навіть коли вони знайдені, нелегко зрозуміти, що відбувається насправді. Крім того, при всякім додаванні нового виду службовців потрібні зміни у всіх важливих функціях програми, тобто функціях, що перевіряють поле типу. У результаті доводиться правити важливі частини програми, збільшуючи тим самим час на налагодження цих частин.
Іншими словами, використання поля типу чревате помилками й труднощами при супроводі програми. Труднощі різко зростають по мірі росту програми, адже використання поля типу суперечить принципам модульності й приховування даних. Кожна функція, що працює з полем типу, повинна знати подання й специфіку реалізації всякого класу, котрий є похідним для класу, що містить поле типу.
За допомогою віртуальних функцій можна перебороти труднощі, що виникають при використанні поля типу. У базовому класі описуються функції, які можуть перевизначатися в будь-якому похідному класі. Транслятор і завантажник забезпечать правильну відповідність між об'єктами й застосовуваними до них функціями:
class employee {
char* name;
short department;
// ...
employee* next;
static employee* list;
public:
employee (char* n, int d);
// ...
static void print_list ();
virtual void print () const;
};
Службове слово virtual (віртуальна) показує, що функція print () може мати різні версії в різних похідних класах, а вибір потрібної версії при виклику print () - це завдання транслятора. Тип функції вказується в базовому класі й не може бути перевизначений у похідному класі. Визначення віртуальної функції повинне даватися для того класу, у якому вона була вперше описана (якщо тільки вона не є чисто віртуальною функцією). Наприклад:
void employee:: print () const
{
cout << name << '\t' << department << '\n';
// ...
}
Ми бачимо, що віртуальну функцію можна використати, навіть якщо немає похідних класів від її класу. У похідному ж класі не обов'язково перевизначити віртуальну функцію, якщо вона там не потрібна. При побудові похідного класу треба визначати тільки ті функції, які в ньому дійсно потрібні:
class manager: public employee {
employee* group;
short level;
// ...
public:
manager (char* n, int d);
// ...
void print () const;
};
Місце функції print_employee () зайняли функції-члени print (), і вона стала не потрібна. Список службовців будує конструктор employee. Надрукувати його можна так:
void employee:: print_list ()
{
for (employee* p = list; p; p=p->next) p->print ();
}
Дані про кожного службовця будуть друкуватися відповідно до типу запису про нього. Тому програма
int main ()
{
employee e ("J. Brown",1234);
manager m ("J. Smith",2,1234);
employee:: print_list ();
}
надрукує
J. Smith 1234
level 2
J. Brown 1234
Зверніть увагу, що функція друку буде працювати навіть у тому випадку, якщо функція employee_list () була написана й трансльована ще до того, як був задуманий конкретний похідний клас manager! Очевидно, що для правильної роботи віртуальної функції потрібно в кожному об'єкті класу employee зберігати деяку службову інформацію про тип. Як правило, реалізація як така інформація використовується просто вказівник. Цей вказівник зберігається тільки для об'єктів класу з віртуальними функціями, але не для об'єктів всіх класів, і навіть для не для всіх об'єктів похідних класів. Додаткова пам'ять виділяється тільки для класів, у яких описані віртуальні функції. Помітимо, що при використанні поля типу, для нього однаково потрібна додаткова пам'ять.
Якщо у виклику функції явно зазначена операція дозволу області видимості::, наприклад, у виклику manager:: print (), то механізм виклику віртуальної функції не діє. Інакше подібний виклик привів би до нескінченної рекурсії. Уточнення імені функції дає ще один позитивний ефект: якщо віртуальна функція є підстановкою (у цьому немає нічого незвичайного), те у виклику з операцією:: відбувається підстановка тіла функції. Це ефективний спосіб виклику, якому можна застосовувати у важливих випадках, коли одна віртуальна функція звертається до іншої з тим самим об'єктом. Приклад такого випадку - виклик функції manager:: print (). Оскільки тип об'єкта явно задається в самому виклику manager:: print (), немає потреби визначати його в динаміку для функції employee:: print (), що і буде викликатися.