Что такое Правило четырех (с половиной)?

Для правильной обработки копирования объектов эмпирическим правилом является правило трех. В C++11 важна семантика перемещения, поэтому вместо этого используется Правило пяти. Однако в обсуждениях здесь и в Интернете я также видел ссылки на Правило четырех (и половина), что является комбинацией правила пяти и идиомы копирования и замены.

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

Вот эталонная реализация, которая напоминает мой текущий код. Если это неверно, как будет выглядеть правильная реализация?

//I understand that in this example, I could just use `std::unique_ptr`.
//Just assume it's a more complex resource.
#include <utility>

class Foo {
public:
    //We must have a default constructor so we can swap during copy construction.
    //It need not be useful, but it should be swappable and deconstructable.
    //It can be private, if it's not truly a valid state for the object.
    Foo() : resource(nullptr) {}

    //Normal constructor, acquire resource
    Foo(int value) : resource(new int(value)) {}

    //Copy constructor
    Foo(Foo const& other) {
        //Copy the resource here.
        resource = new int(*other.resource);
    }

    //Move constructor
    //Delegates to default constructor to put us in safe state.
    Foo(Foo&& other) : Foo() {
        swap(other);
    }

    //Assignment
    Foo& operator=(Foo other) {
        swap(other);
        return *this;
    }

    //Destructor
    ~Foo() {
        //Free the resource here.
        //We must handle the default state that can appear from the copy ctor.
        //(The if is not technically needed here. `delete nullptr` is safe.)
        if (resource != nullptr) delete resource;
    }

    //Swap
    void swap(Foo& other) {
        using std::swap;

        //Swap the resource between instances here.
        swap(resource, other.resource);
    }

    //Swap for ADL
    friend void swap(Foo& left, Foo& right) {
        left.swap(right);
    }

private:
    int* resource;
};

person jpfx1342    schedule 18.08.2017    source источник
comment
if в if (resource != nullptr) delete resource; не нужен.   -  person alain    schedule 18.08.2017
comment
@alain Я знал это, но все равно решил включить его, чтобы было ясно, что состояние по умолчанию безопасно для деконструкции. Я отредактировал, чтобы уточнить.   -  person jpfx1342    schedule 18.08.2017
comment
Поскольку вы не должны использовать необработанные указатели-владельцы, редко возникает необходимость в написании собственного деструктора. Откровенно говоря: я больше не верю ни в одно из правил 0, 3, 4, 5, 6. Я стараюсь писать свои классы таким образом, чтобы мне приходилось писать как можно меньше специальных функций-членов.   -  person MikeMB    schedule 18.08.2017
comment
@MikeMB это правило 0   -  person Caleth    schedule 18.08.2017
comment
@MikeMB Я согласен с тобой. Я использую умные указатели и еще много чего. Этот вопрос на самом деле возник, потому что я рефакторил какой-то старый код, и мне действительно требуется некоторое ручное управление ресурсами. Поскольку мне все еще нужно это сделать, я хочу сделать это правильно.   -  person jpfx1342    schedule 18.08.2017
comment
@Caleth: Не совсем так. Я не против написать, например. просто скопируйте назначение и скопируйте конструктор, если это соответствует моим потребностям.   -  person MikeMB    schedule 18.08.2017
comment
@ jpfx1342: Извините, если это было неясно. Мой комментарий был задуман как ответ на вопрос, что такое правило 4 с половиной (иногда нужен дтор -> 5, а иногда нет -> 4). Поскольку это отвечает только на часть вопроса, я не стал отвечать.   -  person MikeMB    schedule 18.08.2017
comment
Как и в случае с большинством игрушечных примеров, трудно сделать какие-либо общие выводы. Например, зачем кому-то хранить int*, а затем хотеть глубокие копии? Наличие бесполезного конструктора по умолчанию кажется не лучшей идеей, особенно если он просто используется в нечетном конструкторе перемещения, который создаст еще один объект внутри std::swap. Не похоже на очевидную оптимизацию скорости, которой должен быть конструктор перемещения. Также оператор присваивания, возможно, вызывающий std::swap, кажется рекурсивным, когда перемещение подкачки назначает объекты...   -  person Bo Persson    schedule 18.08.2017
comment
@BoPersson Int* предназначен для представления более сложного ресурса, такого как файл или дескриптор из какого-либо API. Правило 4 требует действительного состояния по умолчанию, чтобы вы не заменяли несконструированный объект на объект, который вызовет свой деструктор. Я думаю, что конструктор по умолчанию может быть закрытым, и не имеет значения, что он делает, если деструктор может с этим справиться. Я думаю, что переадресация на std::swap в назначении сделает перемещение копией (что делает его более похожим на правило трех), но я не думаю, что это будет повторяться. Но я не уверен! Вот почему я спросил. :)   -  person jpfx1342    schedule 18.08.2017
comment
Из предыдущей статьи: Чтобы реализовать идиому Copy-Swap, ваш класс управления ресурсами также должен реализовать функцию swap() для выполнения обмена между элементами (вот ваше «…(полтора)»)   -  person Jarod42    schedule 18.08.2017


Ответы (3)


Так что же такое Правило четырех (с половиной)?

«Правило большой четверки (с половиной)» гласит, что если вы реализуете одно из

  • Конструктор копирования
  • Оператор присваивания
  • Конструктор перемещения
  • Деструктор
  • Функция подкачки

тогда у вас должна быть политика в отношении других.

Какие функции необходимо реализовать и как должно выглядеть тело каждой функции?

  • конструктор по умолчанию (который может быть закрытым)
  • конструктор копирования (здесь у вас есть реальный код для обработки вашего ресурса)
  • конструктор перемещения (используя конструктор по умолчанию и своп):

    S(S&& s) : S{} { swap(*this, s); }
    
  • оператор присваивания (используя конструктор и своп)

    S& operator=(S s) { swap(*this, s); }
    
  • деструктор (глубокая копия вашего ресурса)

  • обмен друзьями (не имеет реализации по умолчанию: / вы, вероятно, захотите поменять местами каждого члена). Это важно в отличие от метода обмена элементами: std::swap использует конструктор перемещения (или копирования), что привело бы к бесконечной рекурсии.

Какая функция является половиной?

Из предыдущей статьи:

«Чтобы реализовать идиому Copy-Swap, ваш класс управления ресурсами также должен реализовать функцию swap() для выполнения обмена между членами (вот ваше «… (с половиной)»)»

так что метод swap.

Есть ли недостатки или предупреждения для этого подхода по сравнению с правилом пяти?

Предупреждение, которое я уже написал, касается написания правильного свопа, чтобы избежать бесконечной рекурсии.

person Jarod42    schedule 18.08.2017

Есть ли недостатки или предупреждения для этого подхода по сравнению с правилом пяти?

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

template <class T>
void copy_and_swap(T& target, T source) {
    using std::swap;
    swap(target, std::move(source));
}

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

Реальный способ избежать дублирования кода — использовать правило нуля: выбирать переменные-члены так, чтобы вам не нужно было писать какие-либо специальные функции. В реальной жизни я бы сказал, что в 90+% случаев, когда я вижу специальные функции-члены, их можно было бы легко избежать. Даже если в вашем классе действительно есть какая-то особая логика, необходимая для специальной функции-члена, обычно лучше поместить ее вниз в член. Вашему классу регистратора может потребоваться очистить буфер в своем деструкторе, но это не причина для написания деструктора: напишите небольшой класс буфера, который обрабатывает очистку, и сделайте его членом вашего регистратора. У регистраторов потенциально есть все виды других ресурсов, которые могут обрабатываться автоматически, и вы хотите, чтобы компилятор автоматически генерировал код копирования/перемещения/удаления.

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

В тех случаях, когда вы пишете класс для управления ресурсом и должны иметь дело с этим, он обычно должен быть: а) относительно небольшим и б) относительно универсальным/пригодным для повторного использования. Первое означает, что немного дублированного кода не имеет большого значения, а второе означает, что вы, вероятно, не хотите оставлять производительность на столе.

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

person Nir Friedman    schedule 18.08.2017
comment
Можете ли вы указать, что Правило 4.5 медленнее, чем прямое Правило 5? Играя с Godbolt, я вижу, что генерируется немного другой код, но неясно, что он явно хуже. И не все классы по управлению ресурсами маленькие; рассмотрим vector, которому обычно необходимо управлять своими собственными ресурсами. - person Daniel H; 18.07.2018
comment
(По крайней мере, в ситуациях, когда копирование и уничтожение стоит примерно столько же, сколько изменение на месте, что, как я понимаю, не во всех ситуациях. Моим конкретным мотивирующим примером, который привел меня сегодня на эту страницу, были умные указатели, которые дешевы для копирование в любом случае; для чего-то вроде vector это может быть дополнительный цикл выделения/освобождения и дополнительное использование памяти. Это все, что вы имели в виду?) - person Daniel H; 18.07.2018
comment
@DanielH Долгая задержка, но ... Правило 4.5 медленнее, потому что назначение перемещения - это меньшая и более ограниченная операция, чем замена. Обмен с точки зрения 3 ходов в основном оптимален, порядок по модулю, конкретные машинные инструкции и т. Д., Детали очень низкого уровня. Присвоение перемещения с точки зрения свопа явно неоптимально. Даже для чего-то вроде unique_ptr, который представляет собой просто необработанный указатель под капотом. Назначение перемещения — это всего лишь одно назначение (чтение старого + запись нового) и другое задание записи (пустое старое). swap - это 3 назначения (чтение нового + запись временного, чтение старого + запись нового, чтение временного + запись старого). - person Nir Friedman; 22.04.2020
comment
Таким образом, это 2 записи + 1 чтение против 3 записей + 3 чтения. Реальная картина, конечно, сложнее (последнее чтение не имеет реальной стоимости, например, потому что оно уже находится в регистре), и компилятор может помочь вам и оптимизировать ситуацию. Но на него трудно полагаться по разным причинам, на объяснение которых ушло бы больше времени. По сути, в конце концов, правило 4.5 просто требует выполнения дополнительной работы, часть из которой не нужна (но которая дает вам сильную гарантию исключения). - person Nir Friedman; 22.04.2020

Проще говоря, просто запомните это.

Правило 0:

Classes have neither custom destructors, copy/move constructors or copy/move assignment operators.

Правило 3. Если вы реализуете пользовательскую версию любого из них, вы реализуете их все.

Destructor, Copy constructor, copy assignment

Правило 5. Если вы реализуете собственный конструктор перемещения или оператор присваивания перемещения, вам необходимо определить все 5 из них. Требуется для семантики перемещения.

Destructor, Copy constructor, copy assignment, move constructor, move assignment

Правило четырех с половиной: то же, что и правило 5, но с идиомой копирования и замены. С включением метода swap присваивание копирования и присваивание перемещения сливаются в один оператор присваивания.

Destructor, Copy constructor, move constructor, assignment, swap (the half part)

Destructor: ~Class();
Copy constructor: Class(Class &);
Move constructor: Class(Class &&);
Assignment: Class & operator = (Class);
Swap: void swap(Class &);

Предупреждений нет, преимущество в том, что он быстрее присваивается, так как передача по значению копирования на самом деле более эффективна, чем создание временного объекта в теле метода.

И теперь, когда у нас есть этот временный объект, мы просто выполняем замену временного объекта. Он автоматически уничтожается, когда выходит за пределы области видимости, и теперь у нас есть значение с правой стороны оператора в нашем объекте.

Ссылки:

https://www.linkedin.com/learning/c-plus-plus-advanced-topics/rule-of-five?u=67551194 https://en.cppreference.com/w/cpp/language/rule_of_three

person Mohammad Sheraj    schedule 21.06.2021