This lesson is not translated yet – the Russian original is below.
Класс 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 освободил его повторно
Симптомы: двойное освобождение, обращение к освобождённой памяти через второй объект, изменения в одной «копии», видимые в другой. Проявляются далеко от места ошибки.
Копирование при этом происходит не только там, где написано =:
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
}
Четыре обязательных пункта:
- Проверка на самоприсваивание. Без неё
s = sсначала освободит буфер, а потом попытается из него скопировать. - Освобождение старого ресурса. Забыли – утечка.
- Глубокое копирование. Скопировали указатель – вернулись к исходной проблеме.
- Возврат
*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. Число вызовов зависит от компилятора, уровня оптимизации и версии стандарта. Конструктор копирования обязан копировать, и больше ничего.