Виртуальные функции и полиморфизм

Вопрос, который отсекает тех, кто выучил синтаксис ООП, от тех, кто понимает, как он работает: «Что выведет этот код, если убрать слово 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 не обязателен, но защищает от опечаток, из-за которых переопределение молча не сработает.
Проверьте себя
1. В базовом классе метод НЕ помечен как virtual. Что определит, какая версия метода вызовется через Zhivotnoe* zhivotnoe = new Sobaka(); zhivotnoe->izdatZvuk();?
AРеальный тип объекта, на который указывает указатель, то есть Sobaka
BТип, которым объявлен указатель, то есть Zhivotnoe
CПрограмма не скомпилируется без virtual
DМетод выберется случайно в зависимости от компилятора
2. Зачем нужно ключевое слово override при переопределении виртуального метода, если оно не обязательно?
AОно ускоряет выполнение программы за счёт оптимизации компилятора
BОно заставляет компилятор проверить точное совпадение сигнатуры с виртуальным методом базового класса и выдать ошибку при несовпадении
CБез него виртуальные функции вообще не будут работать
DОно нужно только для методов без параметров