Что такое идиома копирования и обмена? - PullRequest
1851 голосов
/ 19 июля 2010

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

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

Ответы [ 5 ]

2025 голосов
/ 19 июля 2010

Обзор

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

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

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

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

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

На этом завершается идиома копирования и обмена.


Сноска

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

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

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

250 голосов
/ 19 июля 2010

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

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

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

T& operator=(T tmp)
{
    this->swap(tmp);
    return *this;
}
37 голосов
/ 06 марта 2014

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

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

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

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

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

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

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

  • обмен состояния локальной копии rhs и *this равен обычно относительно легко сделать без потенциальных сбоев / исключений, поскольку локальная копия впоследствии не нуждается в каком-либо конкретном состоянии (просто требуется соответствие состояния для запуска деструктора, как и для объекта, перемещенного из in> = 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


‡ Реализация оператора присваивания, которая кажется разумной при назначении из отдельного объекта, может легко потерпеть неудачу для самостоятельного назначения. Хотя может показаться невообразимым, что клиентский код даже попытается выполнить самоназначение, это может сравнительно легко произойти во время алгоритмических операций над контейнерами, с кодом 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_ на недавно выделенную область кучи, затем пытается прочитать содержащиеся в ней неинициализированные данные (неопределенное поведение), если это не ' чтобы сделать что-то слишком странное, copy пытается самостоятельно назначить каждое только что уничтоженное 'T'!


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).

22 голосов
/ 04 сентября 2013

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

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

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

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

}

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

enter image description here

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


Обходной путь - не использовать friendКлючевое слово и переопределить функцию swap:

void swap(A& other) {

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

}

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

enter image description here


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

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

13 голосов
/ 24 июня 2014

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

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

Добро пожаловать на сайт PullRequest, где вы можете задавать вопросы и получать ответы от других членов сообщества.
...