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

Копирование, присваивание и правило трёх

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

Что компилятор генерирует сам

Если ничего не написать, компилятор добавит классу четыре метода (в C++11 – шесть): конструктор по умолчанию, конструктор копирования, оператор присваивания и деструктор.

Копирующие версии работают почленно: каждое поле копируется своим конструктором копирования, а встроенные типы – побитово.

struct Point {
    double x, y;
};

Point a{1, 2};
Point b = a;      // сгенерированный конструктор копирования: скопировал два double

Для Point это ровно то, что нужно. Для String – катастрофа: поле char* str_ тоже «встроенный тип», и копируется адрес.

Поверхностная копия

class String {
public:
    String(const char* str);
    ~String() { delete[] str_; }

private:
    size_t size_;
    char* str_;
};

{
    String a("hello");
    String b = a;      // str_ у обоих один и тот же адрес
}                      // ~b освободил буфер, ~a освободил его повторно
a size_ = 5, str_ = • b size_ = 5, str_ = • "hello\0" один буфер в куче

Симптомы: двойное освобождение, обращение к освобождённой памяти через второй объект, изменения в одной «копии», видимые в другой. Проявляются далеко от места ошибки.

Копирование при этом происходит не только там, где написано =:

void Consume(String s);      // передача по значению – копия
String Make();               // возврат по значению – возможна копия
String c(a);                 // явный вызов
String d = a;                // инициализация копированием

Конструктор копирования

Создаёт новый объект как копию существующего:

String::String(const String& other)
    : size_(other.size_)
    , str_(new char[other.size_ + 1])
{
    std::strcpy(str_, other.str_);
}

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

Оператор присваивания

Отличие от конструктора копирования принципиальное: объект уже существует и, скорее всего, уже чем-то владеет. Значит, старое нужно освободить.

String& String::operator=(const String& other) {
    if (this != &other) {            // защита от самоприсваивания
        delete[] str_;               // освободили старое
        size_ = other.size_;
        str_ = new char[size_ + 1];  // выделили новое
        std::strcpy(str_, other.str_);
    }

    return *this;                    // чтобы работало a = b = c
}

Четыре обязательных пункта:

  1. Проверка на самоприсваивание. Без неё s = s сначала освободит буфер, а потом попытается из него скопировать.
  2. Освобождение старого ресурса. Забыли – утечка.
  3. Глубокое копирование. Скопировали указатель – вернулись к исходной проблеме.
  4. Возврат *this по ссылке. Так ведут себя встроенные типы, и цепочки присваиваний должны работать.

Есть приём, закрывающий все четыре пункта разом, – copy and swap: принять параметр по значению (копия создастся конструктором копирования) и обменять с ним внутренности. Самоприсваивание перестаёт быть особым случаем, а при исключении в середине объект остаётся нетронутым:
String& operator=(String other) { std::swap(size_, other.size_); std::swap(str_, other.str_); return *this; }

Правило трёх

Все три метода – деструктор, конструктор копирования, оператор присваивания – нужны по одной и той же причине: класс владеет ресурсом. Отсюда правило:

Если классу понадобился один из трёх, ему почти наверняка нужны все три.

Обратное тоже верно и полезнее: если класс не владеет ресурсом, не пишите ни одного из них. Сгенерированные версии корректны, оптимальны и не устаревают при добавлении полей.

В C++11 к тройке добавились перемещающие конструктор и присваивание – получилось «правило пяти». И третий вариант, самый практичный: правило нуля – не управляйте ресурсами в прикладных классах вообще, поручите это std::string, std::vector, std::unique_ptr, и тогда все пять методов писать не придётся.

Запретить копирование

Иногда копия не имеет смысла: объект представляет уникальный ресурс – файл, соединение, владеющий указатель. Тогда копирование явно удаляют:

class Connection {
public:
    Connection(const Connection&) = delete;
    Connection& operator=(const Connection&) = delete;
};

Попытка скопировать станет ошибкой компиляции с внятным сообщением. В коде до C++11 того же добивались объявлением этих методов в private без определения – работало, но ошибка вылезала только на этапе компоновки.

Когда копия не создаётся

Стандарт разрешает компилятору выбрасывать вызовы конструктора копирования, даже если у того есть побочные эффекты, – это copy elision.

String Spaces(size_t n) {
    String s(n, ' ');
    return s;               // копии, скорее всего, не будет: объект построен на месте
}

String result = Spaces(10); // и здесь тоже

Начиная с C++17 в ряде случаев отказ от копии не право компилятора, а требование стандарта. Поэтому возврат по значению из функции – нормальный и дешёвый способ отдавать объекты, а «оптимизация» через выходной параметр-ссылку обычно только портит сигнатуру.

Не стройте логику на побочных эффектах конструктора копирования – например, на подсчёте копий через вывод в cout. Число вызовов зависит от компилятора, уровня оптимизации и версии стандарта. Конструктор копирования обязан копировать, и больше ничего.