Деструктор в C++: что это, синтаксис и когда вызывается

Деструктор в C++: что это, синтаксис и когда вызывается Полезное

Деструктор — это специальный метод класса в C++, который автоматически выполняется в момент уничтожения объекта и служит для освобождения захваченных им ресурсов (памяти, файлов, соединений). У каждого класса он ровно один, его имя совпадает с именем класса и начинается с тильды: ~ClassName.

Ниже разберу, чем деструктор отличается от конструктора, как он записывается, в какие моменты вызывается, покажу минимальный рабочий пример с фактическим выводом и три частые ошибки. Язык примеров — C++, деструкторы относятся к его объектной модели (ООП).

Конструктор и деструктор: в чем разница

Конструктор и деструктор — парные методы, отвечающие за начало и конец жизни объекта. Конструктор готовит объект к работе (задает поля, захватывает ресурсы), деструктор наводит порядок перед его удалением. Их удобно держать рядом в голове по таблице.

Признак Конструктор Деструктор
Когда вызывается при создании объекта при уничтожении объекта
Имя ClassName ~ClassName
Аргументы могут быть нет
Перегрузка возможна, несколько вариантов невозможна, ровно один
Возвращаемый тип отсутствует отсутствует
Задача инициализация, захват ресурсов освобождение ресурсов

Деструктор нельзя перегрузить: у него нет параметров, разным по сигнатуре его сделать нечем. Вызывать его вручную почти никогда не нужно — компилятор расставляет вызовы сам.

Синтаксис деструктора

Деструктор объявляется внутри класса как метод с тильдой перед именем, без аргументов и без типа возврата:

class Buffer {
public:
    ~Buffer() {
        // код освобождения ресурсов
    }
};

Если деструктор не написать, компилятор сгенерирует неявный. Он корректно уничтожит поля-объекты (например, std::string или std::vector), но не тронет то, что вы захватили вручную через new, fopen или системные вызовы. Ручной ресурс — ручное освобождение в деструкторе.

Когда вызывается деструктор

Деструктор вызывается автоматически в конце жизни объекта. Основные моменты:

  • объект на стеке выходит из области видимости — на закрывающей скобке блока или функции;
  • объект в куче удаляется явно через delete (для массива — delete[]);
  • глобальный или статический объект уничтожается при завершении программы;
  • временный объект уничтожается в конце полного выражения, где он возник;
  • поле-объект или элемент контейнера уничтожается вместе со своим владельцем.

Оговорка про границу (упрощенно): при вызове std::exit() локальные автоматические объекты не разматываются и их деструкторы не выполняются, а std::abort() не вызывает деструкторов вообще. Нормальный выход из области видимости — деструктор есть, аварийное завершение — деструктора может не быть.

Минимальный рабочий пример

Этот код целиком компилируется и запускается (например, g++ -std=c++17 demo.cpp -o demo). Он показывает все три частых момента вызова деструктора: выход из вложенной области видимости, delete для объекта в куче и конец main.

#include <iostream>
#include <string>

class Logger {
public:
    Logger(const std::string& name) : name_(name) {   // конструктор
        std::cout << "Constructor: " << name_ << "\n";
    }
    ~Logger() {                                        // деструктор
        std::cout << "Destructor:  " << name_ << "\n";
    }
private:
    std::string name_;
};

int main() {
    Logger a("A");                 // на стеке, живет до конца main
    {
        Logger b("B");             // живет только внутри блока
    }                              // <- здесь уничтожается b
    Logger* c = new Logger("C");   // в куче
    delete c;                      // <- здесь уничтожается c
    return 0;
}                                  // <- здесь уничтожается a

Фактический вывод в консоли:

Constructor: A
Constructor: B
Destructor:  B
Constructor: C
Destructor:  C
Destructor:  A

Разбор по строкам. Объект a создается первым, но уничтожается последним — на закрывающей скобке main. Объект b живет только внутри вложенного блока и уничтожается на его закрывающей скобке. Объект c создан в куче через new и не удалится сам — его деструктор срабатывает ровно на delete c. В одной области видимости объекты уничтожаются в порядке, обратном созданию: a создан раньше всех и уходит последним.

Зачем нужен деструктор: освобождение ресурсов и RAII

Главная задача деструктора — гарантировать, что ресурс будет отпущен, даже если функция завершилась досрочно или выбросила исключение. На этом построена идиома RAII (Resource Acquisition Is Initialization): ресурс захватывается в конструкторе и привязан ко времени жизни объекта, а освобождается в деструкторе при его уничтожении.

Практический смысл: вы не пишете delete, fclose или unlock вручную в каждой ветке кода — за это отвечает деструктор, который вызовется автоматически. Так устроены стандартные типы: std::string и std::vector сами освобождают память, std::unique_ptr сам делает delete, std::lock_guard сам снимает мьютекс. Если поручить владение ресурсом такому типу, собственный деструктор писать не придется.

Три частые ошибки

1. Отсутствие виртуального деструктора у базового класса. Если удалять объект-наследник через указатель на базу, а деструктор базы не виртуальный — формально это неопределенное поведение, а на практике вызывается только деструктор базы, и ресурсы наследника не освобождаются.

struct Base    { ~Base()    { std::cout << "~Base\n"; } };
struct Derived : Base { ~Derived() { std::cout << "~Derived\n"; } };

Base* p = new Derived();
delete p;   // Вывод: ~Base     (~Derived НЕ вызван, ресурсы наследника утекли)

Исправление — объявить деструктор базы виртуальным. Полиморфизм — отдельная большая тема; здесь важен сам факт: базовый класс, который удаляют через указатель на базу, должен иметь virtual ~Base().

struct Base { virtual ~Base() { std::cout << "~Base\n"; } };
// Теперь delete p; даст: ~Derived, затем ~Base

2. Двойное освобождение при копировании класса с сырым указателем. Если класс владеет ресурсом через new, а копирующий конструктор не задан, копия по умолчанию скопирует сам указатель. Тогда два объекта будут указывать на один блок, и оба деструктора вызовут delete на одном адресе.

IntArray a(5);
IntArray b = a;   // копия по умолчанию: b.m_array указывает на тот же массив
// в конце оба деструктора делают delete[] на один адрес
// Вывод: free(): double free detected ... -> аварийное завершение

Исправление — либо задать копирующий конструктор и оператор присваивания (упрощенно это правило трех: если классу нужен явный деструктор, ему обычно нужны еще копирующий конструктор и присваивание; с C++11 добавляют перемещающие версии — правило пяти), либо не хранить сырой указатель, а взять std::vector или std::unique_ptr.

3. Исключение, выброшенное из деструктора. С C++11 деструкторы по умолчанию помечены noexcept, поэтому выброс исключения наружу приводит к вызову std::terminate и аварийному завершению программы.

~Connection() { throw std::runtime_error("fail"); }  // Вывод: terminate called ... -> abort

Исправление — не выпускать исключения из деструктора: обработать ошибку внутри (например, залогировать) и не пробрасывать ее дальше.

Выводы

  • Деструктор — метод класса с именем ~ClassName, без аргументов и типа возврата; на класс он ровно один и не перегружается.
  • Конструктор захватывает ресурсы при создании объекта, деструктор освобождает их при уничтожении — это парные методы.
  • Деструктор вызывается автоматически: при выходе объекта из области видимости, при delete для объекта в куче и при завершении программы для статических объектов.
  • В одной области видимости объекты уничтожаются в порядке, обратном созданию.
  • На автоматическом вызове деструктора построена идиома RAII: захват в конструкторе, освобождение в деструкторе, никакой ручной очистки в каждой ветке кода.
  • Базовому классу с наследниками, который удаляют через указатель на базу, нужен виртуальный деструктор.

Где применяется / связь с практикой

Деструкторы и RAII — каждодневная практика C++: работа с памятью, файлами, соединениями и блокировками почти всегда завязана на время жизни объекта. Понимание того, когда вызывается деструктор, отделяет код с утечками и падениями от кода, который сам за собой убирает.

Освойте тему на практике

Разобрать это на практике, с проверкой на компиляторе и код-ревью, удобнее системно. На курсе C++ Basic деструкторы, конструкторы и управление ресурсами разбирают на живых задачах. А посмотреть формат занятий и задать вопрос преподавателю можно на бесплатных вебинарах Otus.

Смежные темы: Концепция ООП-разработки, Программирование на C++: что должен знать начинающий разработчик, Все о наследовании в Си-семействе.

FAQ

Можно ли вызвать деструктор вручную через obj.~ClassName()?
Технически можно, но почти всегда это ошибка: после ручного вызова автоматический деструктор сработает еще раз и даст двойное освобождение. Явный вызов оправдан только в связке с placement new, когда вы сами управляете памятью.

В каком порядке вызываются деструкторы при наследовании?
Сначала выполняется деструктор наследника, затем деструктор базового класса — обратно порядку вызова конструкторов (база строится первой, разрушается последней).

Нужен ли деструктор, если в классе только std::string, std::vector и умные указатели?
Нет. Эти типы освобождают свои ресурсы сами в собственных деструкторах, а компилятор вызовет их за вас. Класс без ручных ресурсов обходится неявным деструктором — это называют правилом нуля.

OTUS Журнал
Скидка 5% 12-19 октября на курсы (popup)
Бесплатные открытые уроки (поп-ап)