Еще про правила трех, пяти и ноля

Добавлено 30 декабря 2022 в 22:04

В предыдущей статье мы рассмотрели Правила Трех, Пяти и Ноля – что это такое и когда какое использовать (спойлер: используйте Правило Ноля).

Но у правил часто есть исключения, и иногда эти исключения важны сами по себе. Какие случаи существуют за пределами правила нуля, и как мы можем их понять?

Категории типов

В C++ слова «тип» и «класс» имеют немного разные значения. Но в естественном языке мы говорим в более общем смысле о типах вещей или классах вещей. Бывает сложно подобрать однозначные слова, чтобы говорить о типах или классах… ну, типов или классов! Питер Соммерлад (Peter Sommerlad) использует термин «классовые сущности», но я собираюсь использовать здесь слово «категория». Однако я чувствую необходимость добавить оговорку о том, что это не следует путать с математическим понятием категории (то есть из теории категорий) – хотя, конечно, связь существует. Также стоит отметить, что тип может принадлежать более чем к одной категории.

Мы уже говорили о типах значений и полиморфных базовых классах, но еще одна распространенная категория типов – это то, что мы могли бы назвать менеджерами ресурсов. Это типы, которые непосредственно управляют каким-либо ресурсом: они обычно получают ресурс в своем конструкторе и уничтожают или освобождают его в своем деструкторе. Между ними они могут делать большее, но это, как мы увидим, зависит от их подкатегории. Возможно, наиболее очевидными примерами этого являются умные указатели, такие как unique_ptr и shared_ptr. Они управляют ресурсом памяти – как и std::string и std::vector (которые также являются хорошими примерами принадлежности более чем к одной категории – они также являются типами значений). У нас также есть файловые потоки, которые управляют файловыми дескрипторами, блокировщики (например, lock_guard) для управления мьютексами и многое другое.

Именно здесь традиционно блистают Правила Трех и Пяти.

Подкатегории менеджеров ресурсов

Что касается специальных функций-членов, сначала кажется, что каждый тип менеджера ресурсов идет своим путем. Но в целом, в зависимости от подхода к владению можно выделить три подкатегории менеджеров ресурсов: с ограничением на область видимости (Scoped), уникальные (Unique) и общего назначения (General).

Менеджеры с ограничением на область видимости

Это не копируемые, не перемещаемые типы. Их основная цель – сделать что-то в своих деструкторах (форма отложенного выполнения). В сочетании со свойством детерминированного разрушения в C++, если такой объект создается в стеке, мы точно знаем, когда этот деструктор будет запущен (в конце области видимости, в которой он был объявлен, или в точке возникновения исключения), и в каком порядке (обратном порядку построения). Это может быть критично для таких вещей, как, например, scoped_lock, который управляет мьютексом.

Деструктор здесь явно важен, но не менее важен и конструктор. Менеджер с ограничением на область видимости обычно имеет собственный конструктор, который получает или забирает во владение какой-либо ресурс – возможно, из какого-либо API более низкого уровня. У него также могут быть другие конструкторы, если ресурс создается внутри, или конструктор по умолчанию может указывать на допустимость нулевого значения. Эти конструкторы получения ресурса (acquire constructors) будут зависеть от используемого подхода.

Однако конструкторы копирования и перемещения должны быть удалены вместе с операторами присваивания копированием и перемещением.

~ScopedManager() { /* пользовательский код деструктора */ }
ScopedManager( /* необязательные аргументы */ ) { /* необязательный пользовательский конструктор */ }

ScopedManager(ScopedManager const &) = delete;
ScopedManager(ScopedManager &&) = delete;
ScopedManager operator=(ScopedManager const &) = delete;
ScopedManager operator=(ScopedManager &&) = delete;

Итак, удалены операции копирования и перемещения, снова. Мы уже видели это в предыдущей статье, когда рассматривали полиморфные базовые классы. Мы могли бы удалить их все вручную – или мы можем использовать сокращение, просто удалив оператор присваивания перемещением.

ScopedManager operator=(ScopedManager &&) = delete;

Это намного меньше кода. Но подождите, что? Почему это работает?

Отступление: правила предоставления компилятором специальных функций-членов C++

В центре того, почему некоторые из этих взаимодействий являются тонкими и часто неинтуитивными, являются правила, для которых специальные функции-члены синтезируются компилятором и при каких обстоятельствах. До C++11 проблема заключалась в том, что операции копирования по умолчанию генерировались во всех случаях, даже если вы определили деструктор. Сам язык нарушал Правило Трех – отсюда и необходимость применять его явно.

Когда C++11 добавил операции перемещения, они не допустили той же ошибки. Если вы определяете конструктор перемещения или оператор присваивания перемещением, операции копирования удаляются. Это оставляет несоответствие, поэтому нам всё еще нужно проявлять осторожность. Технически сгенерированные операции копирования теперь устарели, если определена одна из других исходных функций Правила Трех. Поэтому мы не должны полагаться на их создание. Но на практике они будут, поэтому мы не можем полагаться на то, что они не будут сгенерированы.

Всё это немного легче проследить в таблице. Говард Хиннант (Howard Hinnant) создал аналогичную таблицу в прошлом. Она немного отличается. Используйте ту, которую вы считаете наиболее удобной.

Неявное генерирование специальных членов-функций компилятором (таблица Говарда Хиннанта)
Неявное генерирование специальных членов-функций компилятором (таблица Говарда Хиннанта)

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

Интересно то, что вы можете ясно видеть проблему, для решения которой потребовалось Правило Трех, – выделенное красным, подчеркнутым текстом в центре (это устаревшие функции).

Неявное генерирование специальных членов-функций компилятором
Неявное генерирование специальных членов-функций компилятором

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

Питер Соммерлад называет этот подход «Правилом DesDeMovA» («DEStructor + DElete MOVe Assignment» – это отсылка к трагическому персонажу Дездемоне из шекспировской пьесы «Отелло»). В любое время, когда вам нужен некопируемый, неперемещаемый тип, но при этом разрешен пользовательский деструктор, просто укажите удаленный оператор присваивания перемещением.

Менеджеры уникальных ресурсов

Поскольку семантика перемещения C++11 сделала их возможными, менеджеры уникальных ресурсов стали популярным способом управления ресурсами, при котором управление жизненным циклом может быть передано от одного менеджера другому. Типичным менеджером уникальных ресурсов является std::unique_ptr. Менеджеры уникальных ресурсов работают точно так же, как менеджеры с ограничением на область видимости, за исключением того, что они реализуют конструктор перемещения и/или присваивание перемещением.

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

UniqueManager(UniqueManager &&) { /* пользовательский конструктор перемещения */ }
UniqueManager operator=(UniqueManager &&) { /* пользовательское присваивание перемещением */ }
~UniqueManager() { /* пользовательский код деструктора */ }

UniqueManager() { /* необязательный конструктор по умолчанию */ }
UniqueManager(auto args...) { /* необязательный пользовательский конструктор */ }

Менеджеры ресурсов общего назначения

Менеджер ресурсов общего назначения можно копировать, и, если скопированный объект не зависит от оригинала (а не содержит изменяемые общие ресурсы, как std::shared_ptr), то тип менеджера действует как тип значения – придавая семантику значения ресурсу, которым он управляет. Вместо того чтобы кодировать само значение, он добавляет общеизвестный уровень косвенности. Некоторые называют это косвенным значением. Почему это может быть полезно? Почему бы просто не использовать базовое значение напрямую?

Обычно такие менеджеры управляют объектами в памяти с помощью указателя – как std::unique_ptr, но также с операциями копирования (так что это полное Правило Пяти). Обычный вариант использования – когда управляемое значение является полиморфным. В этом случае необходим способ вызова правильных операций копирования. Традиционно это реализовывалось с помощью виртуальных методов clone(). Другой подход, набирающий популярность, заключается в захвате указателей на методы копирования при получении и сохранении их в объекте-менеджере. Преимущество этого подхода заключается в том, что он более общий и менее навязчивый. Его недостаток в том, что он более сложный и трудный для правильного написания. На момент написания статьи есть предложение стандартизировать std::polymorphic_value, что упростит задачу.

Другой вариант использования – когда конкретный тип ресурса фиксирован, но вы хотите, чтобы он не появлялся в интерфейсе. Это часто используется, как способ сломать или свести к минимуму случайные зависимости в коде, что обычно приводит к более быстрой сборке. Существует несколько вариантов, со временем использовавших разные названия, но одним из наиболее распространенных является идиома pImpl.

В любом случае сделать это правильно может быть сложнее, чем кажется, поэтому, если возможно, используйте проверенные библиотечные решения. Опять же, есть предложение для std::indirect_value, чтобы помочь с этим.

Правила, чертовы правила и рекомендации

Теперь, когда наш словарный запас пополнился распространенными категориями типов, которые мы используем в коде C++, мы смогли рассмотреть более подробные рекомендации по работе со специальными функциями-членами.Завершим это таблицей, резюмирующей то, что мы обсуждали в этой и предыдущей статье.

Добавим еще одну категорию типов – представления или ссылочные типы. Это не владеющие псевдо-менеджеры (например, std::string_view, std::span или даже просто обычные указатели). Поскольку они не участвуют в управлении жизненным циклом, конкретных рекомендаций нет. Деструкторы, вероятно, не нужны. Копии обычно тривиальны, а перемещения не нужны (это были бы просто копии). Всё это покрывается Правилом Нуля.

КатегорияКогда использоватьПравилаСпециальные члены
Типы значенийПростые прямые значенияПравило Ноля 
ПредставленияНевладеющие менеджеры
Полиморфные базовые классыКлассические иерархии ООППравило Пяти с отключенными копированием и перемещением («DesDeMovA»)virtual ~T() = default
operator=(T&&)
Менеджеры с ограничением на область видимостиС коротким сроком жизни, в стеке~T()
operator=(T&&)
Менеджеры уникальных ресурсовОдиночное владение – может хранить. Копировать дорого или бессмысленно.Правило Пяти с отключенными копированием~T()
T(T&&)
operator=(T&&)
Менеджеры общего назначенияПридают семантику значения управляемым ресурсам – с полным копированием независимых объектовПравило Пяти~T()
T(T const&)
T(T&&)
operator=(T const&)
operator=(T&&)

Таким образом, между Правилами Ноля и Пяти мы можем использовать правила подавления генерации операций перемещения, чтобы дать нам более детальный подход к тому, как мы можем прописать наши специальные функции-члены. Помните, любое отклонение от Правила Ноля должно быть редким, особенно в прикладном коде. Правило Ноля по-прежнему в приоритете!

Теги

C++ / Cppstd::shared_ptrstd::unique_ptrДеструктор / Destructor / dtor (программирование)Конструктор / Constructor / ctor (программирование)Конструктор копированияКонструктор перемещенияОператор присваиванияПравило НоляПравило ПятиПравило ТрёхПрограммированиеСемантика перемещенияУмные указатели

На сайте работает сервис комментирования DISQUS, который позволяет вам оставлять комментарии на множестве сайтов, имея лишь один аккаунт на Disqus.com.

В случае комментирования в качестве гостя (без регистрации на disqus.com) для публикации комментария требуется время на премодерацию.