Введение
Статья 524 кратко упомянула, что new/delete автоматически вызывают конструктор/деструктор — в этой статье разберём конструкторы и деструкторы систематически: их виды (по умолчанию, с параметрами, копирования), точный порядок вызова для составных объектов, и введём концепцию RAII, систематически разбираемую далее в статье 552.
Концепция
Конструктор — специальный метод класса, вызываемый автоматически при создании объекта, отвечающий за инициализацию его состояния — класс может иметь несколько конструкторов с разными сигнатурами параметров (перегрузка конструкторов), включая конструктор без параметров (конструктор по умолчанию) и конструктор копирования (статья 553), принимающий ссылку на объект того же класса для создания его копии. Деструктор — единственный специальный метод (не может быть перегружен, поскольку не принимает параметров), вызываемый автоматически при уничтожении объекта (выход из области видимости для стекового объекта, статья 525, или явный delete для объекта в куче), отвечающий за освобождение любых ресурсов, приобретённых объектом за время его существования. Для составных объектов (содержащих другие объекты как поля, или являющихся производными от базового класса, статья 533) конструкторы вызываются в строго определённом порядке (базовый класс, затем поля в порядке их объявления, затем тело самого конструктора), а деструкторы — в строго обратном порядке.
Пример кода: Конструкторы и деструкторы
#include <iostream>
class Logger {
public:
Logger(const std::string &name) : m_name(name)
{
std::cout << "Конструктор Logger(" << m_name << ")" << std::endl;
}
~Logger()
{
std::cout << "Деструктор Logger(" << m_name << ")" << std::endl;
}
private:
std::string m_name;
};
class Application {
public:
Application() : m_databaseLogger("DB"), m_networkLogger("Network")
// ^ список инициализации — порядок ВЫЗОВА конструкторов соответствует ПОРЯДКУ ОБЪЯВЛЕНИЯ
// полей в классе, а НЕ порядку их перечисления в этом списке инициализации!
{
std::cout << "Конструктор Application" << std::endl;
}
~Application()
{
std::cout << "Деструктор Application" << std::endl;
}
private:
Logger m_databaseLogger; // объявлено ПЕРВЫМ — конструктор вызовется первым
Logger m_networkLogger; // объявлено ВТОРЫМ — конструктор вызовется вторым
};
int main()
{
Application app;
return 0;
}
// Вывод покажет: Logger(DB) -> Logger(Network) -> Application -> ~Application -> ~Logger(Network) -> ~Logger(DB)
// Деструкторы вызываются в СТРОГО ОБРАТНОМ порядке относительно конструкторов
// RAII — управление ресурсом через конструктор/деструктор (подробнее в статье 552)
class FileHandle {
public:
FileHandle(const std::string &path)
{
m_file = fopen(path.c_str(), "r");
std::cout << "Файл открыт" << std::endl;
}
~FileHandle()
{
if (m_file) {
fclose(m_file); // ГАРАНТИРОВАННОЕ закрытие файла при уничтожении объекта,
std::cout << "Файл закрыт" << std::endl; // ДАЖЕ если функция завершится через исключение!
}
}
private:
FILE *m_file;
};
Пояснения к коду
Список инициализации Application() с явным комментарием показывает важную, неочевидную тонкость — порядок вызова конструкторов полей определяется ПОРЯДКОМ ОБЪЯВЛЕНИЯ полей в самом классе (m_databaseLogger перед m_networkLogger), а не порядком их перечисления в списке инициализации конструктора, и компиляторы обычно выдают предупреждение, если эти два порядка не совпадают, поскольку рассогласование может ввести в заблуждение читателя кода, ожидающего, что порядок в списке инициализации определяет реальный порядок вызова. Вывод программы, показывающий обратный порядок вызова деструкторов относительно конструкторов, демонстрирует фундаментальный принцип, тесно связанный со стековой природой управления временем жизни объектов (статья 525) — последний созданный объект уничтожается первым, что особенно важно для корректного освобождения взаимозависимых ресурсов. FileHandle показывает суть RAII — связывание приобретения ресурса (открытие файла) с конструктором, и освобождения (закрытие файла) с деструктором, гарантирует, что ресурс будет корректно освобождён при уничтожении объекта независимо от того, как именно это уничтожение происходит (обычный выход из области видимости или, что особенно ценно, раскрутка стека при исключении, статья 540).
Подводные камни
- Несовпадение порядка инициализации полей в списке инициализации конструктора с порядком их объявления в классе — как объяснено в пояснении, реальный порядок вызова определяется порядком ОБЪЯВЛЕНИЯ полей, а не порядком их перечисления в списке инициализации, и эта несогласованность (хотя обычно отмечаемая предупреждением компилятора) может привести к серьёзной, трудно диагностируемой ошибке, если один из полей инициализируется с использованием значения другого поля, которое, как ошибочно предполагает разработчик, уже было инициализировано к этому моменту, хотя реальный порядок вызова конструкторов диктует иное.
- Объявление деструктора без
virtualдля класса, предназначенного для использования как базовый класс в иерархии наследования (статья 533) с полиморфным удалением через указатель на базовый класс — безvirtualдеструктора удаление производного объекта через указатель на базовый класс вызовет только деструктор базового класса, оставляя ресурсы, приобретённые производным классом, неосвобождёнными, что является источником утечек ресурсов и неопределённого поведения, систематически разбираемым в статье 534 про виртуальные функции. - Выбрасывание исключения из деструктора — это крайне опасная, по умолчанию практически запрещённая (начиная с C++11 деструкторы неявно
noexcept, если явно не указано иное) практика, поскольку если деструктор вызывается в процессе раскрутки стека из-за уже происходящего исключения (статья 540), а сам деструктор тоже выбрасывает новое исключение, программа немедленно и безусловно завершается черезstd::terminate()без какой-либо возможности корректной обработки этой ситуации — деструкторы должны быть спроектированы так, чтобы никогда не выбрасывать исключения, даже если внутренняя операция освобождения ресурса теоретически могла бы завершиться с ошибкой. - Создание объекта, инициализация которого (в конструкторе) частично завершилась с ошибкой, без понимания, что если конструктор выбрасывает исключение, деструктор для ЭТОГО конкретного, не до конца сконструированного объекта НЕ будет вызван вовсе (поскольку формально объект считается так и не созданным успешно) — но деструкторы уже полностью сконструированных к моменту возникновения исключения полей (и базовых классов) ВСЁ ЖЕ будут вызваны корректно, что требует внимательного проектирования конструктора, чтобы частично приобретённые ресурсы (до точки, где выбрасывается исключение) были корректно освобождены, обычно через тот же принцип RAII, применённый к каждому отдельному ресурсу как отдельному полю-объекту, а не вручную, императивно внутри самого тела конструктора.