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

1632

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

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

  • 5
    gotw.ca/gotw/059.htm от Херба Саттера
  • 1
    Круто, я связал этот вопрос из своего ответа, чтобы переместить семантику .
Показать ещё 4 комментария
Теги:
copy-and-swap
copy-constructor
assignment-operator
c++-faq

5 ответов

1882
Лучший ответ

Обзор

Зачем нам нужна идиома copy-and-swap?

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

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

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

Концептуально, он работает с использованием функциональности copy-constructor для создания локальной копии данных, затем берет скопированные данные с помощью функции 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).

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

  • Во-вторых, он предоставляет только базовую гарантию исключения. Если 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;
    }
    
  • Код расширился! Это приводит нас к третьей проблеме: дублирование кода. Наш оператор назначения эффективно дублирует весь код, который мы уже писали в другом месте, и это ужасная вещь.

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

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

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

Как уже упоминалось, идиома "копирование и своп" исправит все эти проблемы. Но прямо сейчас у нас есть все требования, кроме одного: a 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;
}

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

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

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

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

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

И это идиома копирования и свопинга.

Как насчет С++ 11?

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

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

class dumb_array
{
public:
    // ...

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

    // ...
};

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

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

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

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

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

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

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

И так заканчивается идиома копирования и свопинга.


Сноски

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

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

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

  • 0
    @GMan: пользовательская перегрузка свопа все еще потенциально быстрее, потому что семантика перемещения должна устанавливать нулевые указатели источника после их копирования.
  • 1
    @Fred: оптимизирующий компилятор может легко увидеть, что такое назначение расточительно. Я демонстрирую, что в своем ответе я ссылался на пост. Возможно, у вас есть точка зрения, что без таких вещей swap может быть еще быстрее. Я бы не посчитал, что это того стоит.
Показать ещё 91 комментарий
234

Назначение в самом сердце состоит из двух шагов: срывать старое состояние объекта и , создавая его новое состояние как копию другого состояния объекта.

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

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

T& operator=(T tmp)
{
    this->swap(tmp);
    return *this;
}
  • 0
    Я думаю, что упоминание прыща так же важно, как упоминание копии, обмена и уничтожения. Обмен не магически исключительный исключительный. Он безопасен для исключений, потому что замена указателей безопасна для исключений. Вам не нужно использовать pimpl, но если вы этого не сделаете, вы должны убедиться, что каждый обмен члена безопасен от исключений. Это может быть кошмар, когда эти члены могут меняться, и это тривиально, когда они прячутся за прыщами. И тогда, тогда прибывает стоимость прыща. Что приводит нас к выводу, что зачастую исключительная безопасность несет затраты в производительности.
  • 0
    ... вы можете написать распределители для класса, которые будут поддерживать амортизацию стоимости прыща. Это добавляет сложности, что сказывается на простоте идиомы копирования и замены. Это выбор.
Показать ещё 17 комментариев
34

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

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

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

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

Основная идея состоит в том, что:

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

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

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

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

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

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

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

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

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

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


‡ реализация оператора присваивания, которая кажется разумной при назначении из отдельного объекта, может легко сбой для самоопределения. Хотя может показаться невообразимым, что клиентский код даже попытается выполнить самоопределение, это может произойти относительно легко во время операций с альго в контейнерах с кодом x = f(x);, где f (возможно, только для некоторых ветвей #ifdef) макрос ala #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_ на вновь выделенную область кучи, затем пытается прочитать неинициализированные данные в нем (Undefined Поведение), если это ничего не делает странно, copy пытается выполнить самоназвание для каждого только что разрушенного "T"!


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

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 (возможно, посылка кода "reset", если это полезно), тогда как копия и смена подход будет ссылаться на экземпляр-конструктор, который, скорее всего, будет написан для открытия отдельного соединения сокетов, а затем закроет исходный. Это может означать не только дистанционное сетевое взаимодействие, но и простое копирование переменной процесса в процессе работы, оно может противоречить ограничениям клиента или сервера на ресурсах или соединениях сокетов. (Конечно, этот класс имеет довольно ужасный интерфейс, но это другое дело; -P).

  • 2
    Тем не менее, сокетное соединение было только примером - тот же принцип применим к любой потенциально дорогостоящей инициализации, такой как аппаратное зондирование / инициализация / калибровка, генерация пула потоков или случайных чисел, определенных задач криптографии, кэшей, проверок файловой системы, базы данных соединения и т. д.
  • 0
    Есть еще один (массивный) минус. Что касается текущих спецификаций, технически объект не будет иметь оператора присваивания! Если позже использовать его как член класса, новый класс не будет автоматически сгенерирован как Move-Ctor! Источник: youtu.be/mYrbivnruYw?t=43m14s
Показать ещё 1 комментарий
21

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

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

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

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

}

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

Изображение 1171

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


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

void swap(A& other) {

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

}

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

Изображение 1172


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

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

  • 1
    Можете ли вы поделиться своим примером, который воспроизводит ошибку?
  • 1
    @GManNickG dropbox.com/s/o1mitwcpxmawcot/example.cpp dropbox.com/s/jrjrn5dh1zez5vy/Untitled.jpg . Это упрощенная версия. Кажется, что ошибка возникает каждый раз, когда функция friend вызывается с *this параметром
Показать ещё 8 комментариев
10

Я хотел бы добавить слово предупреждения, когда вы имеете дело с контейнерами, поддерживающими контейнеры С++ 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(), программа имеет поведение undefined (см. [container.requirements.general/8].

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

Ещё вопросы

Сообщество Overcoder
Наверх
Меню