Course program← Программирование на языке C++
1Введение в язык C++
2Как выполняются программы на C++
3Структуры и классы
4Объектно-ориентированное программирование
5Дополнительные возможности C++
6Шаблоны
7Методические материалы

Конструкторы, деструкторы и RAII

This lesson is not translated yet – the Russian original is below.

Структура с публичными полями всегда может оказаться в неправильном состоянии: кто-то забыл что-то заполнить. Конструктор – способ сделать неправильное состояние невозможным: объект либо не создан, либо создан пригодным к работе.

Конструктор

Метод с именем класса и без возвращаемого типа. Вызывается при создании объекта.

struct Point {
    double x;
    double y;

    Point() : x(0), y(0) {}                 // по умолчанию
    Point(double x, double y) : x(x), y(y) {}  // с параметрами
};

Point a;            // 0, 0 – вызван первый
Point b(3, 4);      // 3, 4 – вызван второй
Point c{3, 4};      // то же самое, современный синтаксис

Классическая ловушка: Point b(); не создаёт объект. Компилятор читает это как объявление функции b без параметров, возвращающей Point, – «самый неприятный разбор» (most vexing parse). Фигурные скобки такой двусмысленности не создают, и это одна из причин предпочитать Point b{};.

Если не написать ни одного конструктора, компилятор сгенерирует конструктор по умолчанию сам. Но как только вы объявили хотя бы один свой – сгенерированного больше нет:

struct Point {
    Point(double x, double y) : x(x), y(y) {}
    double x, y;
};

Point p;    // ошибка: конструктора по умолчанию нет

Список инициализации

То, что стоит после двоеточия, – не украшение, а единственный способ инициализировать поля. Присваивание в теле – это уже перезапись созданного значения.

struct Widget {
    Widget(int value) : count_(value) {}    // инициализация
    // Widget(int value) { count_ = value; } // создание, затем присваивание

    int count_;
};

Для int разница косметическая, для тяжёлого поля – два действия вместо одного. А для трёх случаев список обязателен, потому что присвоить нельзя вообще:

struct Config {
    Config(int id, Logger& log)
        : id_(id)             // константное поле
        , log_(log)           // ссылка
        , conn_("localhost")  // поле без конструктора по умолчанию
    {}

    const int id_;
    Logger& log_;
    Connection conn_;
};

Поля инициализируются в порядке объявления в классе, а не в порядке списка. Пишите список в том же порядке, что и объявления, – тогда никогда не наступите на инициализацию поля значением соседа, которого ещё нет. Компилятор предупреждает об этом ключом -Wreorder.

Несколько конструкторов

Конструкторы перегружаются, как обычные функции:

struct String {
    String() : size_(0), str_(new char[1]{'\0'}) {}
    String(const char* str);
    String(size_t n, char c);      // заполняющий: n повторений символа

    size_t size_;
    char* str_;
};

String empty;
String hello("hello");
String spaces(10, ' ');

Чтобы не дублировать код, конструктор может делегировать работу другому:

String() : String("") {}   // делегирующий конструктор

Деструктор

Метод ~Имя(), без параметров и без возвращаемого значения. Вызывается при уничтожении объекта: для объекта на стеке – при выходе из блока, для объекта в куче – при delete.

struct String {
    String(const char* str) {
        size_ = std::strlen(str);
        str_ = new char[size_ + 1];
        std::strcpy(str_, str);
    }

    ~String() {
        delete[] str_;      // память, выделенную конструктором, возвращаем здесь
    }

    size_t size_;
    char* str_;
};

Деструктор у каждого класса ровно один: перегружать нечего, параметров нет.

void Process() {
    String s("hello");      // конструктор: выделил память
    if (Failed()) {
        return;             // деструктор вызовется здесь
    }
    // ...
}                           // и здесь тоже

Освобождение происходит на любом выходе из блока: обычном, через ранний return, через исключение. Забыть его невозможно – эту работу делает компилятор.

RAII

Отсюда и главная идиома C++: Resource Acquisition Is Initialization – ресурс захватывается при создании объекта и освобождается при его уничтожении.

Ресурс – это не только память:

Ресурс Захват в конструкторе Освобождение в деструкторе
Память new delete
Файл fopen, ifstream fclose, деструктор потока
Мьютекс lock() unlock()
Соединение connect() close()
{
    std::lock_guard<std::mutex> guard(mutex_);   // захватили
    // критическая секция
}                                                // отпустили, что бы ни случилось

Именно поэтому в C++ нет finally. В языках со сборщиком мусора момент вызова финализатора не гарантирован, поэтому там нужна отдельная конструкция – try/finally, using, defer, with. В C++ момент уничтожения объекта на стеке детерминирован, и деструктор закрывает всю задачу целиком.

Порядок

Порядок уничтожения строго обратен порядку создания – и для полей внутри объекта, и для объектов внутри блока.

{
    Logger log;      // 1
    Connection conn; // 2
    Request req;     // 3
}                    // ~Request, ~Connection, ~Logger

Это не деталь реализации, а гарантия, на которую можно опираться: если conn в конструкторе пишет в log, а в деструкторе пишет туда же, логгер к тому моменту ещё жив.

Что дальше

Класс String из примера уже управляет памятью, но пока сломан: скопируйте его – и два объекта станут владеть одним буфером, а деструкторы освободят его дважды. Чинить это будем через два урока, когда дойдём до конструктора копирования. Пока запомните симптом: как только в классе появился владеющий указатель, копирование по умолчанию перестаёт быть корректным.