1. Деструктор как часть контракта класса
Когда вы только начинаете, деструктор кажется чем-то скучным: «ну да, есть какая-то функция ~Class(), которая вызывается при уничтожении». Но в реальном C++ деструктор — это точка, где объект закрывает за собой двери: освобождает память, закрывает файл, отпускает блокировку, дописывает лог и т.д.
Если вы уже встречали RAII, то идея знакомая: ресурс живёт внутри объекта-владельца и освобождается в деструкторе.
Теперь представьте, что у вас есть иерархия, и вы храните объекты через базовый тип (через Base*, Base&, std::unique_ptr<Base>). Тогда вопрос «какой деструктор вызовется?» становится не философией, а вопросом «утечёт ли ресурс / сломается ли программа».
Модель delete: два шага
Чтобы понять проблему, держим простую и честную модель. Когда вы пишете delete p;, где p — указатель, происходят две большие вещи:
- Вызывается деструктор объекта.
- Затем освобождается память (грубо говоря, «возвращаем кусок кучи обратно»).
И ключевой вопрос лекции: как C++ выбирает, какой деструктор вызвать? По какому типу?
Тут удобно держать в голове два типа:
| Термин | Что означает |
|---|---|
| Статический тип | Тип переменной в коде. Например, у Base* p статический тип — Base*. |
| Реальный тип объекта | Какой объект реально лежит в памяти: Derived, Cat, Circle и т.д. |
И вот здесь внезапно выясняется, что без специальной настройки деструктор при delete через базовый указатель может быть выбран не по реальному типу.
2. Пример: компилируется, но опасно
Сделаем маленький пример «как люди случайно ломают программу». Пускай у нас есть базовый тип Shape и наследник Circle. Мы хотим удалять фигуры через Shape*.
#include <iostream>
struct Shape {
virtual double area() const { return 0.0; } // полиморфизм есть
~Shape() { std::cout << "~Shape\n"; } // ОШИБКА: деструктор НЕ virtual
};
struct Circle : Shape {
~Circle() { std::cout << "~Circle\n"; }
};
int main() {
Shape* p = new Circle{};
delete p; // некорректно: удаляем наследника через базу без virtual ~Shape()
}
Кажется, что «ну что такого, сейчас удалим». Но по правилам языка это некорректное использование: поведение не обязано быть правильным. На практике вы можете увидеть только ~Shape, можете не увидеть ~Circle, можете словить утечку ресурсов, а можете получить совсем неожиданные спецэффекты.
То есть это не «баг в логике», а ситуация, где программа перестаёт быть определённой.
4. Почему так: деструктор тоже должен быть virtual
Ключевая мысль: деструктор — это тоже метод (особенный, без возвращаемого типа).
Если вы удаляете объект через Base*, компилятор по умолчанию видит только «указатель на Base». Если деструктор не виртуальный, то вызывается деструктор Base, потому что так устроено обычное (невиртуальное) разрешение вызова — по статическому типу.
Если же деструктор виртуальный, то включается тот же механизм, что и с virtual-методами: выбор происходит по реальному типу объекта, и цепочка разрушения будет корректной: сначала ~Derived(), потом ~Base().
Можно запомнить так: виртуальный деструктор — это «табличка на двери»: «если тебя будут закрывать через общий вход — позови правильного уборщика».
5. Правило: виртуальный деструктор в полиморфной базе
Правило в максимально практичном виде:
Если класс используется как полиморфная база (то есть вы ожидаете работать с ним через Base&, Base*, std::unique_ptr<Base> и т.п.), то в нём должен быть:
virtual ~Base() = default;
Почему = default? Потому что чаще всего базовому деструктору не нужно писать тело руками: нам важна виртуальность и правильная семантика, а не ручные std::cout или «закрыть файл» (это обычно делают поля-объекты по RAII).
Минимальный «правильный» вариант:
struct Shape {
virtual double area() const { return 0.0; }
virtual ~Shape() = default; // ключевое правило
};
6. Было плохо — стало хорошо
Перепишем предыдущий опасный пример в корректный.
#include <iostream>
struct Shape {
virtual double area() const { return 0.0; }
virtual ~Shape() { std::cout << "~Shape\n"; } // теперь virtual
};
struct Circle : Shape {
~Circle() override { std::cout << "~Circle\n"; }
};
int main() {
Shape* p = new Circle{};
delete p; // теперь корректно
// ~Circle
// ~Shape
}
Обратите внимание на override у деструктора наследника. Это не обязательный «магический оберег», но это очень полезная дисциплина: компилятор проверит, что вы действительно переопределили виртуальный деструктор базового класса, а не написали что-то «почти такое же».
7. std::unique_ptr<Base> тоже удаляет через базу
Частый сценарий: «Ну я же не пишу delete, я современный человек, у меня unique_ptr». И это прекрасно… но правило виртуального деструктора всё равно остаётся.
Почему? Потому что std::unique_ptr<Base> при уничтожении делает примерно следующее: delete base_ptr;. То есть удаление всё равно происходит через базовый тип.
Микро-пример:
#include <iostream>
#include <memory>
struct Shape {
virtual double area() const { return 0.0; }
virtual ~Shape() = default; // обязательно
};
struct Circle : Shape {
~Circle() override { std::cout << "~Circle\n"; }
};
int main() {
std::unique_ptr<Shape> p = std::make_unique<Circle>();
} // ~Circle (потом ~Shape, но он default и ничего не печатает)
Если бы ~Shape() не был виртуальным, то unique_ptr не «спас» бы ситуацию: он спасает вас от ручного delete, но не отменяет требования корректного полиморфного уничтожения.
8. Пример с коллекцией: мини-реестр фигур
Чтобы примеры были не «в вакууме», будем считать, что мы делаем простое консольное приложение, которое хранит фигуры и печатает их площади. Одна коллекция — разные реализации поведения.
Сделаем базовый класс Shape. Ключевой момент: виртуальный деструктор в базе.
#include <string>
struct Shape {
virtual std::string name() const { return "Shape"; }
virtual double area() const { return 0.0; }
virtual ~Shape() = default; // правило дня
};
Наследники:
#include <string>
struct Rectangle : Shape {
double w{};
double h{};
Rectangle(double width, double height) : w(width), h(height) {}
std::string name() const override { return "Rectangle"; }
double area() const override { return w * h; }
};
struct Circle : Shape {
double r{};
explicit Circle(double radius) : r(radius) {}
std::string name() const override { return "Circle"; }
double area() const override { return 3.14159 * r * r; }
};
И маленький «реестр» на std::vector<std::unique_ptr<Shape>>.
#include <iostream>
#include <memory>
#include <vector>
void print_shape(const Shape& s) {
std::cout << s.name() << ": area=" << s.area() << '\n';
}
int main() {
std::vector<std::unique_ptr<Shape>> shapes;
shapes.push_back(std::make_unique<Rectangle>(3.0, 4.0));
shapes.push_back(std::make_unique<Circle>(2.0));
for (const auto& p : shapes) {
print_shape(*p);
}
}
Здесь важно: когда shapes разрушится в конце main(), каждый unique_ptr<Shape> удалит свой объект «через базу». И именно поэтому virtual ~Shape() — не украшение, а условие корректности.
9. Полезные нюансы
Схема: какой деструктор вызовется
Иногда проще один раз увидеть как диаграмму, чем перечитывать объяснение. Вот блок-схема выбора:
flowchart TD
A[Есть указатель Base* p] --> B{Удаляем через delete p?}
B --> C{~Base виртуальный?}
C -- нет --> D[Вызывается ~Base по статическому типу]
D --> E[Derived-часть может не разрушиться корректно]
C -- да --> F[Вызывается ~Derived по реальному типу]
F --> G[Затем автоматически вызывается ~Base]
Верхняя ветка (не virtual) выглядит «нормально» только до того момента, пока у наследника нет ресурсов. А ресурсы у наследника появляются очень быстро: динамическая память, файловый дескриптор, сетевой сокет, логгер и т.д. Поэтому правило сформулировано жёстко.
Стиль: ~Derived() override = default;
Новички часто думают, что override нужен только «обычным» методам, а деструктор как будто «сам по себе». Но если у вас есть цепочка наследования, то писать override на деструкторе наследника — хороший способ заставить компилятор быть вашим строгим наставником.
Если наследнику не нужно ничего делать руками в деструкторе, но вы хотите оставить ясное намерение, это выглядит так:
struct Shape {
virtual ~Shape() = default;
};
struct Circle : Shape {
~Circle() override = default; // намерение явно
};
Это особенно приятно в учебном и командном коде: читающий сразу видит, что класс участвует в полиморфном уничтожении, а не «случайно наследуется».
Если вы не хотите полиморфного удаления
Иногда (редко, но бывает) вы делаете базовый класс, от которого наследуются, но вы не хотите, чтобы кто-то делал delete basePtr;. Например, потому что объекты должны жить только на стеке, или потому что их жизненным циклом управляет внешний менеджер.
Сегодня мы не строим сложные политики владения и запретов, но мысль стоит зафиксировать: раз вы допускаете хранение/владение через базовый тип (особенно через умные указатели), вы автоматически должны обеспечить корректное уничтожение. Если вы этого не хотите — дизайн должен быть другим и явно это выражать.
В рамках этого дня держимся практического курса: используем полиморфно → ставим виртуальный деструктор.
10. Типичные ошибки
Ошибка №1: в базовом классе есть virtual-методы, но деструктор забыли сделать виртуальным.
Это самая частая и самая опасная комбинация: класс выглядит полиморфным, его начинают хранить через Base* или std::unique_ptr<Base>, а потом при уничтожении часть объекта «не доразрушается». Привычная профилактика — писать virtual ~Base() = default; сразу, как только в базе появился первый virtual-метод.
Ошибка №2: надежда, что «умный указатель сам разберётся».
std::unique_ptr действительно спасает от ручного delete и утечек при ранних выходах, но он не меняет правила языка: если unique_ptr<Base> уничтожает объект, он делает это через базовый указатель. Поэтому виртуальный деструктор — обязанность дизайна базового класса, а не обязанность владельца.
Ошибка №3: попытка «починить» ситуацию только в наследнике.
Иногда пытаются рассуждать так: «ну я же сделал ~Derived()», и кажется, что всё хорошо. Но проблема не в том, что у наследника нет деструктора, а в том, что при удалении через Base* до наследника могут просто не дойти. Исправление всегда начинается с базы: virtual ~Base().
Ошибка №4: путаница с override из‑за несовпадения сигнатуры.
Если вы пишете ~Derived() override, а компилятор ругается — это подарок, а не наказание. Значит, у базового деструктора нет виртуальности или что-то в объявлении не так. override как раз и нужен, чтобы ловить такие ситуации сразу, а не в продакшене.
Ошибка №5: «проверять по cout, что всё нормально», и делать выводы из одного запуска.
Даже если вы увидели в консоли «правильный» порядок сообщений, это не доказательство корректности, когда деструктор не виртуальный. В проблемном варианте поведение не обязано быть стабильным: сегодня «повезло», завтра — нет. В вопросах уничтожения полиморфных объектов лучше опираться не на удачу, а на правило языка: виртуальный деструктор в полиморфной базе.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ