Перейти к содержимому

Конструкторы, разрушители и правило 0/3/5

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

Жизненный цикл объекта C++

В C++ вы имеете прямой контроль над тем, когда и как объекты создаются и уничтожаются. Компилятор может автоматически генерировать до ** шести специальных функций-членов: конструктор по умолчанию, конструктор копий, оператор присваивания копий, конструктор перемещения, оператор назначения перемещения и деструктор. Понимание того, когда писать самостоятельно и когда позволять компилятору делать это, является одним из самых важных навыков в C++.

Разнообразие конструкторов

Конструкторы инициализируют объект. C++ поддерживает несколько видов: default (без аргументов), parameterized, converting (единый аргумент, неявное преобразование) и delegating (один конструктор называет другой).

constructors.cpp
#include <iostream>
#include <string>

class Temperature {
    double celsius_;
    std::string label_;

public:
    // Default constructor
    Temperature() : celsius_{0.0}, label_{"unknown"} {}

    // Parameterized constructor
    Temperature(double c, const std::string& label)
        : celsius_{c}, label_{label} {}

    // Converting constructor — allows implicit conversion from double
    Temperature(double c) : celsius_{c}, label_{"sensor"} {}

    // Delegating constructor — reuses another constructor
    Temperature(const std::string& label)
        : Temperature{0.0, label} {}  // delegates to parameterized

    void print() const {
        std::cout << label_ << ": " << celsius_ << "°C\n";
    }
};

int main() {
    Temperature t1;                   // default
    Temperature t2{36.6, "body"};     // parameterized
    Temperature t3 = 100.0;           // converting (implicit)
    Temperature t4{"outdoor"};        // delegating

    t1.print();  // unknown: 0°C
    t2.print();  // body: 36.6°C
    t3.print();  // sensor: 100°C
    t4.print();  // outdoor: 0°C
    return 0;
}

Явное ключевое слово

Конструкторы с одним аргументом обеспечивают неявные конверсии , которые могут вызывать тонкие ошибки. Используйте explicit для предотвращения непреднамеренных конверсий — это почти всегда то, что вы хотите.

explicit.cpp
#include <iostream>
#include <string>

class UserId {
    int id_;
public:
    explicit UserId(int id) : id_{id} {}
    int get() const { return id_; }
};

void process(UserId uid) {
    std::cout << "Processing user #" << uid.get() << '\n';
}

int main() {
    UserId u1{42};       // OK: direct initialization
    // UserId u2 = 42;   // ERROR: implicit conversion blocked by explicit
    // process(42);      // ERROR: would require implicit conversion
    process(UserId{42}); // OK: explicit construction
    return 0;
}

Список инициализаторов и порядок инициализации

Список инициализаторов является предпочтительным способом инициализации членов. Это необходимо для членов const, эталонных членов и членов без конструктора по умолчанию. Члены инициализируются в порядке декларирования**, а не в порядке, указанном в списке инициализаторов.

init_list.cpp
#include <iostream>
#include <string>

class Connection {
    const int max_retries_;      // const: MUST use init list
    std::string& log_target_;   // reference: MUST use init list
    std::string host_;
    int port_;

public:
    Connection(std::string& log, const std::string& host, int port)
        : max_retries_{3}          // const member
        , log_target_{log}         // reference member
        , host_{host}              // initialized in declaration order
        , port_{port}
    {
        // Constructor body runs AFTER all members are initialized
        log_target_ += "[Connection to " + host_ + " created]\n";
    }

    void print() const {
        std::cout << host_ << ':' << port_
                  << " (max retries: " << max_retries_ << ")\n";
    }
};

int main() {
    std::string log;
    Connection conn{log, "db.example.com", 5432};
    conn.print();
    std::cout << log;
    return 0;
}

Destructors & Rule of Zero / Три / Пять

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

Правило ноля: отдавайте предпочтение оберткам RAII (std::string, std::vector, std::unique_ptr), чтобы вам никогда не приходилось писать какие-либо специальные функции-члены. Если вы должны управлять необработанным ресурсом, в «Правиле трех» говорится, что вам нужен деструктор, конструктор копий и оператор присваивания копий. Правило пяти распространяется на конструктора ходов и оператора назначения ходов.

rule_of_five.cpp
#include <iostream>
#include <cstring>

// === Rule of Zero: the GOAL ===
// No special members needed — std::string handles everything
class Person {
    std::string name_;
    int age_;
public:
    Person(const std::string& name, int age)
        : name_{name}, age_{age} {}
    // Compiler generates correct copy/move/destructor automatically!
};

// === Rule of Five: when managing a raw resource ===
class CharBuffer {
    char* data_;
    std::size_t size_;

public:
    explicit CharBuffer(const char* str)
        : size_{std::strlen(str)}
        , data_{new char[std::strlen(str) + 1]}
    {
        std::strcpy(data_, str);
    }

    // Destructor
    ~CharBuffer() { delete[] data_; }

    // Copy constructor (deep copy)
    CharBuffer(const CharBuffer& other)
        : size_{other.size_}
        , data_{new char[other.size_ + 1]}
    {
        std::strcpy(data_, other.data_);
    }

    // Copy assignment (copy-and-swap idiom)
    CharBuffer& operator=(CharBuffer other) {  // note: by value
        swap(*this, other);
        return *this;
    }

    // Move constructor
    CharBuffer(CharBuffer&& other) noexcept
        : data_{other.data_}, size_{other.size_}
    {
        other.data_ = nullptr;
        other.size_ = 0;
    }

    // Move assignment
    CharBuffer& operator=(CharBuffer&& other) noexcept {
        if (this != &other) {
            delete[] data_;
            data_ = other.data_;
            size_ = other.size_;
            other.data_ = nullptr;
            other.size_ = 0;
        }
        return *this;
    }

    friend void swap(CharBuffer& a, CharBuffer& b) noexcept {
        using std::swap;
        swap(a.data_, b.data_);
        swap(a.size_, b.size_);
    }

    void print() const {
        std::cout << (data_ ? data_ : "(null)") << '\n';
    }
};

int main() {
    CharBuffer a{"Hello"};
    CharBuffer b = a;           // copy constructor
    CharBuffer c{"World"};
    c = a;                      // copy assignment
    CharBuffer d = std::move(a); // move constructor
    d.print();  // Hello
    a.print();  // (null) — moved-from state
    return 0;
}

=default и =delete

Вы можете запросить явно созданную компилятором версию специального члена с =default или явно подавить ее с =delete. Это делает ваше намерение ясным и документирует копирование / движущуюся семантику класса.

default_delete.cpp
#include <memory>
#include <string>

// Non-copyable, movable resource handle
class FileHandle {
    std::string path_;
    int fd_;

public:
    FileHandle(const std::string& path, int fd)
        : path_{path}, fd_{fd} {}

    // Explicitly default the destructor
    ~FileHandle() = default;

    // Delete copy operations — file handles should not be duplicated
    FileHandle(const FileHandle&) = delete;
    FileHandle& operator=(const FileHandle&) = delete;

    // Default move operations
    FileHandle(FileHandle&&) = default;
    FileHandle& operator=(FileHandle&&) = default;
};

int main() {
    FileHandle fh{"data.txt", 3};
    // FileHandle copy = fh;         // ERROR: copy deleted
    FileHandle moved = std::move(fh); // OK: move is allowed
    return 0;
}
Подводный камень

Члены инициализируются в том порядке, в котором они объявлены в классе, а не в том порядке, в котором они появляются в списке инициализаторов. Если один член зависит от другого, убедитесь, что зависимость объявлена первой. Компиляторы предупреждают об этом с помощью -Wreorder — никогда не игнорируйте это предупреждение. Классическая ошибка: инициализация size_ из параметра, но затем использование size_ для выделения data_, с data_ объявлено до size_.

Ключевые выводы
  • Использование explicit на конструкторах с одним аргументом для предотвращения непреднамеренных неявных конверсий
  • Список инициализаторов необходим для членов const и эталонных членов, и более эффективен для всех членов.
  • Члены инициализируются в порядке декларирования, а не в порядке списка инициализаторов
  • Цель для Правила ноля — используйте типы RAII, чтобы специальные члены, созданные компилятором, были правильными.
  • Если вы пишете какой-либо деструктор/копи-ктор/копи-назначение, напишите все пять (Правило пяти).
  • =default документирует намерение; =delete явно запрещает операцию

Тест — проверьте свои знания

(20 XP)

1. Что предотвращает ключевое слово `explicit` на конструкторе?

2. В каком порядке инициализируются члены класса?

3. Что такое правило нуля?