Цель этого поста — познакомить вас с Правилами Трех, Пяти и Ноля и объяснить, какое из них и когда вам следует использовать. В следующем посте мы углубимся в применение Правила Пяти в различных сценариях.
Для начала давайте вспомним один из основополагающих принципов 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;
Если это кажется странным или немного подозрительным, или если вы хотите больше узнать о применении Правила Пяти в ряде разных случаев, читайте вторую часть этой серии, где мы углубимся во все это, а также в то, как определяются эти взаимодействия.
Приглашаем всех желающих на открытое занятие, посвященое многопоточному программированию на C++. На примере такой задачи, как подсчет числа простых чисел, мы рассмотрим, как различные элементы многопоточного программирования на C++ помогут получить более производительное решение. Регистрация на вебинар открыта по ссылке.
Комментарии (6)
jmdorian
11.12.2022 14:24Если вы не компилируете код для более ранней версии, чем C++11, вы должны следовать Правилу Пяти.
Прочитал предложение раз 7. Пожалуйста, переводите чуть ближе к родному нам языку.
oleg_shamshura
if(ptr) delete ptr;
AFAIK, оператор delete содержит в себе проверку на ноль (как и free() в С).
Создается впечатление, что в С++ никто ни в чем до конца не уверен -- даже гуру, пишущие статьи о том, "как надо".
В комитете по стандартизации -- одни юристы, что ли? Как можно было все так заморочить?
playermet
Что поделать, даже лично Страуструп оценивает свое знание С++ на 7-8 из 10. А это человек, который на С++ почти 40 лет положил. Причем если для рядового программиста это инструмент, то для Страуструпа это еще и продукт, и задача, и предметная область. А сколько тогда балов должно быть у среднестатистического сеньора?
klimkinMD
Похожая ситуация с PL/1.
Playa
Всё проще. Многие из таких вот статей пишутся людьми, которые не занимаются разработкой ПО. А потом их переводит мэд макс, который не занимается разработкой ПО.
KanuTaH
Кто умеет - тот делает, кто не умеет - тот учит, как надо делать (С).