На предыдущем уроке мы рассматривали ряд примеров, в которых использование указателей или ссылок родительского класса упрощало логику и уменьшало количество кода.
Виртуальные функции и Полиморфизм
Тем не менее, мы сталкивались с проблемой, когда родительский указатель или ссылка вызывали только родительские методы, а не дочерние. Например:
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().
У меня есть некоторые сомнения, стоило бы сразу рассказать о таком ключевом слове как override, а не в отдельной статье. Мне как изучившему сразу это было сложно понять почему нигде не контролируется переопределение функции. Может быть компилятор считает все функции virtual каждый раз разными а не одной сигнатуры. Я же и без virtual + override в другом наследуемом классе могу назвать функцию точно так же и работать она будет.
Вопрос явно не в тему но все же интересно, это такая "фича" или нет? Если достаточно уменьшить окно браузера, самолетик для возврата на начало страницы, попадает на пример кода. Более светлы оттенок перекрывает самолетик, а более темный цвет — нет. В итоге получается своеобразная зебра. Я бы еще скинул скрин, но в комментариях нет такой возможности.
Это больше похоже на баг, нежели на фичу 🙂 Я понимаю, о чем Вы говорите. Пробовал это исправить, но здесь получается конфликт кода плагина и кода скрипта, который средствами CSS исправить не получается, поэтому пока так.
В чем причина того, что метод в дочернем классе, у которого одна и та же сигнатура, но отличается лишь тип возвращаемого выражения, нельзя считать переопределенным. Ведь для перегрузок компилятор не смотрит на тип возврата, а для виртуальных функций это имеет значение. В чем причина такого решения?
Причина в том, что виртуальный метод базового класса задает *интерфейс* для всех наследников. Т.е. должны быть возможность использовать виртуальные методы базового класса так, как будто это методы наследника (потому что именно им и передается управление в результате позднего связывания). Если возвращаемые типы будут отличаться, то вы не сможете это сделать. Допустим вы возвращаете целое в базовом классе, а в наследнике написали возврат своего какого-то класса. И что дальше делать? Клиент *интерфейса* ждет возврат целого, вызывает метод, дальше происходит позднее связывание и управление передается в функцию наследника, которая возвращает объект какого-нибудь класса A. Что делать клиенту, он ждет целое и вызывал метод с сигнатурой, которая предполагает возврат целого, а ему на рантайме подсунули A. Полная ерунда, согласитесь? 🙂
Опять же спорный момент о снижении скорости виртуальных методов. Некоторые это даже приравнивают к интерпретации. Но вся виртуальность выполнена на таблице vTable (что позже легло в основу технологии COM). И опять же, сложность возрастает лишь на время извлечения адреса функции из таблицы, а это не более 5 машинных тактов, как и в случаем обращения по ссылке/указателю.
Очень даже влияет на скорость. Если представить, что это какой-то критический участок кода, где происходит огромнейшее число таких вызовов функций, то эти дельта участки времени скажутся очень даже неплохо.
А зачем для char возвращать значение с помощью указателя?
Наследие C. Строка представляет собой массив символов,
char* speak(){ return "Woof";} возвращает указатель на первый элемент массива с символами 'W', 'o', 'o', 'f'. Работает быстрее чем std::string и требует меньше памяти.
и забыли '\0' в конце, тут это важно
Не забыли. Компилятор автоматически добавляет '\0' к статичной строке.