Привет, Хабр!
Сегодня я хочу поговорить о двух правилах С++: правиле трех и правиле пяти.
Правильное понимание этих правил способно уберечь код от утечек и неопределенных поведений.
Правило трех
Скотт Мейерс, один из гуру C++ и автор шикарных книг, впервые сформулировал правило трех в своих книгах "Effective C++".
Правило трех которая гласит, что если классу нужен один из следующих трех методов, то, скорее всего, ему понадобятся и два других:
1. Деструктор — это специальный метод класса, который автоматически вызывается при уничтожении объекта. Деструктор — это тот персонаж боевичков, который убирает все следы операции перед уходом. Он гарантирует, что все выделенные ресурсы будут корректно освобождены, когда объект больше не нужен.
2. Конструктор копирования позволяет создавать новые объекты как копии существующих Т.е когда вы копируете объект, каждый бит информации из оригинала переносится в новый объект. Без явно определенного конструктора копирования, C++ предоставит стандартный, который скопирует все поля вашего объекта. Это не очень, если объект управляет внешним ресурсом, например, выделяет память. Почему? Потому что теперь два объекта будут думать, что они владеют одним и тем же ресурсом, и попытаются его освободить при уничтожении.
3. Оператор присваивания копированием позволяет одному уже существующему объекту принять состояние другого существующего объекта. Если этот оператор не будет определен явно, C++ сгенерирует его за вас, но, как и в случае с конструктором копирования, это может привести к проблемам с управлением ресурсами.
Итак: правило трех возникло не на пустом месте из-за некоторых требований управления памятью и ресурсами в C++, где неаккуратное обращение с динамически выделенной памятью, файловыми дескрипторами или сетевыми соединениями может легко привести к утечкам ресурсов или вообще крашу программы.
Рассмотрим пример класса, который не следует правилу трех:
class BrokenResource {
public:
char* data;
BrokenResource(const char* input) {
data = new char[strlen(input) + 1];
strcpy(data, input);
}
~BrokenResource() {
delete[] data;
}
// конструктор копирования и оператор присваивания не определены!
};
При копировании объекта BrokenResource
компилятором будут сгенерированы конструктор копирования и оператор присваивания по умолчанию, которые просто копируют указатель data
, ведущий к потенциальному двойному освобождению памяти.
Исправим предыдущий пример, явно реализовав правило трех:
class FixedResource {
public:
char* data;
FixedResource(const char* input) {
data = new char[strlen(input) + 1];
strcpy(data, input);
}
~FixedResource() {
delete[] data;
}
// конструктор копирования
FixedResource(const FixedResource& other) {
data = new char[strlen(other.data) + 1];
strcpy(data, other.data);
}
// оператор присваивания
FixedResource& operator=(const FixedResource& other) {
if (this != &other) { // предотвращение самоприсваивания
delete[] data; // освобождаем существующий ресурс
data = new char[strlen(other.data) + 1];
strcpy(data, other.data);
}
return *this;
}
};
Добавили конструктор копирования и оператор присваивания, которые гарантируют, что каждый объект имеет свою собственную копию данных.
Правило пяти
Правило пяти в C++ — это эволюция предшествующего правила трех, адаптированное для учета нововведений C++11, таких как семантика перемещения. Это правило гласит, что если явно определяется один из следующих пяти специальных методов класса, вам, скорее всего, нужно явно определить и остальные четыре:
Деструктор.
Конструктор копирования.
Оператор присваивания копированием.
Конструктор перемещения.
Оператор присваивания перемещением.
Правило пяти также и правило пяти, решает проблемы эффективности и безопасности при работе с ресурсами. Копирование ресурсов может быть ресурсоемким с точки зрения производительности, а перемещение позволяет избежать ненужных операций копирования, передавая владение ресурсами непосредственно новому объекту.
Рассмотрим анти-пример:
class ResourceHolder {
public:
int* data;
ResourceHolder(int value) : data(new int(value)) {}
~ResourceHolder() { delete data; }
// Правило пяти не соблюдено: отсутствуют конструктор копирования,
// оператор присваивания копированием, конструктор перемещения и
// оператор присваивания перемещением.
};
Этот класс управляет динамически выделенной памятью, но определяет только конструктор и деструктор. Без явного определения операций копирования и перемещения, компилятор сгенерирует их автоматически, что приводит часто к двойному освобождению
Соблюдение:
class ProperResource {
public:
int* data;
ProperResource(int value) : data(new int(value)) {}
~ProperResource() { delete data; }
// конструктор копирования
ProperResource(const ProperResource& other) : data(new int(*other.data)) {}
// оператор присваивания копированием
ProperResource& operator=(const ProperResource& other) {
if (this != &other) {
*data = *other.data;
}
return *this;
}
// конструктор перемещения
ProperResource(ProperResource&& other) noexcept : data(other.data) {
other.data = nullptr;
}
// оператор присваивания перемещением
ProperResource& operator=(ProperResource&& other) noexcept {
if (this != &other) {
delete data;
data = other.data;
other.data = nullptr;
}
return *this;
}
};
В ProperResource
явно определены все пять специальных методов, управляющих копированием и перемещением ресурсов.
Умные указатели
Нельзя было не сказать о них в контексте этой статьи.
Умные указатели автоматически управляют памятью, делая код более чище.
C++ предлагает несколько типов умных указателей, но обратим внимание на два основных: std::unique_ptr
и std::shared_ptr
.
std::unique_ptr
обеспечивает эксклюзивное владение ресурсом. Это означает не может быть двух std::unique_ptr
, указывающих на один и тот же объект. При его уничтожении уничтожается и ресурс.
#include <memory>
void useUniquePtr() {
std::unique_ptr<int> ptr(new int(10)); // инициализация с новым int
// нет необходимости вызывать delete, уничтожение ptr автоматически освободит память
}
std::shared_ptr
поддерживает совместное владение ресурсом через подсчет ссылок. Ресурс будет освобожден только тогда, когда последний std::shared_ptr
, владеющий этим ресурсом, будет уничтожен или сброшен.
#include <memory>
void useSharedPtr() {
std::shared_ptr<int> sharedPtr1 = std::make_shared<int>(20);
std::shared_ptr<int> sharedPtr2 = sharedPtr1; // оба указателя сейчас владеют ресурсом.
// ресурс будет автоматически освобожден после уничтожения последнего shared_ptr.
}
Одна из наиболее распространенных ошибок – это полное игнорирование необходимости реализации этих методов в классах, управляющих ресурсами. Это может привести к утечкам памяти, двойному освобождению ресурсов и другим неприятностям. Даже при реализации этих методов легко совершить ошибку, не корректно скопировав или переместив ресурсы, что приведет к аналогичным проблемам.
В современном C++ все используют умные указатели и прочие методы, которые самостоятельно управляют своими ресурсами, класс может вообще не требовать явной реализации этих пяти методов. Это известно как правило нуля.
Но об этих правилах тоже нельзя забывать, они внесли свой вклад в развитие ЯП C++.
В языке C++ есть множество вариантов решения задачи, которые часто будут отличаться различными свойствами по производительности и гибкости. К одной из таких возможностей можно отнести семантики копирования и перемещения. О том как они отличаются синтаксически и какие возможности по оптимизациям нам это открывает эксперты OTUS расскажут на бесплатном вебинаре. Регистрация на вебинар доступна по ссылке.