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

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

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

В контексте C++ слова Type и Class имеют немного разные значения. Но на нашем естественном языке мы обычно говорим о типах или классах чего-либо в более общем смысле. Не так просто подобрать слова, чтобы абсолютно точно и совершенно однозначно рассуждать о типах или классах… эмм, типов или классов! Питер Соммерленд (Peter Sommerlad) оперирует термином “классовые сущности” (class natures), но я здесь буду использовать слово “категория”. Однако я чувствую необходимость добавить оговорку о том, что его не следует путать с математическим понятием категории (т.е. из теории категорий) - хотя, конечно, некоторая связь имеет место. Также сразу же стоит отметить, что тип может принадлежать более чем к одной категории.

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

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

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

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

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

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

Важность деструктора очевидна, но не менее важен и конструктор. Менеджер с ограничением на область видимости обычно имеет пользовательский конструктор, который получает или берет под контроль какой-либо ресурс, возможно, через какое-либо низкоуровневое API. У него также могут быть другие конструкторы, если ресурс создается внутри менеджера. Или конструктор по умолчанию может указывать на допустимость неопределенного значения или null. Определение этих конструкторов для получения ресурса (Acquire Constructors) будет зависеть от того, какой подход нам покажется самым адекватным.

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

~ScopedManager() { /* пользовательский код деструктора */ }
ScopedManager( /* optional arguments */ ) { /* опциональный пользовательский конструктор */ }

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 добавляли операции перемещения (move семантику), они не стали допускать ту же ошибку. Если вы определяете конструктор перемещения или оператор присваивания перемещением, операции копирования удаляются. И вот тут имеет место быть некоторая неконсистентность, поэтому нам с вами нужно по прежнему проявлять здесь осторожность. Технически сгенерированные операции копирования теперь устарели, если определена одна из функций оригинального Правила Трех. Поэтому мы не должны полагаться на их генерацию. Но на практике они будут, поэтому мы не можем полагаться и на то, что они не будут сгенерированы.

Эта информация будет намного нагляднее в форме таблицы. Говард Хиннант (Howard Hinnant) уже создавал аналогичную таблицу в прошлом. Приведенная здесь таблица немного от нее отличается. Пользуйтесь той, которая вам нравится больше.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Правила, клятые правила и гайдлайны

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

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

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


Автоматизация — одна из целей разработки программного обеспечения. Однако и для разработки ПО автоматизация не повредит. На открытом уроке рассмотрим несколько полезных инструментов для повседневной работы программиста на языке C++. Регистрируйтесь на занятие по ссылке.

Комментарии (3)


  1. Apoheliy
    19.12.2022 18:56

    В ссылке на табличку Хиннанта нужно в конце слэш убрать.


    1. MaxRokatansky Автор
      19.12.2022 22:06

      Убрали, спасибо


  1. QtRoS
    20.12.2022 18:43