Правила трех, пяти и ноля
Цель данного поста – познакомить вас с правилами трех, пяти и ноля и объяснить, какое из них и когда вам следует использовать. В следующем посте мы углубимся в применение правила пяти в различных случаях.
Для начала давайте вспомним один из основополагающих принципов C++ – RAII (Resource Acquisition Is Initialization — «получение ресурса есть инициализация»). Этот принцип заключается в возможности управлять ресурсами, такими как память, с помощью пяти специальных функций-членов: конструкторов копирования и перемещения, деструкторов и операторов присваивания. Очень часто, когда кто-либо упоминает RAII, речь идет о деструкторах, детерминированно вызываемых в конце области видимости. Немного иронично, учитывая и без того несуразное название. Но остальные особенности RAII не менее важны. В то время как многие языки просто разделяют свои типы на «типы значений» и «ссылочные типы» (например, C# определяет типы значений в структурах, а ссылочные типы – в классах), C++ дает нам куда более широкое пространство для работы с идентификаторами и ресурсами посредством этого набора специальных функций-членов.
Но даже до C++11 ценой этой гибкости была сложность. Некоторые взаимодействия довольно тонкие, и в них легко ошибиться. Поэтому еще в 1991 году Маршалл Клайн (Marshall Cline) сформулировал «Правило Трех» – простое эмпирическое правило, применимое для большинства сценариев. Когда C++11 представил move-семантику (или семантику перемещения), оно было трансформировано в «Правила Пяти». Затем Р. Мартиньо Фернандес (R. Martinho Fernandes) сформулировал «Правило Ноля», предполагая, что оно по умолчанию превосходит «Правила Пяти». Но в чем смысл всех этих правил? И должны ли мы им следовать?
Как Правило Трех стало Правилом Пяти
Правило Трех предполагает, что если вам нужно определить что-то одно из конструктора копирования, оператора присваивания копированием или деструктора, то, скорее всего, вам нужно определить «все три». Я взял «все три» в кавычки, потому что этот совет устарел начиная с C++11. Теперь, с move-семантикой, у нас появилось две дополнительные специальные функции-члены: конструктор перемещения и оператор присваивания перемещением. Таким образом, «Правило Пяти» – это просто расширение, которое предполагает, что если вам нужно определить любого из этой пятерки, то вам, скорее всего, нужно определить или удалить (или, по крайней мере, рассмотреть такую возможность) все пять.
(Это утверждение не так строго, как Правило Трех, потому что, если вы не определите операции перемещения, они не будут генерироваться, и вызовы будут обрабатываться через операции копирования. И это не будет ошибкой, но, возможно, это будет вашим большим упущением с точки зрения оптимизации.)
Если вы не компилируете код для версии ниже, чем C++11, вы должны следовать Правилу Пяти.
В любом случае это правило имеет смысл. Если вам нужно определить пользовательскую специальную функцию-член (не являющуюся конструктором по умолчанию), то обычно это из-за того, что вам нужно непосредственно управлять каким-либо ресурсом. В этом случае вам нужно будет отслеживать, что происходит с ним на каждом этапе его жизненного цикла. Обратите внимание, что существуют различные причины, по которым реализации по умолчанию для специальных функций-членов могут быть запрещены или удалены, и мы рассмотрим их подробнее в следующем посте.
Вот пример, вдохновленный indirect_value
из P1950:
template<typename T>
class IndirectValue {
T* ptr;
public:
// Инициализация и уничтожение
explicit IndirectValue(T* ptr ) : ptr(ptr) {}
~IndirectValue() noexcept { if(ptr) delete ptr; }
// Копирование (вместе с деструктором дает нам Правило Трех)
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;
}
// Перемещение (добавление этих членов уже дает нам Правило Пяти)
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;
}
// Остальные методы
};
Хочу обратить ваше внимание на то, что для реализации операторов присваивания мы использовали идиомы копирования и обмена (copy-and-swap) и перемещения и обмена (move-and-swap) в целях предотвращения утечек и автоматической обработки самоприсваивания (мы также могли бы объединить эти два оператора в один, который принимает аргумент по значению, но я хотел продемонстрировать в этом примере обе функции).
Также важно отметить, что оба правила начинаются со слов «если вам необходимо определить что-то из…». Иногда оборотная сторона тоже может представлять интерес. Еще один неявный вывод из этих правил заключается в том, что существуют практические случаи, когда нам вообще не нужно определять какие-либо специальные функции-члены, и всё будет работать так, как нужно. Оказывается, это вполне может быть самым важным выводом, но чтобы понять почему, нам нужно немного переформулировать правила. Так на сцену выходит Правило Ноля.
Правило Ноля
Если ничего из специальных функций-членов не определено пользователем, то (с учетом переменных-членов) для каждой из них компилятор предоставит реализации по умолчанию. Правило Ноля заключается в том, что предпочтительным должен быть сценарий, когда не нужно определять ничего из специальных функций-членов. Отсюда вытекает два сценария:
- Ваш класс определяет чисто тип значений, и любое его состояние состоит чисто из типов значений (например, примитивов).
- Любые ресурсы, которые приходится задействовать состояниям вашего класса, управляются классами, которые специализируются исключительно на управлении ресурсами (например, умными указателями, файловыми объектами и т.д.).
Второй сценарий требует немного больше пояснений. Мы можем привести еще одну формулировку: любой класс должен непосредственно управлять не более чем одним ресурсом. Поэтому, если вам нужно управлять какой-нибудь памятью, вам следует задействовать уже готовый или написать свой класс, специализированный для управления этой памятью – будь то умный указатель, контейнер на основе массива или что-нибудь еще. Эти типы для управления ресурсами в свою очередь уже будут следовать Правилу Пяти. Но такие классы должны быть довольно редким явлением – стандартная библиотека покрывает наиболее распространенные сценарии своими контейнерами, умными указателями и потоковыми объектами. Класс, который использует тип для управления ресурсами, должен «просто делать свою работу», следуя Правилу Ноля.
Соблюдение этого строгого различия делает ваш код проще, чище и специализированнее, а также делает написание корректного кода немного проще. «Нет такого кода, в котором было бы меньше ошибок, чем в его отсутствии», поэтому необходимость писать меньше кода (особенно кода для управления ресурсами) – это зачастую очень хорошо.
И даже с этой точки зрения Правило Ноля имеет смысл – и, действительно, анализаторы Sonar рекомендуют вам его в S493 – «Правило Ноля» следует соблюдать.
Когда и какое правило использовать?
В некотором смысле, Правило Ноля включает в себя Правило Пяти, так что вы можете просто следовать ему. Но самый лучший подход – по умолчанию следовать Правилу Ноля, прибегая к Правилу Пяти, если обнаружили, что вам нужно написать какие-либо специализированные классы, управляющие ресурсами (что само по себе должно происходить достаточно редко). Опять же, это уже оговорено в S3624 – «Когда Правило Ноля не применимо, следует следовать Правилу Пяти».
Правило Трех применимо только в том случае, если вы работаете с версиями строго до C++11.
Но действительно ли они охватывают все случаи?
Когда Правил Трех, Пяти и Ноля недостаточно
Полиморфные базовые классы – распространенный случай, когда применяются вышеуказанные правила, но они кажутся несколько тяжеловесными. Почему? Потому что такие классы должны иметь (по умолчанию) виртуальный деструктор (S1235 – Деструктор полиморфного базового класса должен быть виртуальным public
или не виртуальным protected
). Это не означает, что они должны иметь какие-либо другие специальные функции-члены – на самом деле хорошей практикой является использование в качестве полиморфных базовых классов чисто абстрактных базовых классов – без какого-либо функционала.
Предоставление публичных операций копирования и перемещения для полиморфных иерархий делает их склонными к нарезке, когда разница между статическими и динамическими типами теряется при копировании. Если требуется возможность копирования или перемещения, то они должны осуществляться с помощью виртуальных методов. В этом случае обычно используется виртуальный метод clone()
. Реализации этих виртуальных методов могут использовать операции копирования и перемещения: в этом случае они могут быть реализованы или заданы по умолчанию как protected
члены, предотвращая случайное использование извне. В противном случае (т.е. в большинстве случаев) их следует просто удалить.
virtual ~MyBaseClass() = default;
MyBaseClass(MyBaseClass const &) = delete;
MyBaseClass(MyBaseClass &&) = delete;
MyBaseClass operator=(MyBaseClass const &) = delete;
MyBaseClass operator=(MyBaseClass &&) = delete;
Реализация или удаление всех специальных функций-членов может стать вполне утомительным занятием, особенно если вы работаете с кодовой базой, в которой много полиморфных базовых классов (хотя в наши дни это довольно редко, по крайней мере, в более современном коде). Один из способов обойти это (фактически единственный способ до C++ 11) это приватное наследование от базового класса, который уже имеет все эти пять определений (или, до C++11, делать «удаленные» функции приватными и нереализованными). Это вполне допустимый вариант, который, пожалуй, возвращает нас к Правилу Ноля.
Однако оказывается, что всё, что нам нужно сделать, это удалить оператор присваивания перемещением. Из-за того, как определяются взаимодействия между специальными функциями-членами, это будет иметь тот же эффект (и, на самом деле, может быть, немного лучше, как мы увидим в следующем посте).
virtual ~MyBaseClass() = default;
MyBaseClass operator=(MyBaseClass &&) = delete;
Если это кажется странным или немного подозрительным, или если вы хотите больше узнать о применении Правила Пяти в ряде разных случаев, читайте вторую часть этой серии статей, где мы углубимся во всё это, а также в то, как определяются эти взаимодействия.