Что такое идиома копирования и обмена?

Что это за идиома и когда ее следует использовать? Какие проблемы это решает? Меняется ли идиома при использовании C++11?

Хотя это упоминалось во многих местах, у нас не было ни единого вопроса и ответа "что это такое", так что вот оно. Вот частичный список мест, где это было упомянуто ранее:

5 ответов

Решение

обзор

Зачем нам нужен способ копирования и замены?

Любой класс, который управляет ресурсом (обертка, как умный указатель), должен реализовать Большую тройку. В то время как цели и реализация конструктора и деструктора копирования просты, оператор присвоения копии, пожалуй, самый нюансированный и сложный. Как это должно быть сделано? Какие подводные камни следует избегать?

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

Как это работает?

Концептуально, он работает с использованием функциональности конструктора копирования для создания локальной копии данных, а затем берет скопированные данные с swap функция, заменяя старые данные новыми данными. Затем временная копия разрушается, забирая старые данные. Нам остается копия новых данных.

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

Функция подкачки - это функция без выбрасывания, которая меняет два объекта класса, член на член. Мы могли бы испытать желание использовать std::swap вместо того, чтобы предоставлять свои, но это было бы невозможно; std::swap использует конструктор копирования и оператор копирования-присваивания в своей реализации, и мы в конечном итоге попытаемся определить оператор присваивания в терминах самого себя!

(Не только это, но безоговорочные призывы к swap будет использовать наш собственный оператор подкачки, пропуская ненужные конструкции и разрушения нашего класса, которые std::swap повлечет за собой.)


Подробное объяснение

Цель

Давайте рассмотрим конкретный случай. Мы хотим управлять в другом бесполезном классе динамическим массивом. Мы начнем с рабочего конструктора, конструктора копирования и деструктора:

#include <algorithm> // std::copy
#include <cstddef> // std::size_t

class dumb_array
{
public:
    // (default) constructor
    dumb_array(std::size_t size = 0)
        : mSize(size),
          mArray(mSize ? new int[mSize]() : nullptr)
    {
    }

    // copy-constructor
    dumb_array(const dumb_array& other)
        : mSize(other.mSize),
          mArray(mSize ? new int[mSize] : nullptr),
    {
        // note that this is non-throwing, because of the data
        // types being used; more attention to detail with regards
        // to exceptions must be given in a more general case, however
        std::copy(other.mArray, other.mArray + mSize, mArray);
    }

    // destructor
    ~dumb_array()
    {
        delete [] mArray;
    }

private:
    std::size_t mSize;
    int* mArray;
};

Этот класс почти успешно управляет массивом, но ему нужно operator= работать правильно.

Неудачное решение

Вот как может выглядеть наивная реализация:

// the hard part
dumb_array& operator=(const dumb_array& other)
{
    if (this != &other) // (1)
    {
        // get rid of the old data...
        delete [] mArray; // (2)
        mArray = nullptr; // (2) *(see footnote for rationale)

        // ...and put in the new
        mSize = other.mSize; // (3)
        mArray = mSize ? new int[mSize] : nullptr; // (3)
        std::copy(other.mArray, other.mArray + mSize, mArray); // (3)
    }

    return *this;
}

И мы говорим, что мы закончили; это теперь управляет массивом, без утечек. Тем не менее, он страдает от трех проблем, отмеченных последовательно в коде как (n),

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

  2. Второе - это то, что он предоставляет только базовую гарантию исключения. Если new int[mSize] выходит из строя, *this будет изменено (А именно, размер неправильный, а данные пропали!) Для гарантии сильного исключения это должно быть что-то вроде:

    dumb_array& operator=(const dumb_array& other)
    {
        if (this != &other) // (1)
        {
            // get the new data ready before we replace the old
            std::size_t newSize = other.mSize;
            int* newArray = newSize ? new int[newSize]() : nullptr; // (3)
            std::copy(other.mArray, other.mArray + newSize, newArray); // (3)
    
            // replace the old data (all are non-throwing)
            delete [] mArray;
            mSize = newSize;
            mArray = newArray;
        }
    
        return *this;
    }
    
  3. Код расширился! Что приводит нас к третьей проблеме: дублирование кода. Наш оператор присваивания эффективно дублирует весь код, который мы уже написали в другом месте, и это ужасно.

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

(Можно задаться вопросом: если для правильного управления одним ресурсом требуется столько кода, что если мой класс управляет более чем одним? Хотя это может показаться обоснованным, и на самом деле это требует нетривиального try/catch пункты, это не проблема. Это потому, что класс должен управлять только одним ресурсом!)

Успешное решение

Как уже упоминалось, идиома копирования и обмена исправит все эти проблемы. Но сейчас у нас есть все требования, кроме одного: swap функция. Хотя правило трех успешно влечет за собой существование нашего конструктора копирования, оператора присваивания и деструктора, его действительно следует называть "Большая тройка с половиной": всякий раз, когда ваш класс управляет ресурсом, имеет смысл также предоставить swap функция.

Нам нужно добавить функциональность подкачки в наш класс, и мы делаем это следующим образом †:

class dumb_array
{
public:
    // ...

    friend void swap(dumb_array& first, dumb_array& second) // nothrow
    {
        // enable ADL (not necessary in our case, but good practice)
        using std::swap;

        // by swapping the members of two objects,
        // the two objects are effectively swapped
        swap(first.mSize, second.mSize);
        swap(first.mArray, second.mArray);
    }

    // ...
};

( Вот объяснение, почему public friend swap.) Теперь мы можем не только обменять наши dumb_array, но в целом обмены могут быть более эффективными; он просто меняет указатели и размеры, а не выделяет и копирует целые массивы. Помимо этого бонуса в функциональности и эффективности, мы теперь готовы реализовать идиому копирования и замены.

Без лишних слов наш оператор присваивания:

dumb_array& operator=(dumb_array other) // (1)
{
    swap(*this, other); // (2)

    return *this;
}

И это все! Одним махом все три проблемы решаются одновременно.

Почему это работает?

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

dumb_array& operator=(const dumb_array& other)
{
    dumb_array temp(other);
    swap(*this, temp);

    return *this;
}

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

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

Обратите внимание, что при входе в функцию все новые данные уже распределены, скопированы и готовы к использованию. Это то, что дает нам полную гарантию исключения бесплатно: мы даже не войдем в функцию, если построение копии не удастся, и поэтому невозможно изменить состояние *this, (То, что мы делали раньше вручную для гарантии строгих исключений, сейчас делает для нас компилятор; как мило.)

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

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

И это идиома копирования и обмена.

Что насчет C++11?

Следующая версия C++, C++11, вносит одно очень важное изменение в то, как мы управляем ресурсами: Правило трех теперь является Правилом четырех (с половиной). Зачем? Поскольку мы должны не только иметь возможность копировать-конструировать наш ресурс, мы также должны его перемещать-конструировать.

К счастью для нас, это легко:

class dumb_array
{
public:
    // ...

    // move constructor
    dumb_array(dumb_array&& other)
        : dumb_array() // initialize via default constructor, C++11 only
    {
        swap(*this, other);
    }

    // ...
};

Что тут происходит? Вспомните цель конструкции перемещения: взять ресурсы из другого экземпляра класса, оставив его в состоянии, гарантированно присваиваемом и разрушаемом.

Итак, что мы сделали, это просто: инициализировать с помощью конструктора по умолчанию (функция C++ 11), затем поменять местами с other; мы знаем, что созданный по умолчанию экземпляр нашего класса можно безопасно назначать и уничтожать, поэтому мы знаем, other сможет сделать то же самое, после обмена.

(Обратите внимание, что некоторые компиляторы не поддерживают делегирование конструктора; в этом случае мы должны вручную создать класс по умолчанию. Это неудачная, но, к счастью, тривиальная задача.)

Почему это работает?

Это единственное изменение, которое мы должны внести в наш класс, так почему это работает? Вспомните всегда важное решение, которое мы приняли, чтобы сделать параметр значением, а не ссылкой:

dumb_array& operator=(dumb_array other); // (1)

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

И так завершает идиому копирования и обмена.


Сноски

* Почему мы устанавливаем mArray обнулить? Потому что, если любой дополнительный код в операторе бросает, деструктор dumb_array можно назвать; и если это происходит без установки значения null, мы пытаемся удалить уже удаленную память! Мы избегаем этого, устанавливая его в null, так как удаление null не является операцией.

† Есть и другие претензии, которые мы должны специализировать std::swap для нашего типа, предоставить в классе swap рядом со свободной функцией swapи т. д. Но это все ненужно: любое правильное использование swap будет через неквалифицированный вызов, и наша функция будет найдена через ADL. Одна функция будет делать.

‡ Причина проста: если у вас есть ресурс для себя, вы можете поменять его и / или переместить (C++11) куда угодно. А сделав копию в списке параметров, вы максимально оптимизируете.

Назначение, по сути, состоит из двух шагов: разрушение старого состояния объекта и построение его нового состояния как копии состояния какого-либо другого объекта.

По сути, это то, что делают деструктор и конструктор копирования, поэтому первая идея заключается в том, чтобы передать им работу. Однако, поскольку разрушение не должно заканчиваться неудачей, в то время как строительство может, мы на самом деле хотим сделать это наоборот: сначала выполнить конструктивную часть и, если это удалось, затем выполнить разрушительную часть. Идиома копирования и замены - это способ сделать это: сначала вызывается конструктор копирования класса для создания временного объекта, затем обменивается данными с временным, а затем деструктор временного объекта уничтожает старое состояние.
поскольку swap() предполагается, что никогда не потерпит неудачу, единственная часть, которая может потерпеть неудачу, это копирование. Это выполняется в первую очередь, и в случае неудачи ничего не будет изменено в целевом объекте.

В своей уточненной форме копирование и замена реализованы путем выполнения копирования путем инициализации (не ссылочного) параметра оператора присваивания:

T& operator=(T tmp)
{
    this->swap(tmp);
    return *this;
}

Уже есть несколько хороших ответов. Я сосредоточусь в основном на том, что, как мне кажется, им не хватает - объяснение "минусов" с идиомой копирования и обмена....

Что такое идиома копирования и обмена?

Способ реализации оператора присваивания в терминах функции подкачки:

X& operator=(X rhs)
{
    swap(rhs);
    return *this;
}

Основная идея заключается в том, что:

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

  • это обнаружение может быть предпринято до изменения текущего состояния объекта (т.е. *this) если сделана копия нового значения, вот почему rhs принимается по значению (т.е. копируется), а не по ссылке

  • обмен состояния локальной копии rhs а также *this Обычно это относительно легко сделать без потенциальных сбоев / исключений, поскольку локальная копия впоследствии не нуждается в каком-либо конкретном состоянии (просто требуется соответствие состояния для запуска деструктора, как и для объекта, перемещаемого из>= C++11)

Когда его следует использовать? (Какие проблемы это решает [/ create]?)

  • Если вы хотите, чтобы возражение, на которое он был назначен, не было затронуто назначением, которое выдает исключение, предполагая, что вы имеете или можете написать swap с сильной гарантией исключения, и в идеале тот, который не может провалиться throw..†

  • Если вам нужен простой, понятный и надежный способ определения оператора присваивания в терминах (более простого) конструктора копирования, swap и деструктор функции.

    • Самоназначение, выполняемое как копирование и обмен, позволяет избежать часто пропускаемых крайних случаев.‡

  • Когда какое-либо снижение производительности или кратковременное увеличение использования ресурсов, вызванное наличием дополнительного временного объекта во время назначения, не имеет значения для вашего приложения. ⁂

swap throwing: как правило, можно надежно поменять элементы данных, которые объекты отслеживают по указателю, но элементы без указателя данных, которые не имеют swap без бросков или для которых обмен должен быть реализован как X tmp = lhs; lhs = rhs; rhs = tmp; и создание копии или назначение могут бросить, все еще есть потенциал, чтобы потерпеть неудачу, оставляя некоторые элементы данных замененными, а другие нет. Этот потенциал применим даже к C++03 std::string Джеймс комментирует другой ответ:

@wilhelmtell: В C++03 нет упоминаний об исключениях, которые могут быть вызваны std::string::swap (который вызывается std::swap). В C++0x std:: string:: swap не является исключением и не должен генерировать исключения. - Джеймс МакНеллис 22 декабря 2010 года в 15:24


‡ Реализация оператора присваивания, которая кажется разумной при назначении из отдельного объекта, может легко потерпеть неудачу для самостоятельного назначения. Хотя может показаться невообразимым, что клиентский код даже попытается выполнить самостоятельное назначение, это может произойти относительно легко во время операций algo над контейнерами, с x = f(x); код где f есть (возможно, только для некоторых #ifdef ветки) макрос аля #define f(x) x или функция, возвращающая ссылку на x или даже (вероятно, неэффективный, но лаконичный) код x = c1 ? x * 2 : c2 ? x / 2 : x;). Например:

struct X
{
    T* p_;
    size_t size_;
    X& operator=(const X& rhs)
    {
        delete[] p_;  // OUCH!
        p_ = new T[size_ = rhs.size_];
        std::copy(p_, rhs.p_, rhs.p_ + rhs.size_);
    }
    ...
};

При самостоятельном назначении вышеуказанный код удаляет x.p_;, точки p_ в недавно выделенной области кучи, затем пытается прочитать неинициализированные данные в ней (неопределенное поведение), если это не делает ничего слишком странного, copy попытка самостоятельного назначения каждому только что разрушенному "Т"!


I Идиома копирования и замены может привести к неэффективности или ограничениям из-за использования дополнительного временного параметра (когда параметр оператора создается при копировании):

struct Client
{
    IP_Address ip_address_;
    int socket_;
    X(const X& rhs)
      : ip_address_(rhs.ip_address_), socket_(connect(rhs.ip_address_))
    { }
};

Здесь рукописный Client::operator= может проверить, если *this уже подключен к тому же серверу, что и rhs (возможно, отправка кода "сброса", если это полезно), тогда как подход "копировать и поменять" будет вызывать конструктор копирования, который, вероятно, будет написан для открытия отдельного соединения с сокетом, а затем закроет исходное соединение. Мало того, что это может означать удаленное сетевое взаимодействие вместо простой внутрипроцессной копии переменных, оно может нарушать ограничения клиента или сервера для ресурсов сокетов или соединений. (Конечно, этот класс имеет довольно ужасный интерфейс, но это другое дело;-P).

Этот ответ больше похож на дополнение и небольшую модификацию ответов выше.

В некоторых версиях Visual Studio (и, возможно, в других компиляторах) есть ошибка, которая действительно раздражает и не имеет смысла. Так что если вы объявите / определите свой swap функционировать так:

friend void swap(A& first, A& second) {

    std::swap(first.size, second.size);
    std::swap(first.arr, second.arr);

}

... компилятор будет кричать на вас, когда вы вызываете swap функция:

Это как-то связано с friend вызываемая функция и this Объект передается в качестве параметра.


Способ обойти это не использовать friend ключевое слово и переопределить swap функция:

void swap(A& other) {

    std::swap(size, other.size);
    std::swap(arr, other.arr);

}

На этот раз вы можете просто позвонить swap и пройти в otherчто делает компилятор счастливым:


В конце концов, вам не нужно использовать friend функция, чтобы поменять 2 объекта. Это имеет столько же смысла, чтобы сделать swap функция-член, которая имеет один other объект как параметр.

У вас уже есть доступ к this объект, поэтому передача его в качестве параметра технически избыточна.

Я хотел бы добавить слово предупреждения, когда вы имеете дело с контейнерами, поддерживающими распределитель в стиле C++11. Обмен и назначение имеют слегка различную семантику.

Для конкретности рассмотрим контейнер std::vector<T, A>, где A некоторый тип распределителя с сохранением состояния, и мы сравним следующие функции:

void fs(std::vector<T, A> & a, std::vector<T, A> & b)
{ 
    a.swap(b);
    b.clear(); // not important what you do with b
}

void fm(std::vector<T, A> & a, std::vector<T, A> & b)
{
    a = std::move(b);
}

Назначение обеих функций fs а также fm это дать a государство, которое b было изначально. Тем не менее, есть скрытый вопрос: что произойдет, если a.get_allocator() != b.get_allocator()? Ответ: это зависит. Давайте напишем AT = std::allocator_traits<A>,

  • Если AT::propagate_on_container_move_assignment является std::true_type, затем fm переназначает распределитель a со значением b.get_allocator()в противном случае это не так, и a продолжает использовать свой оригинальный распределитель. В этом случае элементы данных необходимо менять по отдельности, так как хранилище a а также b не совместимо

  • Если AT::propagate_on_container_swap является std::true_type, затем fs меняет местами данные и распределители ожидаемым образом.

  • Если AT::propagate_on_container_swap является std::false_typeтогда нам нужна динамическая проверка.

    • Если a.get_allocator() == b.get_allocator()затем эти два контейнера используют совместимое хранилище, и замена происходит обычным образом.
    • Однако если a.get_allocator() != b.get_allocator()программа имеет неопределенное поведение (см. [container.requirements.general/8].

В результате подкачка стала нетривиальной операцией в C++11, как только ваш контейнер начинает поддерживать распределители с сохранением состояния. Это несколько "продвинутый вариант использования", но он не совсем маловероятен, поскольку оптимизация перемещений обычно становится интересной только тогда, когда ваш класс управляет ресурсом, а память является одним из самых популярных ресурсов.

Другие вопросы по тегам