На предыдущих уроках мы рассматривали использование наследования для получения новых классов из существующих. На уроках в этой главе мы сосредоточимся на одном из самых важных и мощных аспектов наследования — виртуальных функциях. Но, прежде чем мы перейдем к изучению виртуальных функций, давайте сначала определимся, зачем это вообще нам нужно.
Указатели, ссылки и дочерние классы
Из урока №155 мы знаем, что при создании объекта дочернего класса выполняется построение двух частей, из которых этот объект и состоит: родительская и дочерняя. Например:
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.
В примечании к тесту указано, что для члена m_speak предпочтительнее использовать тип const char*, чтобы не плодить лишние копии строки speak, и глубокое копирование std::string будет выполняться медленнее.
Но в то же время в классе есть член m_name, который имеет тип std::string со всеми вытекающими недостатками.
Не правильнее было бы, используя данные доводы, сделать все члены типа const char* ?
Или же с членом m_name "под капотом" происходят другие процессы, отличные от члена m_speak?
Да, как-то неоднозначно получается. Используются и const char* и std::string для одних и тех же членов класса.
К тому же в конце 79 урока жирным шрифтом констатируется:
Правило: Используйте std::string вместо строк C-style.
И в этом весь C++ )))
Ну и причем же тут C++?
Каждому инструменту свое место. Не важно, C++ у вас или Python, или еще что. Вы в любом случае должны знать весь инструментарий, который дает язык и уметь адекватно его применять.
Может быть тогда так:
Разница в том, что m_name хранит *копию* имени, переданного *извне* (т.е. заданного пользователем класса), а для m_speak автор заложил неявный контракт, что его инициализация будет происходить только в наследнике и больше нигде, и только таким безопасным способом, как использование строкового литерала. Таким образом он слегка сэкономил на спичках.