«Правило ноля»

    Применительно к с++03 существует “правило трех”, с появлением с++11 оно трансформировалось в “правило 5ти”. И хотя эти правила по сути являются не более чем неформальными рекомендациями к проектированию собственных типов данных, но тем не менее часто бывают полезны. “Правило ноля” продолжает ряд этих рекомендаций. В этом посте я напомню о чем, собственно, первые 2 правила, а также попробую объяснить идею, стоящую за “правилом ноля”.

    Мотивация


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

    Стратегии владения


    В случае же больших сложных классов, или классов, в качестве члена которого выступает хендлер внешнего ресурса поведение, реализуемое компилятором по умолчанию, нас уже может не устроить. К счастью мы можем самостоятельно определить специальные функции, реализовав нужную в данной ситуации стратегию владения ресурсом. Условно можно выделить несколько основных таких стратегий:
    1. запрет копирования и перемещения;
    2. копирование разделяемого ресурса вместе с хендлером (deep copy);
    3. запрет копирования, но разрешение перемещения;
    4. совместное владение (регулируется, например, подсчетом ссылок).

    “Правило трех” и “правило пяти”


    Так вот “правило трех” и “правило 5ти” говорят о том, что в общем случае, если возникла необходимость самостоятельного определения одной из операций копирования, перемещения или разрушения нашего объекта в соответствие с одной из выбранных стратегий, то скорее всего для корректной работы нужно будет определить все остальные функции тоже.
    Почему это так, легко увидеть на следующем примере. Допустим членом нашего класса является указатель на объект в куче.

    class my_handler {
    public:
    	my_handler(int c) : counter_(new int(c)) {}
    private:
    	int* counter_;
    };
    


    Деструктор по умолчанию в этой ситуации нам не подходит, так как он уничтожит только сам указатель counter_, но не то, на что он указывает. Определяем деструктор.

    my_handler::~my_handler() {delete counter_;}
    


    Но что теперь произойдет при попытке скопировать объект нашего класса? Вызовется определенный по умолчанию конструктор копирования, который честно скопирует указатель и в итоге у нас будет 2 объекта, владеющих указателем на один и тот же ресурс. Это плохо по понятным причинам. Значит нам нужно определить собственные конструктор копирования, оператор присваивания и т.д.
    Ну так в чем же дело? Давайте всегда будем определять все 5 “специальных” функций и все будет ок. Можно, но, честно говоря, довольно утомительно и чревато ошибками. Тогда давайте будем определять только те, что действительно необходимы в текущей ситуации, а остальные пусть генерируются компилятором? Тоже вариант, но во-первых “ситуация” в которой используется наш код вполне может измениться без нашего ведома, и наш класс окажется неспособным работать в новых условиях, а во-вторых есть особые (и, как мне кажется, довольно запутанные) правила подавляющие генерацию компилятором спец. функций. Например, “функции перемещения не будут неявно сгенерированы компилятором, если есть хотя бы одна явно объявленная функция из 5ки” или “функции копирования не будут сгенерированы, если есть хотя бы одна явно объявленная функция перемещения”.

    “Правило ноля”


    Один из возможных выходов был озвучен Мартино Фернандесом в виде “правила ноля” и кратко может быть cформулирован следующим образом: “не определяйте самостоятельно ни одну из функций 5ки, вместо этого поручите заботу о владении ресурсами специально придуманным для этого классам”. А такие специальные классы уже есть в стандартной библиотеке. Это std::unique_ptr и std::shared_ptr. Благодаря тому, что при использовании этих классов существует возможность задавать пользовательские deleter’ы, с помощью них можно реализовать большинство из стратегий владения, описанных выше (по крайней мере, самые полезные). Например, если класс владеет объектом, для которого совместное владение не имеет смысла или даже вредно (файловый дескриптор, мутекс, поток и т.п.), завернем этот объект в std::unique_ptr с соответствующим deleter’ом. Теперь объект нашего класса нельзя будет скопировать (только переместить), а также автоматически обеспечится корректное уничтожение ресурса при уничтожении нашего объекта. Если же семантика хранимого хендлера допускает совместное владение ресурсом, то используем shared_ptr. В качестве примера подойдет приведенный выше пример с указателем на счетчик.
    Подождите… Но ведь в ситуациях с полиморфным наследованием мы просто обязаны объявить виртуальный деструктор, чтобы обеспечить корректное разрушение производных объектов. Получается “правило ноля” здесь неприменимо? Не совсем так. Shared_ptr поможет нам и в этой ситуации. Дело в том, что deleter shared_ptr’а “помнит” реальный тип хранимого в нем указателя.

    struct base {virtual void foo() = 0;};
    struct derived : base {void foo() override {...}};
    
    base* bad = new derived;
    delete bad; // Плохо! Нет виртуального деструктора в base
    
    {
    	...
    std::shared_ptr<base> good = std::make_shared<derived>();
    } // Хорошо! shared_ptr при разрушении вызовет правильный деструктор.
    


    Если вас смущает оверхед shared_ptr’а или вы хотите обеспечить эксклюзивное владение указателю на ваш полиморфный объект, можно завернуть его и в unique_ptr, но тогда придется написать свой кастомный deleter.

    typedef std::unique_ptr<base, void(*)(void*)> base_ptr;
    base_ptr good{new base, [](void* p){delete static_cast<derived*>(p);}};
    


    Последний способ чреват определенными проблемами. Для множественного наследования придется писать 2 (или больше) разных deleter’a, также появляется возможность переместить один смарт пойнтер из другого, не смотря на то что реализация deleter’ов у них могут быть разными.

    Итак, “правило ноля” представляет собой еще один подход к механизму управления ресурсами, но как и любые другие идиомы С++, использовать его бездумно нельзя. В каждой конкретной ситуации нужно решать отдельно имеет ли смысл его применять. В ссылках ниже есть статья Скота Мейерса на эту тему.

    Ссылки

    flamingdangerzone.com/cxx11/2012/08/15/rule-of-zero.html
    scottmeyers.blogspot.ru/2014/03/a-concern-about-rule-of-zero.html
    stackoverflow.com/questions/4172722/what-is-the-rule-of-three
    stackoverflow.com/questions/4782757/rule-of-three-becomes-rule-of-five-with-c11
    Поделиться публикацией

    Похожие публикации

    Комментарии 24
      +12
      Как раз читаю Effective C++ Мейерса. RAII и умные указатели — действительно отличная вещь, во многом облегчающая жизнь.
      Вместо того, чтобы полагаться или не полагаться на сгенерированные компилятором функции, можно ему явно запретить генерацию. В C++11 для этого есть "=delete":
      Singleton
      class Singleton {
      public:
          Singleton(const Singleton& rhs) =delete;
          Singleton& operator=(const Singleton& rhs) =delete;
          static Singleton *getInstance() {
              return instance_;
          }
      
      private:
          Singleton();
          static Singleton *instance_;
      };
      
      


      мутекс
      Резануло глаза. В русскоязычной литературе всё-таки «мьютекс» обычно пишут.
        +2
        Красивше такой вариант:
        static auto instance() {
        static singleton instance{};
        return &instance;
        }

        Хотя я возвращал бы ссылку, копирование все равно запрещено.
          –4
          А глобальная ссылка позволит обращаться к объекту проще.

          Singleton &  singletonThe = Singleton::Instance();
          

          Ещё требуется указать, что эта переменная одна общая для всех cpp-файлов, использующих наш Singleton.h. Например в MSVC это так:

          __declspec(selectany)  Singleton &  singletonThe = Singleton::Instance();
          

            +4
            «Нужно больше глобальных переменных».
              –6
              Идея не встретила одобрения. Забавно.

              Коллеги, вы предпочитаете повсюду писать Singleton::Instance() вместо singletonThe – два идентификатора вместо одного? Или какая-то другая причина?
                +4
                __declspec(selectany) — это какбэ не портабельный C++. Плюс к тому, здесь не ленивая инициализация, а порядок инициализации глобальных переменных в C++ — это просто-таки поле из граблей.
                  –3
                  Атрибут __declspec(selectany) поддерживается и в gcc, и в Borland C++, и в Clang с ключом -fms-extensions.

                  Не поддерживается в Intel C++. Однако его используют около 2% присутствующих (из пишущих на С++) – результаты опроса.

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

                  В нашем проекте так много классов с паттерном Одиночка (Singleton), что можно запутаться в порядке их инициализации. Значит это не мелкая поделка, а большой проект, а значит и автоматические тесты в нём конечно же есть.

                  Зная всё это, спросите себя, согласны ли вы вдвое ухудшить читабельность везде, где происходит обращение к объекту класса Singleton.
                    0
                    По-моему мутные глобальные пременные куда больше ухудшают читабельность программы, чем пара лишних букв в обращении к переменной.
                      0
                      Вы всерьёз утверждаете, что

                      singletonInstance
                      

                      менее читабельно, чем

                      Singleton::Instance()
                      

                      ?
                        0
                        Да. singletonInstance может быть чем угодно, какого угодно типа, уже инициализированный или еще нет. В случае с Singleton::Instance() все придельно ясно.
                          0
                          Это венгерская запись. Тип переменной ясен из префикса «singleton».

                          Про то, что можно не бояться порядка инициализации, писал выше.

                          Впрочем, одну деталь я упустил с самого первого примера. Глобальность переменной тоже надобно показывать префиксом. Заменяем повсюду singletonInstance и singletonThe на 
                          g_singletonInstance.
                            0
                            Когда вызываем Singleton::Instance(), мы должны опасаться, что может произойти повторный вход (reenter) в метод, из которого делаем вызов. Примерно так:

                            A::Instance() приводит к вызову B::Instance()
                            B::Instance() приводит к вызову C::Instance()

                            X::Instance() приводит к вызову A::Instance()

                            Как видим, при ленивой инициализации порядок создания объектов так же может выйти из-под контроля и создать не меньшие проблемы, чем с глобальными объектами.
                              +1
                              Порядоком инициализации классических синглтонов хотя бы можно напрямую управлять, в отличии от порядка инициализации глобальных переменных. А круговые зависимости будут не меньшей проблемой и в случае с глобальными переменными.
              0
              По моему должно быть
              static auto& instance()
                +1
                При возврате по ссылке — да, либо decltype(auto). Но тут возвращается указатель.
          +2
          про «правило двух» забыли ;)
            0
            очень люблю работать с shared_ptr на лист из shared_ptr на векторов из… ну вы поняли. У меня есть правило, если объект который я создаю будет использоваться в списках, пиши свой класс хранитель (а в внутрях если приспичит используй shared_ptr)
              +1
              Правило -1: если в классе есть виртуальные методы, делайте виртуальный деструктор. И забудьте про уродливый кастомный делетер.
                0
                А про «public virtual or protected non-virtual destructor» не слышали? Не всегда нужно разрешать удаление объектов через указатель на базовый класс.
                0
                Жду когда примут вот этот proposal N3949 — Scoped Resource — Generic RAII Wrapper for the
                Standard Library
                , упростит написание врапперов для ресурсов которые нужно освобождать
                  0
                  Спасибо! Было интересно. Теперь буду знать как это называется. чтоб не просто писать и объяснять почему так, а говорить «Правило ноля.» :) Подход интересный, практичный. Единственный, как мне кажется, его изъян — это при использовании его в команде, члены команды должны о нем быть осведомлены, чтоб не выкорчевывали со словами: «Это не правильно. Надо так...». И не переписывали уже сами с использованием привил 3х или 5ти, так как они более распространены.
                    0

                    Правильно использованное "правило ноля" не вызывает желания переписать код с использованием других правил.

                    0
                    Почему у вас везде «хэндлер» («обработчик») а не «хэндл» («ручка»)?

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

                    Самое читаемое