На попередніх уроках ми розглянули використання спадкування для отримання нових класів з існуючих. На уроках цього розділу ми зосередимося на одному з найважливіших і найпотужніших аспектів спадкування — віртуальних функціях. Але, перш ніж ми перейдемо до вивчення віртуальних функцій, давайте спочатку визначимося, навіщо це взагалі нам потрібно.
Вказівники, посилання і дочірні класи
З уроку №163 ми знаємо, що при створенні об’єкта дочірнього класу виконується побудова двох частин, з яких цей об’єкт і складається: батьківська і дочірня. Наприклад:
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 |
class Parent { protected: int m_value; public: Parent(int value) : m_value(value) { } const char* getName() { return "Parent"; } int getValue() { return m_value; } }; class Child: public Parent { public: Child(int value) : Parent(value) { } const char* getName() { return "Child"; } int getValueDoubled() { return m_value * 2; } }; |
При створенні об’єкта класу Child спочатку виконується побудова частини Parent, а потім вже частини Child. Пам’ятайте, що тип відносин в спадкуванні — «є». Оскільки Child «є» Parent, то логічно, що Child містить частину Parent.
Ми можемо дати команду вказівникам і посиланням класу Child вказувати на інші об’єкти класу Child:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
#include <iostream> int main() { Child child(7); std::cout << "child is a " << child.getName() << " and has value " << child.getValue() << '\n'; Child &rChild = child; std::cout << "rChild is a " << rChild.getName() << " and has value " << rChild.getValue() << '\n'; Child *pChild = &child; std::cout << "pChild is a " << pChild->getName() << " and has value " << pChild->getValue() << '\n'; return 0; } |
Результат:
child is a Child and has value 7
rChild is a Child and has value 7
pChild is a Child and has value 7
Цікаво, оскільки Child має частину Parent, то чи можемо ми дати команду вказівнику або посиланню класу Parent вказувати на об’єкт класу Child? Виявляється, що можемо!
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
#include <iostream> int main() { Child child(7); // Все коректно! Parent &rParent = child; Parent *pParent = &child; std::cout << "child is a " << child.getName() << " and has value " << child.getValue() << '\n'; std::cout << "rParent is a " << rParent.getName() << " and has value " << rParent.getValue() << '\n'; std::cout << "pParent is a " << pParent->getName() << " and has value " << pParent->getValue() << '\n'; return 0; } |
Результат:
child is a Child and has value 7
rParent is a Parent and has value 7
pParent is a Parent and has value 7
Але це може бути не зовсім те, що ви очікували побачити!
Оскільки rParent
і pParent
є посиланням і вказівником класу Parent, то вони можуть бачити тільки члени класу Parent (і члени будь-яких інших класів, які наслідує Parent). Таким чином, вказівник/посилання класу Parent не може побачити Child::getName(). Отже, викликається Parent::getName(), а rParent
і pParent
повідомляють, що вони відносяться до класу Parent, а не до класу Child.
Зверніть увагу, це також означає, що неможливо викликати Child::getValueDoubled() через rParent
або pParent
. Вони нічого не можуть бачити в класі Child.
Ось складніший приклад:
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 56 57 58 |
#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"; } }; int main() { Cat cat("Matros"); std::cout << "cat is named " << cat.getName() << ", and it says " << cat.speak() << '\n'; Dog dog("Barsik"); std::cout << "dog is named " << dog.getName() << ", and it says " << dog.speak() << '\n'; Animal *pAnimal = &cat; std::cout << "pAnimal is named " << pAnimal->getName() << ", and it says " << pAnimal->speak() << '\n'; pAnimal = &dog; std::cout << "pAnimal is named " << pAnimal->getName() << ", and it says " << pAnimal->speak() << '\n'; return 0; } |
Результат виконання програми:
cat is named Matros, and it says Meow
dog is named Barsik, and it says Woof
pAnimal is named Matros, and it says ???
pAnimal is named Barsik, and it says ???
Тут ми бачимо ту ж проблему. Оскільки pAnimal
є вказівником типу Animal, то він може бачити тільки частину Animal. Отже, pAnimal->speak()
викликає Animal::speak(), а не Dog::Speak() чи Cat::speak().
Вказівники, посилання і батьківські класи
Тепер ви можете сказати: «Вищенаведені приклади здаються безглуздими. Чому я повинен використовувати вказівник або посилання батьківського класу на об’єкт дочірнього класу, якщо я можу просто використовувати дочірній об’єкт?». Виявляється, на це є кілька вагомих причин.
По-перше, припустимо, що ви хочете написати функцію, яка виводитиме ім’я та звук тварини. Без використання вказівника на батьківський клас, вам доведеться реалізувати це через перевантаження функцій. Наприклад:
1 2 3 4 5 6 7 8 9 |
void report(Cat &cat) { std::cout << cat.getName() << " says " << cat.speak() << '\n'; } void report(Dog &dog) { std::cout << dog.getName() << " says " << dog.speak() << '\n'; } |
Не дуже важко, але уявіть, що у нас 30 різних типів тварин. Нам довелося б написати 30 перевантажень! Крім того, якщо ви коли-небудь додасте новий тип тварини, то вам також доведеться написати нову функцію для цього типу тварини. Це величезна трата часу.
Однак, оскільки Cat і Dog наслідують Animal, Cat і Dog мають частину Animal, тому ми можемо зробити наступне:
1 2 3 4 |
void report(Animal &rAnimal) { std::cout << rAnimal.getName() << " says " << rAnimal.speak() << '\n'; } |
Це дозволить нам передавати будь-який клас, який є дочірнім класу Animal! Замість окремого методу на кожен дочірній клас ми записали один метод, який працює відразу з усіма дочірніми класами!
Проблема, звичайно, в тому, що, оскільки rAnimal
є посиланням класу Animal, rAnimal.speak()
викличе Animal::speak() замість методу speak() дочірнього класу.
По-друге, припустимо, що у нас є 3 кішки і 3 собаки, яких ми хотіли б зберегти в масиві для легкого доступу до них. Оскільки масиви можуть містити об’єкти тільки одного типу, то без вказівників/посилань на батьківський клас, нам би довелося створювати окремий масив для кожного дочірнього класу. Наприклад:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
#include <iostream> int main() { Cat cats[] = { Cat("Matros"), Cat("Ivan"), Cat("Martun") }; Dog dogs[] = { Dog("Barsik"), Dog("Tolik"), Dog("Tyzik") }; for (int iii=0; iii < 3; ++iii) std::cout << cats[iii].getName() << " says " << cats[iii].speak() << '\n'; for (int iii=0; iii < 3; ++iii) std::cout << dogs[iii].getName() << " says " << dogs[iii].speak() << '\n'; return 0; } |
Тепер уявіть, що у нас 30 різних типів тварин. Нам довелося б створити 30 масивів — по одному на кожен тип тварини!
Однак, оскільки Cat і Dog наслідують Animal, то можна зробити наступне:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
#include <iostream> int main() { Cat matros("Matros"), ivan("Ivan"), martun("Martun"); Dog barsik("Barsik"), tolik("Tolik"), tyzik("Tyzik"); // Створюємо масив вказівників на наші об'єкти Cat і Dog Animal *animals[] = { &matros, &ivan, &martun, &barsik, &tolik, &tyzik}; for (int iii=0; iii < 6; ++iii) std::cout << animals[iii]->getName() << " says " << animals[iii]->speak() << '\n'; return 0; } |
Хоча це скомпілюється і виконається, але, на жаль, той факт, що кожен елемент масиву animals
є вказівником на Animal, означає, що animals[iii]->speak()
викликатиме Animal::speak(), замість методів speak() дочірніх класів.
Хоча обидва ці способи можуть заощадити нам багато часу і зусиль, вони мають одну і ту ж проблему: вказівник або посилання батьківського класу викликає батьківську версію функції, а не дочірню. От якби був якийсь спосіб змусити батьківські вказівники викликати методи дочірніх класів.
Вгадайте тепер, навіщо потрібні віртуальні функції? 🙂
Тест
Наш приклад з Animal/Cat/Dog не працює так, як ми хочемо, тому що посилання/вказівник класу Animal не може отримати доступ до методів speak() дочірніх класів. Один із способів обійти цю проблему — зробити так, щоб дані, які повертаються методом speak(), стали доступними у вигляді батьківської частини класу Animal (так само, як name
класу Animal доступний через член m_name
).
Оновіть класи Animal, Cat і Dog у вищенаведеному коді, додавши новий член m_speak
в клас Animal. Ініціалізуйте його відповідним чином. Наступна програма повинна працювати коректно:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
#include <iostream> int main() { Cat matros("Matros"), ivan("Ivan"), martun("Martun"); Dog barsik("Barsik"), tolik("Tolik"), tyzik("Tyzik"); // Створюємо масив вказівників на наші об'єкти Cat і Dog Animal *animals[] = { &matros, &ivan, &martun, &barsik, &tolik, &tyzik}; for (int iii=0; iii < 6; iii++) std::cout << animals[iii]->getName() << " says " << animals[iii]->speak() << '\n'; return 0; } |
Відповідь
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 |
#include <iostream> #include <string> class Animal { protected: std::string m_name; const char* m_speak; // Ми робимо цей конструктор protected, тому що не хочемо, щоб користувачі могли створювати об'єкти класу Animal напряму, // але хочемо, щоб у дочірніх класів доступ був відкритим Animal(std::string name, const char* speak) : m_name(name), m_speak(speak) { } public: std::string getName() { return m_name; } const char* speak() { return m_speak; } }; class Cat: public Animal { public: Cat(std::string name) : Animal(name, "Meow") { } }; class Dog: public Animal { public: Dog(std::string name) : Animal(name, "Woof") { } }; int main() { Cat matros("Matros"), ivan("Ivan"), martun("Martun"); Dog barsik("Barsik"), tolik("Tolik"), tyzik("Tyzik"); // Створюємо масив вказівників на наші об'єкти Cat і Dog Animal *animals[] = { &matros, &ivan, &martun, &barsik, &tolik, &tyzik}; for (int iii=0; iii < 6; iii++) std::cout << animals[iii]->getName() << " says " << animals[iii]->speak() << '\n'; return 0; } |
Примітка: Ви також можете зробити m_speak
типу std::string, але недоліком є те, що кожен об’єкт класу Animal міститиме зайву копію рядка speak
, а побудова об’єктів Animal займе більше часу, тому що глибоке копіювання std::string виконуватиметься повільніше, ніж копіювання вказівника, який вказує на константний рядок C-style.