Pull to refresh

«Правило ноля»

Reading time 5 min
Views 46K
Применительно к с++03 существует “правило трех”, с появлением с++11 оно трансформировалось в “правило 5ти”. И хотя эти правила по сути являются не более чем неформальными рекомендациями к проектированию собственных типов данных, но тем не менее часто бывают полезны. “Правило ноля” продолжает ряд этих рекомендаций. В этом посте я напомню о чем, собственно, первые 2 правила, а также попробую объяснить идею, стоящую за “правилом ноля”.

Мотивация


Все упомянутые выше правила написаны в основном (но не всегда) для ситуаций, когда объект нашего класса владеет каким-либо ресурсом (хендлером, указателем на ресурс) и нужно каким-то образом решить что будет происходить с этим хендлером и с самим ресурсом при копировании/перемещении нашего объекта.
По умолчанию, если мы не объявляем ни одну из “специальных” функций (конструктор копирования, оператор присваивания, деструктор и т.п.), компилятор сгенерирует их код автоматически. При этом они будут вести себя в общем-то ожидаемо. Например, конструктор копирования будет пытаться скопировать не POD члены класса вызывая их соответствующие конструкторы копирования и побитово копировать члены POD типов. Такое поведение вполне приемлемо для простых классов, содержащих всех своих членов в себе самих.

Стратегии владения


В случае же больших сложных классов, или классов, в качестве члена которого выступает хендлер внешнего ресурса поведение, реализуемое компилятором по умолчанию, нас уже может не устроить. К счастью мы можем самостоятельно определить специальные функции, реализовав нужную в данной ситуации стратегию владения ресурсом. Условно можно выделить несколько основных таких стратегий:
1. запрет копирования и перемещения;
2. копирование разделяемого ресурса вместе с хендлером (deep copy);
3. запрет копирования, но разрешение перемещения;
4. совместное владение (регулируется, например, подсчетом ссылок).

“Правило трех” и “правило пяти”


Так вот “правило трех” и “правило 5ти” говорят о том, что в общем случае, если возникла необходимость самостоятельного определения одной из операций копирования, перемещения или разрушения нашего объекта в соответствие с одной из выбранных стратегий, то скорее всего для корректной работы нужно будет определить все остальные функции тоже.
Почему это так, легко увидеть на следующем примере. Допустим членом нашего класса является указатель на объект в куче.

class my_handler {
public:
	my_handler(int c) : counter_(new int(c)) {}
private:
	int* counter_;
};


Деструктор по умолчанию в этой ситуации нам не подходит, так как он уничтожит только сам указатель counter_, но не то, на что он указывает. Определяем деструктор.

my_handler::~my_handler() {delete counter_;}


Но что теперь произойдет при попытке скопировать объект нашего класса? Вызовется определенный по умолчанию конструктор копирования, который честно скопирует указатель и в итоге у нас будет 2 объекта, владеющих указателем на один и тот же ресурс. Это плохо по понятным причинам. Значит нам нужно определить собственные конструктор копирования, оператор присваивания и т.д.
Ну так в чем же дело? Давайте всегда будем определять все 5 “специальных” функций и все будет ок. Можно, но, честно говоря, довольно утомительно и чревато ошибками. Тогда давайте будем определять только те, что действительно необходимы в текущей ситуации, а остальные пусть генерируются компилятором? Тоже вариант, но во-первых “ситуация” в которой используется наш код вполне может измениться без нашего ведома, и наш класс окажется неспособным работать в новых условиях, а во-вторых есть особые (и, как мне кажется, довольно запутанные) правила подавляющие генерацию компилятором спец. функций. Например, “функции перемещения не будут неявно сгенерированы компилятором, если есть хотя бы одна явно объявленная функция из 5ки” или “функции копирования не будут сгенерированы, если есть хотя бы одна явно объявленная функция перемещения”.

“Правило ноля”


Один из возможных выходов был озвучен Мартино Фернандесом в виде “правила ноля” и кратко может быть cформулирован следующим образом: “не определяйте самостоятельно ни одну из функций 5ки, вместо этого поручите заботу о владении ресурсами специально придуманным для этого классам”. А такие специальные классы уже есть в стандартной библиотеке. Это std::unique_ptr и std::shared_ptr. Благодаря тому, что при использовании этих классов существует возможность задавать пользовательские deleter’ы, с помощью них можно реализовать большинство из стратегий владения, описанных выше (по крайней мере, самые полезные). Например, если класс владеет объектом, для которого совместное владение не имеет смысла или даже вредно (файловый дескриптор, мутекс, поток и т.п.), завернем этот объект в std::unique_ptr с соответствующим deleter’ом. Теперь объект нашего класса нельзя будет скопировать (только переместить), а также автоматически обеспечится корректное уничтожение ресурса при уничтожении нашего объекта. Если же семантика хранимого хендлера допускает совместное владение ресурсом, то используем shared_ptr. В качестве примера подойдет приведенный выше пример с указателем на счетчик.
Подождите… Но ведь в ситуациях с полиморфным наследованием мы просто обязаны объявить виртуальный деструктор, чтобы обеспечить корректное разрушение производных объектов. Получается “правило ноля” здесь неприменимо? Не совсем так. Shared_ptr поможет нам и в этой ситуации. Дело в том, что deleter shared_ptr’а “помнит” реальный тип хранимого в нем указателя.

struct base {virtual void foo() = 0;};
struct derived : base {void foo() override {...}};

base* bad = new derived;
delete bad; // Плохо! Нет виртуального деструктора в base

{
	...
std::shared_ptr<base> good = std::make_shared<derived>();
} // Хорошо! shared_ptr при разрушении вызовет правильный деструктор.


Если вас смущает оверхед shared_ptr’а или вы хотите обеспечить эксклюзивное владение указателю на ваш полиморфный объект, можно завернуть его и в unique_ptr, но тогда придется написать свой кастомный deleter.

typedef std::unique_ptr<base, void(*)(void*)> base_ptr;
base_ptr good{new base, [](void* p){delete static_cast<derived*>(p);}};


Последний способ чреват определенными проблемами. Для множественного наследования придется писать 2 (или больше) разных deleter’a, также появляется возможность переместить один смарт пойнтер из другого, не смотря на то что реализация deleter’ов у них могут быть разными.

Итак, “правило ноля” представляет собой еще один подход к механизму управления ресурсами, но как и любые другие идиомы С++, использовать его бездумно нельзя. В каждой конкретной ситуации нужно решать отдельно имеет ли смысл его применять. В ссылках ниже есть статья Скота Мейерса на эту тему.

Ссылки

flamingdangerzone.com/cxx11/2012/08/15/rule-of-zero.html
scottmeyers.blogspot.ru/2014/03/a-concern-about-rule-of-zero.html
stackoverflow.com/questions/4172722/what-is-the-rule-of-three
stackoverflow.com/questions/4782757/rule-of-three-becomes-rule-of-five-with-c11
Tags:
Hubs:
+59
Comments 24
Comments Comments 24

Articles