JavaRush /Курсы /C++ SELF /Виртуальный деструктор

Виртуальный деструктор

C++ SELF
51 уровень , 2 лекция
Открыта

1. Деструктор как часть контракта класса

Когда вы только начинаете, деструктор кажется чем-то скучным: «ну да, есть какая-то функция ~Class(), которая вызывается при уничтожении». Но в реальном C++ деструктор — это точка, где объект закрывает за собой двери: освобождает память, закрывает файл, отпускает блокировку, дописывает лог и т.д.

Если вы уже встречали RAII, то идея знакомая: ресурс живёт внутри объекта-владельца и освобождается в деструкторе.

Теперь представьте, что у вас есть иерархия, и вы храните объекты через базовый тип (через Base*, Base&, std::unique_ptr<Base>). Тогда вопрос «какой деструктор вызовется?» становится не философией, а вопросом «утечёт ли ресурс / сломается ли программа».

Модель delete: два шага

Чтобы понять проблему, держим простую и честную модель. Когда вы пишете delete p;, где p — указатель, происходят две большие вещи:

  1. Вызывается деструктор объекта.
  2. Затем освобождается память (грубо говоря, «возвращаем кусок кучи обратно»).

И ключевой вопрос лекции: как 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, что всё нормально», и делать выводы из одного запуска.
Даже если вы увидели в консоли «правильный» порядок сообщений, это не доказательство корректности, когда деструктор не виртуальный. В проблемном варианте поведение не обязано быть стабильным: сегодня «повезло», завтра — нет. В вопросах уничтожения полиморфных объектов лучше опираться не на удачу, а на правило языка: виртуальный деструктор в полиморфной базе.

1
Задача
C++ SELF, 51 уровень, 2 лекция
Недоступна
Зоопарк памяти
Зоопарк памяти
1
Задача
C++ SELF, 51 уровень, 2 лекция
Недоступна
Умный владелец
Умный владелец
1
Задача
C++ SELF, 51 уровень, 2 лекция
Недоступна
Починка удаления
Починка удаления
1
Задача
C++ SELF, 51 уровень, 2 лекция
Недоступна
Очередь задач
Очередь задач
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ