Задача, которую решают виртуальные функции
Представим зоопарк: у нас есть разные животные, все умеют издавать звук. Мы хотим хранить их в одном массиве и вызывать «голос» у каждого — но каждый должен звучать по-своему.
class Animal {
public:
void speak() { cout << "..." ; }
};
class Dog : public Animal {
public:
void speak() { cout << "Гав!"; } // хотим, чтобы это вызывалось
};
class Cat : public Animal {
public:
void speak() { cout << "Мяу!"; } // и это
};
int main() {
Animal* zoo[2];
zoo[0] = new Dog();
zoo[1] = new Cat();
zoo[0]->speak(); // ??? что выведется
zoo[1]->speak(); // ??? и это
}
Проблема: указатель имеет тип Animal*, а объект — Dog или
Cat. Какой метод вызвать? Именно этот выбор и решает механизм виртуальных
функций.
Демонстрация 1: что меняет слово virtual
Один и тот же код — с virtual и без. Нажмите кнопку и посмотрите на вывод.
Иерархия классов
Что такое таблица виртуальных методов (vtable)
Это главный механизм, который делает динамическое связывание возможным. Идея проста: для каждого класса с виртуальными функциями компилятор строит массив указателей на функции. А в каждый объект добавляет скрытое поле — указатель на этот массив.
vtable
Virtual method table. Строится компилятором один раз на класс. Это статический массив, содержащий адреса виртуальных функций этого класса.
- Одна таблица на класс (не на объект).
- Расположена в статической памяти программы.
- Слоты
[0],[1], ... — по порядку объявления виртуальных функций.
vptr
Virtual pointer. Скрытое поле, которое компилятор автоматически добавляет в каждый объект класса с виртуальными функциями.
- Одно поле на объект (обычно 8 байт).
- Указывает на
vtableтого класса, к которому объект фактически принадлежит. - Устанавливается конструктором и «переустанавливается» на каждом уровне иерархии.
vptr. Значит, программа, имея указатель базового
класса, может посмотреть в vptr объекта и определить, к какому классу
он принадлежит, — а потом вызвать правильную функцию.
Демонстрация 2: vtable для иерархии
Класс Animal и два его наследника — Dog и Cat.
Переключайте класс и смотрите, как выглядит его таблица.
Таблица виртуальных методов
Демонстрация 3: что происходит при вызове animal->speak()
Программа выполняет шесть шагов, чтобы найти и вызвать правильную функцию. Пройдите их пошагово.
Как выглядит объект с виртуальными функциями
Компилятор добавляет в начало объекта скрытое поле — vptr.
Пользовательские поля идут после него.
vptr не виден в исходном коде, но реально
существует. Из-за него размер объекта с виртуальными функциями увеличивается
на 8 байт (на 64-битной системе).
Опасность 1: срезка объекта (object slicing)
Очень коварная ошибка: код компилируется, работает, но делает не то, что вы ожидали.
Срезка при копировании по значению
void makeSound(Animal a) { // ← аргумент по значению, а не по ссылке
a.speak();
}
int main() {
Dog dog;
makeSound(dog); // выведет "..." (Animal::speak), а не "Гав!"
}
Как избежать. Всегда передавайте объекты базовых классов
по ссылке или указателю: void makeSound(Animal& a) или
void makeSound(const Animal* a).
Опасность 2: виртуальный вызов в конструкторе или деструкторе
Логика, которая не сработает
class Animal {
public:
Animal() {
speak(); // ← вызов виртуальной функции в конструкторе
}
virtual void speak() { cout << "Animal::speak"; }
virtual ~Animal() {}
};
class Dog : public Animal {
public:
void speak() override { cout << "Гав!"; }
};
int main() {
Dog dog; // при создании выведет "Animal::speak", а не "Гав!"
}
Почему так. К моменту, когда тело конструктора Animal()
начинает выполняться, объект ещё не стал Dog. Компилятор
устанавливает vptr на vtable_Animal, и вызов speak()
приводит к Animal::speak. То же самое происходит в деструкторе: он уже
«откатился» к базовому классу.
init() и вызывайте после создания объекта.
Опасность 3: невиртуальный деструктор базового класса
Утечка памяти, о которой легко забыть
class Base {
public:
~Base() { cout << "~Base "; } // ← НЕ виртуальный
};
class Derived : public Base {
char* buffer; // выделяется в конструкторе
public:
Derived() { buffer = new char[1000]; }
~Derived() { delete[] buffer; cout << "~Derived "; }
};
int main() {
Base* p = new Derived();
delete p; // выведет только "~Base" — утечка!
}
Что произошло. Компилятор видит тип Base* и вызывает
~Base. Деструктор Derived не вызывается вообще — выделенная
в нём память остаётся висеть.
delete через указатель
базового класса корректно вызовет всю цепочку деструкторов.
Статическое и динамическое связывание
Два принципиально разных способа решить, какую функцию вызвать.
| Статическое связывание | Динамическое связывание | |
|---|---|---|
| Когда выбирается функция | Во время компиляции | Во время выполнения |
| На что опирается | Тип указателя | Тип объекта, на который указывают |
| Скорость | Быстрее — прямой вызов | Чуть медленнее — через vtable |
| Когда используется | Обычные функции, невиртуальные методы | Виртуальные функции |
| Можно ли оптимизировать | Да (inline-подстановка) | Только в особых случаях |
Шпаргалка
| Что нужно | Синтаксис |
|---|---|
| Объявить виртуальную функцию | virtual void f(); |
| Чисто виртуальная (абстрактный класс) | virtual void f() = 0; |
| Переопределить в наследнике (рекомендуется) | void f() override { ... } |
| Запретить дальнейшее переопределение | void f() final; |
| Виртуальный деструктор | virtual ~Base() {} |
| Передать объект-наследник в функцию без срезки | по ссылке: void fn(Base& b) |
Задание
- Соберите иерархию
Shape → Circle, Square. У базового класса сделайтеvirtual double area(),virtual void draw()и виртуальный деструктор. Создайте массивShape*и вызовите методы для каждой фигуры. - Уберите ключевое слово
virtualиз методаarea()базового класса. Что изменится в выводе? Объясните почему. - Напишите функцию
printArea(Shape s)— с аргументом по значению. Вызовите её для объектаCircle. Какой метод вызовется? Почему так происходит? - Переделайте аргумент на
Shape& s— что изменится? - Почему виртуальный вызов в конструкторе вызывает метод базового класса, а не наследника?
Нарисуйте диаграмму vptr на каждом этапе конструирования объекта
Dog. - Найдите ошибку в этом коде и объясните, к какой категории она относится:
class Base { public: ~Base() {} virtual void run() { cout << "Base"; } }; class Derived : public Base { int* data; public: Derived() : data(new int[100]) {} ~Derived() { delete[] data; } }; int main() { Base* b = new Derived(); delete b; }