Правила трех, пяти и нуля

1964
Правила трех, пяти и нуля
Правила трех, пяти и нуля

В этой статье представлены правила трех, пяти и нуля, а также объясняется, какое из них следует использовать и когда.

Правило трех было придумано еще в 1991 году. Оно расширилось до правила пяти с семантикой перемещений в C++11, но даже оно затем было подчинено правилу нуля. Но что такое все эти правила? И должны ли мы им следовать?

C++ давно известен своим принципом RAII (Resource Acquisition Is Initialization). Этот термин относится к возможности управлять ресурсами, такими как память, с помощью пяти специальных функций-членов: конструкторов копирования и перемещения, деструкторов и операторов присваивания. Часто, когда упоминается RAII, это относится к деструкторам, детерминированно вызываемым в конце области видимости. Немного иронично, учитывая и без того неудобное название. Но остальные суперспособности RAII не менее важны. В то время как многие языки просто различают «типы значений» и «ссылочные типы» (например, C# определяет типы значений в структурах и ссылочные типы в классах), C++ дает нам гораздо более богатую канву для работы с идентичностью и ресурсами с помощью этого набора специальных функций-членов.

Но даже до появления C++11 за эту гибкость приходилось платить сложностью. Некоторые взаимодействия очень тонкие, и их легко перепутать. Поэтому еще в 1991 году Маршалл Клайн придумал «Правило трех», простое эмпирическое правило, которое охватывало большинство случаев. Когда в C++11 появилась семантика перемещений, это правило было усовершенствовано до «Правила пяти». Затем Р. Мартиньо Фернандеш придумал «Правило нуля», предполагая, что оно превосходит «Правило пяти» по умолчанию. Но что такое все эти правила? И должны ли мы им следовать?

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

Правило трех предполагает, что если вам нужно определить любой из конструктора копирования, оператора присваивания копирования или деструктора, то обычно нужно определить «все три». Я взял «все три» в кавычки, потому что этот совет устарел по сравнению с C++11. Теперь, с семантикой перемещения, есть две дополнительные специальные функции-члена: конструктор перемещения и оператор присваивания перемещения. Таким образом, правило пяти — это просто расширение, которое предполагает, что если вам нужно определить любую из пяти функций, то вам, вероятно, нужно определить или удалить (или, по крайней мере, рассмотреть) все пять.

(Это утверждение не так сильно, как в Правиле трех, потому что если вы не определите операции перемещения, то они не будут сгенерированы — и вызовы вернутся к операциям копирования. Это не будет неправильным, но, возможно, упущенная возможность для оптимизации).

Если вы не компилируете строго для более ранних версий, чем C++11, вы должны следовать правилу пяти.

В любом случае это имеет смысл. Если вам нужно определить пользовательскую специальную функцию-член (отличную от конструктора по умолчанию), то обычно это связано с управлением каким-либо ресурсом. В этом случае вам нужно будет подумать о том, что происходит с ним на каждом этапе его жизни. Обратите внимание, что существуют различные причины, по которым реализация специальных функций-членов по умолчанию может быть подавлена или удалена, о чем мы подробнее поговорим во второй статье.

Вот пример, в значительной степени вдохновленный indirect_value из P1950:

template<typename T>
class IndirectValue {
   T* ptr;
public:

   // Init & destroy
   explicit IndirectValue(T* ptr ) : ptr(ptr) {}
   ~IndirectValue() noexcept { if(ptr) delete ptr; }

   // Copy (along with the destructor, gives us the Rule of Three)
   IndirectValue(IndirectValue const& other) : ptr(other.ptr ? new T(*other.ptr) : nullptr) {}

   IndirectValue& operator=(IndirectValue const& other) {
       IndirectValue temp(other);
       std::swap(ptr, temp.ptr);
       return *this;
   }

   // Move (Adding these gives us the Rule of Five)
   IndirectValue(IndirectValue&& other) noexcept : ptr(other.ptr) {
       other.ptr = nullptr;
   }
   IndirectValue& operator=(IndirectValue&& other) noexcept {
       IndirectValue temp(std::move(other));
       std::swap(ptr, temp.ptr);
       return *this;
   }

   // Other methods
};

Обратите внимание, что мы использовали идиому copy-and-swap (и move-and-swap) для реализации операторов присваивания, чтобы предотвратить утечки и автоматически обрабатывать самоназначение (мы также могли бы объединить эти два оператора в один, который принимает свой аргумент по значению, но я хотел показать обе функции в этом примере).

Теперь оба правила начинаются со слов: «Если вам нужно определить что-либо из …». Иногда негативное пространство бывает интересным. Неявная сторона этих правил заключается в том, что есть полезные случаи, когда вам не нужно определять ни одну из специальных функций-членов, и все будет работать так, как ожидается. Оказывается, это может быть самым важным случаем, но чтобы понять, почему, нам нужно немного изменить ситуацию. Введите правило нуля.

Правило нуля

Если специальные функции-члены не определены пользователем, то (с учетом переменных-членов) компилятор предоставляет реализацию по умолчанию для всех этих функций. Правило нуля заключается в том, что вы должны предпочесть случай, когда нет необходимости определять специальные функции-члены. Это делится на два случая:

  1. Ваш класс определяет чистый тип значения, и любое его состояние состоит из чистых типов значений (например, примитивов).
  2. Любые ресурсы, хранящиеся как часть состояния вашего класса, управляются классами, которые специализируются на управлении ресурсами (например, интеллектуальные указатели, файловые объекты и т.д.).

Второй случай заслуживает более подробного объяснения. Другая формулировка заключается в том, что любой данный класс должен непосредственно управлять, самое большее, одним ресурсом. Таким образом, если вам нужно управлять памятью, то вы должны использовать или написать класс, специализированный для управления этой памятью — будь то умный указатель, контейнер на основе массива или что-то еще. Эти типы, управляющие ресурсами, будут следовать правилу пяти. Но такие классы должны быть довольно редкими — стандартная библиотека покрывает большинство распространенных случаев своими контейнерами, умными указателями и потоковыми объектами. Класс, использующий тип, управляющий ресурсами, должен «просто работать», следуя правилу нуля.

Соблюдение этого строгого разграничения делает ваш код более простым, чистым и сфокусированным — и его легче написать правильно. «No code has less bugs than no code», поэтому необходимость писать меньше кода (особенно кода для управления ресурсами) — это, как правило, хорошо.

Поэтому, опять же, правило нуля имеет смысл — и, действительно, анализаторы Sonar направят вас к этому с помощью S493 — «Правило нуля» должно соблюдаться.

Когда использовать то или иное правило?

В некотором смысле, Правило нуля включает в себя Правило пяти, поэтому вы должны следовать ему. Но еще один способ задуматься об этом — следовать правилу нуля по умолчанию. Возвращайтесь к правилу пяти, когда обнаружите, что вам нужно написать какие-либо специализированные классы, владеющие ресурсами (что должно быть редкостью). Опять же, это отражено в S3624 — Когда «Правило нуля» неприменимо, следует следовать «Правилу пяти».

Правило трех вступает в силу, только если вы работаете строго с pre-C++11.

Но действительно ли это касается всех случаев?

Когда правил трех, пяти и нуля недостаточно

Полиморфные базовые классы — это распространенный случай, когда вышеупомянутые правила применимы, но кажутся немного тяжеловесными. Почему? Потому что такие классы должны иметь (по умолчанию) виртуальный деструктор (S1235 — Деструктор полиморфного базового класса должен быть либо публичным виртуальным, либо защищенным невиртуальным). Это не означает, что они должны иметь какие-либо другие специальные функции-члены — на самом деле, хорошей практикой для полиморфных базовых классов является использование чистых абстрактных базовых классов — без функциональности.

Предоставление публичных операций копирования и перемещения для полиморфных иерархий делает их склонными к срезу — когда разница между статическими и динамическими типами теряется при копировании. Если требуется возможность копирования (или перемещения), то они должны выполняться виртуальными методами. В этом случае обычно используется виртуальный метод clone(). Реализации этих виртуальных методов могут использовать операции копирования и перемещения — в этом случае они могут быть реализованы или по умолчанию использоваться как защищенные члены, предотвращая случайное использование извне. В противном случае, что чаще всего и происходит, их следует просто удалить.

virtual ~MyBaseClass() = default;
MyBaseClass(MyBaseClass const &) = delete;
MyBaseClass(MyBaseClass &&) = delete;
MyBaseClass operator=(MyBaseClass const &) = delete;
MyBaseClass operator=(MyBaseClass &&) = delete;

Реализация или удаление всех специальных функций-членов может стать немного утомительным занятием, особенно если вы работаете в кодовой базе с большим количеством полиморфных базовых классов (хотя в наши дни это встречается довольно редко, по крайней мере, в новом коде). Одним из способов обойти это — фактически единственным способом до C++11 — является частное наследование от базового класса, который имеет эти пять определений (или, до C++11, сделать «удаленные» функции частными и нереализованными). Это все еще актуальный вариант и, возможно, возвращает нас к правилу нуля.

Однако оказывается, что удалить оператор присваивания move — это все, что нам нужно сделать. Из-за того, как заданы взаимодействия между специальными функциями-членами, это будет иметь тот же эффект (а на самом деле, может быть, даже немного лучше, как мы увидим в следующей статье).

virtual ~MyBaseClass() = default;
   MyBaseClass operator=(MyBaseClass &&) = delete;