На даному уроці ми розглянемо, що таке віртуальні функції та поліморфізм в С++.
Віртуальні функції і Поліморфізм
На попередньому уроці ми розглянули ряд прикладів, в яких використання вказівників або посилань батьківського класу спрощувало логіку і зменшувало кількість коду. Проте, ми стикалися з проблемою, коли батьківський вказівник або посилання викликали тільки батьківські методи, а не дочірні. Наприклад:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 |
#include <iostream> class Parent { public: const char* getName() { return "Parent"; } }; class Child: public Parent { public: const char* getName() { return "Child"; } }; int main() { Child child; Parent &rParent = child; std::cout << "rParent is a " << rParent.getName() << '\n'; } |
Результат:
rParent is a Parent
Оскільки rParent
є посиланням класу Parent, то викликається Parent::getName(), хоча фактично ми посилаємося на частину Parent об’єкту child
. На цьому уроці ми розглянемо, як можна вирішити дану проблему за допомогою віртуальних функцій.
Віртуальна функція в мові С++ — це особливий тип функції, яка, при її виклику, виконує «найдочірніший» метод, який існує між батьківським і дочірніми класами. Ця властивість відома як поліморфізм. Дочірній метод викликається тоді, коли збігається сигнатура (ім’я, типи параметрів і чи є метод константним) і тип повернення дочірнього методу з сигнатурою і типом повернення методу батьківського класу. Такі методи називаються перевизначеннями (або “перевизначеними методами”).
Щоб зробити функцію віртуальною, потрібно просто вказати ключове слово virtual перед оголошенням функції. Наприклад:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 |
#include <iostream> class Parent { public: virtual const char* getName() { return "Parent"; } // додали ключове слово virtual }; class Child: public Parent { public: virtual const char* getName() { return "Child"; } }; int main() { Child child; Parent &rParent = child; std::cout << "rParent is a " << rParent.getName() << '\n'; return 0; } |
Результат:
rParent is a Child
Оскільки rParent
є посиланням на батьківську частину об’єкту child
, то, звичайно, при обробці rParent.getName()
викликався б Parent::getName(). Проте, оскільки Parent::getName() є віртуальною функцією, то компілятор розуміє, що потрібно подивитися, чи є перевизначення цього методу в дочірніх класах. І компілятор знаходить Child::getName()!
Розглянемо складніший приклад:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 |
#include <iostream> class A { public: virtual const char* getName() { return "A"; } }; class B: public A { public: virtual const char* getName() { return "B"; } }; class C: public B { public: virtual const char* getName() { return "C"; } }; class D: public C { public: virtual const char* getName() { return "D"; } }; int main() { C c; A &rParent = c; std::cout << "rParent is a " << rParent.getName() << '\n'; return 0; } |
Як ви думаєте, який результат виконання цієї програми?
Розглянемо все по порядку:
Спочатку створюється об’єкт c
класу C.
rParent
— це посилання класу A, якому ми вказуємо посилатися на частину A об’єкту c
.
Потім викликається метод rParent.getName()
.
Виклик rParent.GetName()
призводить до виклику A::getName(). Однак, оскільки A::getName() є віртуальною функцією, то компілятор шукає «найдочірніший» метод між A і C. У цьому випадку — це C::getName().
Зверніть увагу, компілятор не викликатиме D::getName(), оскільки наш вихідний об’єкт був класу C, а не класу D, тому розглядаються методи тільки між класами A і C.
Результат виконання програми:
Складніший приклад
Розглянемо клас Animal з попереднього уроку, додавши тестовий код:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 |
#include <iostream> #include <string> class Animal { protected: std::string m_name; // Ми робимо цей конструктор protected, тому що не хочемо, щоб користувачі мали можливість створювати об'єкти класу Animal напряму, // але хочемо, щоб в дочірніх класах доступ був відкритий Animal(std::string name) : m_name(name) { } public: std::string getName() { return m_name; } const char* speak() { return "???"; } }; class Cat: public Animal { public: Cat(std::string name) : Animal(name) { } const char* speak() { return "Meow"; } }; class Dog: public Animal { public: Dog(std::string name) : Animal(name) { } const char* speak() { return "Woof"; } }; void report(Animal &animal) { std::cout << animal.getName() << " says " << animal.speak() << '\n'; } int main() { Cat cat("Matros"); Dog dog("Barsik"); report(cat); report(dog); } |
Результат виконання програми:
Matros says ???
Barsik says ???
А тепер розглянемо той же клас, але зробивши метод speak() віртуальним:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 |
#include <iostream> #include <string> class Animal { protected: std::string m_name; // Ми робимо цей конструктор protected, тому що не хочемо, щоб користувачі мали можливість створювати об'єкти класу Animal напряму, // але хочемо, щоб в дочірніх класах доступ був відкритий Animal(std::string name) : m_name(name) { } public: std::string getName() { return m_name; } virtual const char* speak() { return "???"; } }; class Cat: public Animal { public: Cat(std::string name) : Animal(name) { } virtual const char* speak() { return "Meow"; } }; class Dog: public Animal { public: Dog(std::string name) : Animal(name) { } virtual const char* speak() { return "Woof"; } }; void report(Animal &animal) { std::cout << animal.getName() << " says " << animal.speak() << '\n'; } int main() { Cat cat("Matros"); Dog dog("Barsik"); report(cat); report(dog); } |
Результат виконання програми:
Matros says Meow
Barsik says Woof
Спрацювало!
При обробці animal.speak()
, компілятор бачить, що метод Animal::speak() є віртуальною функцією. Коли animal
посилається на частину Animal об’єкту cat
, то компілятор переглядає всі класи між Animal і Cat, щоб знайти найбільш дочірній метод speak(). І знаходить Cat::speak(). У випадку, коли animal
посилається на частину Animal об’єкту dog
, компілятор знаходить Dog::speak().
Зверніть увагу, ми не зробили Animal::GetName() віртуальною функцією. Це через те, що GetName() ніколи не перевизначається ні в одному з дочірніх класів, тому в цьому немає необхідності.
Аналогічно з наступним прикладом з масивом тварин:
1 2 3 4 5 6 7 |
Cat matros("Matros"), ivan("Ivan"), martun("Martun"); Dog barsik("Barsik"), tolik("Tolik"), tyzik("Tyzik"); // Створюємо масив вказівників на наші об'єкти Cat і Dog Animal *animals[] = { &matros, &barsik, &ivan, &tolik, &martun, &tyzik}; for (int iii=0; iii < 6; ++iii) std::cout << animals[iii]->getName() << " says " << animals[iii]->speak() << '\n'; |
Результат:
Matros says Meow
Barsik says Woof
Ivan says Meow
Tolik says Woof
Martun says Meow
Tyzik says Woof
Незважаючи на те, що ці два приклади використовують тільки класи Cat і Dog, будь-які інші дочірні класи також працюватимуть з нашою функцією report() і з масивом тварин без внесення додаткових модифікацій! Це, мабуть, найбільша перевага віртуальних функцій — можливість структурувати код таким чином, щоб нові дочірні класи автоматично працювали зі старим кодом без необхідності внесення змін з боку програміста!
Попередження: Сигнатура віртуального методу дочірнього класу повинна повністю відповідати сигнатурі віртуального методу батьківського класу. Якщо у дочірнього методу буде інший тип параметрів, ніж у батьківського, то викликатися цей метод не буде.
Використання ключового слова virtual
Якщо функція позначена як віртуальна, то всі відповідні перевизначення теж вважаються віртуальними, навіть якщо біля них явно не вказано ключове слова virtual. Однак, наявність ключового слова virtual біля методів дочірніх класів послужить корисним нагадуванням про те, що ці методи є віртуальними, а не звичайними. Отже, хорошою практикою є вказування ключового слова virtual біля перевизначень в дочірніх класах, навіть якщо це не є строго необхідним.
Типи повернення віртуальних функцій
Типи повернення віртуальної функції і її перевизначень повинні збігатися. Розглянемо наступний приклад:
1 2 3 4 5 6 7 8 9 10 11 |
class Parent { public: virtual int getValue() { return 7; } }; class Child: public Parent { public: virtual double getValue() { return 9.68; } }; |
В цьому випадку Child::getValue() не рахується підходящим перевизначенням для Parent::getValue(), тому що типи повернень різні (метод Child::getValue() вважається повністю окремою функцією).
Не викликайте віртуальні функції в тілі конструкторів або деструкторів
Ось ще одна пастка для початківців. Ви не повинні викликати віртуальні функції в тілі конструкторів або деструкторів. Чому?
Пам’ятаєте, що при створенні об’єкта класу Child спочатку створюється батьківська частина цього об’єкту, а потім вже дочірня? Якщо ви викликатимете віртуальну функцію з конструктора класу Parent при тому, що дочірня частина створюваного об’єкта ще не була створена, то викликати дочірній метод замість батьківського буде неможливо, тому що об’єкт child
для роботи з методом класу Child ще не буде створений. У таких випадках в мові C++ викликатиметься батьківська версія методу.
Аналогічна проблема існує і з деструкторами. Якщо ви викликаєте віртуальну функцію в тілі деструктора класу Parent, то завжди викликатиметься метод класу Parent, тому що дочірня частина об’єкту вже буде знищена.
Правило: Ніколи не викликайте віртуальні функції в тілі конструкторів або деструкторів.
Недолік віртуальних функцій
«Якщо все так добре з віртуальними функціями, то чому б не зробити всі методи віртуальними?» — запитаєте Ви. Відповідь: “Це неефективно!”. Обробка і виконання виклику віртуального методу займає більше часу, ніж обробка і виконання виклику звичайного методу. Крім того, компілятор також повинен виділяти один додатковий вказівник для кожного об’єкта класу, який має одну або кілька віртуальних функцій.
Тест
Який результат виконання наступних програм? Не потрібно запускати/виконувати наступний код, ви повинні визначити результат без допомоги своєї IDE.
a)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 |
#include <iostream> class A { public: virtual const char* getName() { return "A"; } }; class B: public A { public: virtual const char* getName() { return "B"; } }; class C: public B { public: // Примітка: Тут немає методу getName() }; class D: public C { public: virtual const char* getName() { return "D"; } }; int main() { C c; A &rParent = c; std::cout << rParent.getName() << '\n'; return 0; } |
Відповідь a)
Результат:
B
rParent
— це посилання класу A на об’єкт c
. rParent.getName()
викликає A::getName(), але, оскільки A::getName() є віртуальною функцією, викликатиметься “найдочірніший” метод між класами A і C. А це B::getName(), тому що в класі C методу getName() немає.
b)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 |
#include <iostream> class A { public: virtual const char* getName() { return "A"; } }; class B: public A { public: virtual const char* getName() { return "B"; } }; class C: public B { public: virtual const char* getName() { return "C"; } }; class D: public C { public: virtual const char* getName() { return "D"; } }; int main() { C c; B &rParent = c; // примітка: rParent на цей раз класу B std::cout << rParent.getName() << '\n'; return 0; } |
Відповідь b)
Результат:
C
Все доволі просто, C::getName() — це “найдочірніший” метод між класами B і C.
c)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 |
#include <iostream> class A { public: const char* getName() { return "A"; } // примітка: Немає ключового слова virtual }; class B: public A { public: virtual const char* getName() { return "B"; } }; class C: public B { public: virtual const char* getName() { return "C"; } }; class D: public C { public: virtual const char* getName() { return "D"; } }; int main() { C c; A &rParent = c; std::cout << rParent.getName() << '\n'; return 0; } |
Відповідь c)
Результат:
A
Оскільки getName() класу A не є віртуальним методом, то при обробці rParent.getName()
викликається A::getName().
d)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 |
#include <iostream> class A { public: virtual const char* getName() { return "A"; } }; class B: public A { public: const char* getName() { return "B"; } // примітка: Немає ключового слова virtual }; class C: public B { public: const char* getName() { return "C"; } // примітка: Немає ключового слова virtual }; class D: public C { public: const char* getName() { return "D"; } // примітка: Немає ключового слова virtual }; int main() { C c; B &rParent = c; // примітка: rParent на цей раз класу B std::cout << rParent.getName() << '\n'; return 0; } |
Відповідь d)
Результат:
C
Хоча B і C не є віртуальними функціями, але A::getName() є віртуальною функцією, а B::getName() і C::getName() є перевизначеннями. Отже, B::getName() і C::getName() вважаються неявно віртуальними, і тому виклик rParent.getName() викличе C::getName().
e)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 |
#include <iostream> class A { public: virtual const char* getName() const { return "A"; } // примітка: Метод є const }; class B: public A { public: virtual const char* getName() { return "B"; } }; class C: public B { public: virtual const char* getName() { return "C"; } }; class D: public C { public: virtual const char* getName() { return "D"; } }; int main() { C c; A &rParent = c; std::cout << rParent.getName() << '\n'; return 0; } |
Відповідь e)
Результат:
A
Це вже трохи складніше. rParent
— це посилання класу A на об’єкт c
, тому rParent.getName()
викликає A::getName(). Але оскільки A::getName() є віртуальною функцією, то викликається найбільш дочірній метод між A і C — A::getName(). Оскільки B::getName() і С::getName() не є const, то вони не вважаються перевизначеннями!
f)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 |
#include <iostream> class A { public: A() { std::cout << getName(); } // зверніть увагу на наявність конструктора virtual const char* getName() { return "A"; } }; class B : public A { public: virtual const char* getName() { return "B"; } }; class C : public B { public: virtual const char* getName() { return "C"; } }; class D : public C { public: virtual const char* getName() { return "D"; } }; int main() { C c; return 0; } |
Відповідь f)
Результат:
А
Ще одне хитре завдання. При створенні об’єкта c
, спочатку виконується побудова батьківської частини A. Для цього викликається конструктор A, який, в свою чергу, викликає віртуальну функцію getName(). Оскільки частини класів B і C ще не створені, то виконується A::getName().