Идиома копирования и замены

Последнее обновление: 20.03.2023

Когда нужно изменить состояние одного или нескольких объектов, и на любом этапе модификации может возникнуть ошибка, для создания кода, устойчиваого к ошибкам, может применяться идиома копирования и замены (copy-and-swap idiom). Суть данной идиомы состоит в следующей последовательности действий:

  1. Создаем копию объекта(ов)

  2. Изменяем копию. При этом оригинальные объекты остаются нетронутыми

  3. Если все изменения прошли успешно, заменяем оригинальный объект измененной копией. Если же при изменении копии на каком-то этапе возникла ошибка, то оригинальный объект не заменяется.

Обычно эта идиома применяется в функциях и частным, хотя и распространенным, случаем ее применения является оператор присваивания. В общем случаем это выглядит так:

// оператор присвоения некоторого класса Copyable
Copyable& operator=(const Copyable& obj) {
    Copyable copy{obj};	// создаем копию через конструктор копирования
    swap(copy);			// обмениваем значения копии и оригинального объекта
    return *this;
}
// некоторая функция для обмена значениями
void swap(Copyable& copy) noexcept;

В функции оператора присваивания сначала создается временная копия присваиваемого объекта. И в случае успешного создания копиии текущий объект (this) и копия обмениваются содержимым через некоторую функцию swap().

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

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

// оператор присвоения некоторого класса Copyable
Copyable& operator=(const Copyable& obj) {
    Copyable copy{obj};		// создаем копию через конструктор копирования
	if(this != &obj)		// если не текущий объект
        swap(copy);			// обмениваем значения копии и оригинального объекта
    return *this;
}
// некоторая функция для обмена значениями
void swap(Copyable& copy) noexcept;

Рассмотрим реализацию этого принципа. Но сначала посмотрим, какую проблему мы можем решить с помощью подобной идиомы. Пусть у нас есть следуюший класс:

template <typename T>
class Array
{
public:
    Array(unsigned size) : data{ new T[size] }, size{size} {}  // выделяем память
    ~Array() { delete[] data;}          // освобождаем память 

    // оператор присвоения
    Array<T>& operator=(const Array& array)
    {
        if (&array != this)
        {
            delete[] data;      // освобождаем память
            size = array.size;
            data = new T[size];          // выделяем память - может столкнуться с std::bad_alloc
            // копируем значения
            for (unsigned i{}; i < size; ++i)
                data[i] = array.data[i];  // можем столкнуться с исключением в зависимости от типа T
        }
        return *this;
    }
    // оператор индексирования для доступа к элементам
    T& operator[](unsigned index) { return data[index]; }
    unsigned getSize() const {return size;}
private:
    T* data;         // хранимые данные
    unsigned size;  // размер массива
};

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

Array(unsigned size) : data{ new T[size] }, size{size} {}  // выделяем память
~Array() { delete[] data;}          // освобождаем память 

В функции оператора присваивания нам надо присвоить значения объекта-параметра текущему объекту. Для этого освобождаем ранее выделенную память и выделяем заново память для нового массива. В данном случае кажется, что все нормально, поскольку память удалена. Но посмотрим на выделение памяти:

data = new T[size];

Но надо отметить, что оператор new[] генерирует исключение std::bad_alloc, если по какой-то причине не удалось выделить память. Например, когда надо выделить память под очень большой массив, который не помещается в доступной памяти.

Если оператор new[] не может выделить новую память, указатель data становится так называемым висячим указателем — указателем на освобожденную память. Поэтому даже если мы обработаем исключение bad_alloc, то объект Array будет непригоден для использования. А на этапе вызове деструктора мы столкнемся со сбоем.

Далее в цикле присваиваем значения элементам массива data:

data[i] = array.data[i];

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

Изменим код, применив идиому копирования и замены:

#include <iostream>
 
template <typename T>
class Array
{
public:
    Array(unsigned size) : data{ new T[size] }, size{size} {}  // выделяем память
    ~Array() { delete[] data;}          // освобождаем память 

    Array(const Array& array) : Array{array.size}
    {
        for (unsigned i {}; i < size; ++i)
            data[i] = array.data[i];
    }
    // оператор присвоения
    Array<T>& operator=(const Array& other)
    {
        Array<T> copy{ other };     // вызываем конструктор копирования
        swap(copy);                 // обмениваем значениями
        return *this;
    }
 
    // оператор индексирования для доступа к элементам
    T& operator[](unsigned index) { return data[index]; }
 
    // функция обмена значениями
    void swap(Array& other) noexcept
    {
        std::swap(data, other.data);    // обмениваем два указателя
        std::swap(size, other.size);    // обмениваем размеры
    }
    unsigned getSize() const {return size;}
private:
    T* data;         // хранимые данные
    unsigned size;  // размер массива
};

int main()
{   
    const unsigned count {5};   // количество элементов
    Array<int> values{count};     // создаем объект
 
    // присваиваем элементам values некоторые значения
    for (unsigned i {}; i < count; ++i)
    {
        values[i] = i;
    }
    Array<int> numbers{0};
    numbers = values;     // применение оператора присвоения
    // выводим элементы из объекта numbers на консоль
    for (unsigned i {}; i < numbers.getSize(); ++i)
    {
        std::cout << numbers[i] << "\t";
    }
    std::cout << std::endl;
}

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

Array(const Array& array) : Array{array.size}
{
    for (unsigned i {}; i < size; ++i)
        data[i] = array.data[i];
}

Таким образом, мы получим копию текущего объекта.

Для обмена значениями реализована функция swap:

void swap(Array& other) noexcept
{
    std::swap(data, other.data);    // обмениваем два указателя
    std::swap(size, other.size);    // обмениваем размеры
}

Для упрощения для обмена значениями применяется стандартная функция std::swap из стандартной библиотеки C++, которая обменивает значения двух параметров, используя их оператор копирования. Фактически здесь обмениваем значениями по отдельности элементы динамического массива и размеры массива.

В функции оператора присваивания применяем конструктор копирования и функцию swap:

Array<T>& operator=(const Array& other)
{
    Array<T> copy{ other };     // вызываем конструктор копирования
    swap(copy);                 // обмениваем значениями
    return *this;
}

Далее в функции main мы можем создать объект Array и присвоить его другому объекту:

Array<int> numbers{0};
numbers = values;     // применение оператора присвоения

Консольный вывод:

0       1       2       3       4

Хотя часто подобный способ применяется именно в операторах присвоения, но также он может применяться в других ситуациях, где необходимо выполнить устойчивую к исключениям модификацию объекта. И всегда принцип будет тот же. Сначала копируем объект, который надо изменить. Далее выполняем над объектом-копией изменения. И если все пройдет удачно, обмениваем значениями целевой объект и объект-копию.

Помощь сайту
Юмани:
410011174743222
Перевод на карту
Номер карты:
4048415020898850