Виртуальные функции и полиморфизм
Вопрос, который отсекает тех, кто выучил синтаксис ООП, от тех, кто понимает, как он работает: «Что выведет этот код, если убрать слово virtual?»
Полиморфизм — способность вызвать метод через указатель или ссылку на базовый класс и получить поведение того класса, объект которого там реально лежит. virtual — ключевое слово, которое включает это поведение для конкретного метода.
Что выведет этот код?
Посмотрите на иерархию классов ниже. Обратите внимание: метод izdatZvuk в базовом классе не помечен как virtual.
#include <iostream>
class Zhivotnoe {
public:
void izdatZvuk() {
std::cout << "Какое-то животное издаёт звук" << std::endl;
}
};
class Sobaka : public Zhivotnoe {
public:
void izdatZvuk() {
std::cout << "Гав!" << std::endl;
}
};
int main() {
Zhivotnoe* zhivotnoe = new Sobaka();
zhivotnoe->izdatZvuk();
delete zhivotnoe;
return 0;
}Вывод:
Какое-то животное издаёт звукВот она, ловушка. Указатель zhivotnoe имеет тип Zhivotnoe*, хотя физически хранит адрес объекта Sobaka. Без virtual компилятор решает, какой именно izdatZvuk вызвать, ещё на этапе компиляции — глядя только на объявленный тип указателя, а не на реальный тип объекта. Раз указатель объявлен как Zhivotnoe*, вызовется версия из Zhivotnoe. Это называется раннее связывание (static binding) — какой код выполнится, известно заранее, до запуска программы.
А теперь то же самое, но с virtual
#include <iostream>
class Zhivotnoe {
public:
virtual void izdatZvuk() {
std::cout << "Какое-то животное издаёт звук" << std::endl;
}
};
class Sobaka : public Zhivotnoe {
public:
void izdatZvuk() override {
std::cout << "Гав!" << std::endl;
}
};
int main() {
Zhivotnoe* zhivotnoe = new Sobaka();
zhivotnoe->izdatZvuk();
delete zhivotnoe;
return 0;
}Вывод:
Гав!Добавили одно слово virtual перед объявлением метода в базовом классе — и поведение перевернулось. Теперь при вызове zhivotnoe->izdatZvuk() программа смотрит не на тип указателя, а на реальный тип объекта, на который он указывает, и в момент выполнения (не компиляции!) выбирает версию метода из Sobaka. Это называется позднее связывание (dynamic binding) — какой код выполнится, решается уже во время работы программы. Именно это и есть полиморфизм в C++: один и тот же вызов ведёт себя по-разному в зависимости от того, какой объект на самом деле «под указателем».
Зачем это вообще нужно
Представьте, что у вас есть массив указателей на разных животных — собак, кошек, попугаев, — и вы хотите каждого заставить «издать звук» в одном цикле, не разбираясь заранее, кто есть кто:
Zhivotnoe* zoopark[3] = { new Sobaka(), new Koshka(), new Popugay() };
for (int i = 0; i < 3; i++) {
zoopark[i]->izdatZvuk(); // у каждого свой звук, хотя тип массива один
}Без virtual этот цикл был бы бесполезен: все три вызова напечатали бы одинаковое «Какое-то животное издаёт звук», потому что компилятор ориентируется только на тип Zhivotnoe*. С virtual каждый объект «вспоминает», кто он на самом деле, и вызывает свою собственную версию метода. Это и есть главная польза полиморфизма: можно писать код, который работает с базовым классом, а вести себя он будет по-разному в зависимости от того, что туда реально положили — даже с классами, которые появятся в проекте позже.
А что такое override и обязательно ли его писать?
Слово override у метода в Sobaka — не обязательное, но крайне полезное. Оно ничего не меняет в поведении программы, но просит компилятор проверить: «а точно ли этот метод переопределяет виртуальный метод из базового класса с точно таким же именем, параметрами и типом возврата?» Если вы опечатаетесь в имени или перепутаете тип параметра, без override компилятор молча создаст новый, никак не связанный метод — и полиморфизм тихо перестанет работать. С override он выдаст ошибку компиляции сразу. На собеседовании стоит показать, что вы знаете об этой защите — это признак аккуратного стиля.
Частые ошибки на собеседовании
- Путают, от чего зависит выбор метода без
virtual: не от того, какой объект реально создан, а от объявленного типа указателя или ссылки, через который идёт вызов. - Забывают, что при вызове через сам объект напрямую (не через указатель/ссылку на базовый класс), например
Sobaka s; s.izdatZvuk();, разница между virtual и обычным методом не проявится — оба варианта выведут «Гав!», потому что тип объекта и так известен точно. - Не могут объяснить простыми словами, зачем это нужно — путаются в теории, хотя стоит просто привести пример с циклом по массиву разных объектов, как выше.
Итог
- Без
virtualкомпилятор выбирает метод по типу указателя/ссылки ещё во время компиляции (раннее связывание). - С
virtualметод выбирается по реальному типу объекта во время выполнения программы (позднее связывание) — это и есть полиморфизм. overrideне обязателен, но защищает от опечаток, из-за которых переопределение молча не сработает.