View Categories

Копирование и перемещение объектов

Рассмотрим, как строятся взаимоотношения между объектами в C++. Простейший класс:

class Foo {
  public:
    int bar = 0;
};

Как и почему работает следующий код?

Foo f1;
Foo f2(f1);
Foo f3 = f2;
Foo f4 = Foo();
Foo f5 = Foo(f4);
f5 = f4;

Когда мы создаём класс, компилятор автоматически добавляет в него некоторые инструменты "по умолчанию", т.е. мы их не писали - но они там есть. Давайте их рассмотрим.

"По умолчанию" #

Конструктор #

Про него было рассказано в уроке про классы. Конструктор по умолчанию (default constructor) просто создаёт объект со значениями по умолчанию, т.е. вызов Foo() "вернёт" новый безымянный объект, а Foo f1 создаст новый объект f1, у которого будет автоматически вызван конструктор по умолчанию.

Конструктор по умолчанию Foo() можно переопределить:

class Foo {
  public:
    Foo() {
        bar = 123;
    }

    int bar = 0;
};

Теперь у Foo f значение f.bar будет равно 123, т.к. при создании вызовется написанный нами код:

Foo f;          // вызван переопределённый нами конструктор по умолчанию
f.bar == 123;   // верно

Деструктор #

Тоже вам знаком - вызывается при уничтожении объекта и тоже существует в классе по умолчанию - ~Foo(), его можно переопределить:

class Foo {
  public:
    ~Foo() {
        // "код"
    }

    int bar = 0;
};
{
    Foo f;
}   // здесь вызовется "код"

Конструктор копирования #

Помимо конструктора по умолчанию создаётся конструктор копирования (copy constructor), он позволяет писать конструкции вида Foo f2(f1) или Foo(f1). Этот конструктор создаёт копию объекта, т.е. буквально копирует значения его переменных:

Foo f1;         // конструктор по умолчанию
f1.bar = 456;

Foo f2(f1);     // конструктор копирования
f2.bar == 456;  // верно

Конструктор копирования Foo(const Foo&) можно переопределить:

class Foo {
  public:
    Foo(const Foo& other) {
        // здесь other - копируемый объект
        // а bar - переменная нашего объекта
        bar = other.bar;
    }

    int bar = 0;
};

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

Инциализация #

Формально, с точки зрения стандарта C++, вызов конструктора копирования происходит также при инициализации объекта:

Foo f1 = Foo();     // создан новый объект и скопирован в f1
Foo f2 = f1;        // f1 скопирован в f2
Foo f3 = Foo(f2);   // создана копия f2 и затем скопирована в f3

На деле компилятор в целях оптимизации часто применяет copy elision или RVO - вызов конструктора при инициализации не происходит, объект просто создаётся "на месте"

Оператор копирования #

При присваивании одного объекта другому он ожидаемо копируется в него при помощи оператора присваивания копированием (copy assignment operator) - как и в случае с конструктором копирования, все члены одного объекта просто копируются в члены другого:

Foo f1, f2;
f1.bar = 123;
f2 = f1;
f2.bar == 123;  // верно

Оператор возвращает ссылку на сам объект - Foo&, т.е. вот такой код будет работать как ожидается:

f1 = f2 = Foo();

Справа налево:

  • Вызов конструктора по умолчанию Foo() создаёт новый объект
  • Он копируется в f2 при помощи оператора присваивания и возвращается f2
  • f2 копируется в f1 при помощи оператора присваивания

Оператор присваивания копированием Foo& operator=(const Foo&) можно переопределить:

class Foo {
  public:
    Foo& operator=(const Foo& other) {
        bar = other.bar;
        return *this;
    }

    int bar = 0;
};

Здесь я также написал то, что было по умолчанию - скопировал переменную.

Неявный вызов конструктора #

Если мы создадим какой-нибудь другой конструктор, то он может быть вызван неявно, исходя из аргумента. Например добавим конструктор, который инициализирует свойство bar указанным значением типа int:

class Foo {
  public:
    Foo() {}
    Foo(int v) : bar(v) {}

    int bar = 0;
};

Теперь будут работать следующие конструкции:

Foo f1(123);        // обычный вызов конструктора Foo(int)
Foo f2 = Foo(123);  // создан объект и скопирован в f2
Foo f3 = 123;       // из 123 будет создан объект Foo(123) и скопирован (copy constructor) в f3
f3 = 123;           // из 123 будет создан объект Foo(123) и скопирован (copy assignment) в f3

// bar везде равно 123

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

rvalue #

В C++11 (справедливо для AVR Arduino, ESP8266, ESP32) появилось понятие rvalue (right value) - это значение, которое не имеет имени, адреса в памяти и не может быть ссылкой, это временное значение, которое находится "справа" от оператора присваивания = и будет выгружено из памяти после выполнения операции присваивания (если это объект - будет вызван его деструктор):

int x = 123;    // 123 - rvalue
Foo f = Foo();  // формально Foo() создаёт rvalue-объект

Также rvalue будет являться результат выполнения функции:

Foo getFoo() {
    Foo f;
    return f;
}

f = getFoo();   // getFoo() возвращает временный rvalue объект

Для работы с rvalue есть отдельный синтаксис - такой объект передаётся по &&, т.е. это как "двойная ссылка". Если существует пара перегруженных функций, которые принимают объект по Foo& и Foo&&, то при передаче rvalue объекта будет вызвана версия с &&:

void func(const Foo& val) {}    // #1
void func(Foo&& rval) {}        // #2

Foo foo;
func(foo);      // вызов #1
func(Foo());    // вызов #2

Обратите внимание: ссылка указывается как const (функция не может изменить объект по ней), а для rvalue - просто &&, неконстантно - функция сможет менять объект

Объект можно принудительно "кастовать в rvalue" (Foo&&) для вызова соответствующей функции. На сам объект каст никак не повлияет, он не уничтожится и никуда не денется - это просто указание компилятору, какую функцию вызвать:

Foo foo;
func(foo);          // вызов #1
func((Foo&&)foo);   // вызов #2

Конструктор перемещения #

Здесь всё просто - это как конструктор копирования, только принимает rval: Foo(Foo&&). По умолчанию ведёт себя точно так же, как конструктор копирования: никого никуда не перемещает, а точно так же копирует в текущий объект. Но его поведение тоже можно переопределить:

class Foo {
  public:
    Foo(Foo&& other) noexcept {
        bar = other.bar;
    }

    int bar = 0;
};

У конструкторов и операторов с rvalue указывается спецификатор noexcept, чтобы компилятор мог оптимизировать перемещение сущностей, которые умеют перемещаться (например стандартных контейнеров из стандартной библиотеки STL). Даже если в классе не используются подобные контейнеры - лучше всё равно указывать noexcept.

Оператор перемещения #

По умолчанию - то же самое, что оператор присваивания, но для rvalue - Foo& operator=(Foo&&), по умолчанию копирует объект. Можно переопределить:

class Foo {
  public:
    Foo& operator=(Foo& other) noexcept {
        bar = other.bar;
        return *this;
    }

    int bar = 0;
};

Точно так же указываем noexcept.

Всё вместе #

Итого у класса есть целый набор стандартных инструментов для базового взаимодействия между своими объектами:

class Foo {
  public:
    Foo() {}    // default constructor
    ~Foo() {}   // destructor

    Foo(const Foo& other) {}            // copy constructor
    Foo(Foo&& rval) noexcept {}         // move constructor

    Foo& operator=(const Foo& other) {}     // copy assignment
    Foo& operator=(Foo&& rval) noexcept {}  // move assignment
};

Переопределение #

  • При добавлении нового конструктора мы теряем конструктор по умолчанию, если он нужен - его нужно объявить явно
  • При переопределении нужно быть очень внимательным и не пропустить члены, которые нужно скопировать или переместить - они останутся нетронутыми, т.е. переопределяя оператор или конструктор, мы полностью теряем его "стандартное" поведение
class Foo {
  public:
    Foo() {}    // по умолчанию, создан явно

    Foo(const Foo& other) {
        a = other.a;
        // забыли про b
    }

    int a = 0;
    int b = 0;
};

Foo f1;     // по умолчанию
f1.a = 123;
f1.b = 456;

Foo f2(f1);
//f2.a == 123
//f2.b == 0 !

Правило 0 #

Вы не обязаны создавать конструкторы и операторы копирования и перемещения - они уже существуют по умолчанию.

Правило 3 и 5 #

Правило трёх (Rule of Three) - если в классе вам нужно переопределить деструктор, конструктор копирования или оператор копирования присваиванием, то скорее всего нужно будет переопределить все 3 из перечисленных, чтобы программа работала корректно во всех случаях.

Правило пяти (Rule of Five) - то же самое, но включает в себя дополнительно конструктор и оператор перемещения, т.е. для C++11 и выше.

Управление данными #

Зачем же нужен этот набор операторов и конструкторов с возможностью переопределения? Они могут применяться в разных сценариях взаимодействия объектов, но самый частый - если объект динамически выделяет память - в этом случае без переопределения операторов в принципе не получится написать корректно работающий класс!

Рассмотрим самый простой и наглядный пример - класс динамического массива типа int. Нам понадобится как минимум:

  • Переменная-указатель на int для записи адреса выделенной памяти: int* _buf
  • Конструктор с указанием длины массива - в нём выделим память и запишем в указатель: _buf = new int[len]
  • Деструктор - в нём освободим выделенную память: delete[] _buf
  • Для удобства и тестов:
    • Переменная для хранения длины массива в количестве ячеек: size_t _len
    • Метод получения текущей длины: return _len
    • Оператор для доступа к объекту как к указателю и через []: return _buf
class Array {
   public:
    Array(int len) {
        _buf = new int[len];
        if (_buf) _len = len;   // если успешно выделено. Иначе останется 0
    }
    ~Array() {
        delete[] _buf;
    }

    operator int*() {
        return _buf;
    }

    size_t length() {
        return _len;
    }

   private:
    int* _buf = nullptr;
    size_t _len = 0;
};

Всё работает, можно пользоваться...

Array arr(10);  // 10 ячеек
arr[0] = 123;
arr[1] = 456;

arr[0];         // == 123
arr[1];         // == 456
arr.length();   // == 10

... ровно до тех пор, пока не начнётся взаимодействие между объектами. Рассмотрим вот такую ситуацию:

Array arr1(10);
Array arr2(20);

arr1 = arr2;    // <- здесь

Сработает оператор присваивания по умолчанию и просто скопирует значения переменных из arr2 в arr1. Это означает, что:

  • Указатель arr1._buf теперь равен arr2._buf и указывает на те же данные в памяти, то есть оба объекта теперь управляют одним и тем же массивом
  • Старое значение arr1._buf утеряно, а значит освободить память теперь невозможно - появилась утечка памяти
  • Когда объекты выйдут из своей области существования, то сначала удалится arr2, его деструктор освободит память. Затем удалится arr1 и его деструктор будет освобождать память по указателю, который уже был освобождён (тот же адрес, что у второго объекта) - программа сломается

Дальше - больше:

arr1 = Array(10);

Здесь будет создан новый временный объект массива и присвоен (скопирован) в arr1:

  • Адрес предыдущего выделенного блока arr1 будет утерян - утечка памяти
  • После выполнения операции временный массив Array(10) будет удалён, т.е. его деструктор освободит выделенную память. В arr1 останется указатель на эту память, т.е. запись и чтение будет происходить уже вне выделенной памяти - программа сломается
  • При удалении arr1 будет освобождена память по указателю, который уже не указывает на выделенную память - программа сломается

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

Всё вручную #

Копирование #

Первым делом добавим конструктор копирования, который выделит необходимую память и скопирует в неё данные из второго объекта. Он может выглядеть так:

Array(const Array& other) : Array(other._len) {     // #1
    memcpy(_buf, other._buf, _len * sizeof(int));   // #2
}
  1. Вызываем конструктор с длиной второго массива other._len, чтобы наш объект выделил память и запомнил длину
  2. Копируем память себе в _buf из other._buf размером _len * sizeof(int) байт. Если на предыдущем шаге память выделить не удалось - размер будет ноль, данные не скопируются - безопасно

Далее - оператор копирования:

Array& operator=(const Array& other) {
    if (this == &other) return *this;               // #1
    delete[] _buf;                                  // #2
    _buf = new int[other._len];                     // #3
    _len = _buf ? other._len : 0;                   // #4
    memcpy(_buf, other._buf, _len * sizeof(int));   // #5
    return *this;
}
  1. Если это наш объект (операция arr = arr) - выходим
  2. Освобождаем свою память
  3. Выделяем новую размером как у второго массива
  4. Если выделено успешно - записываем новую длину, иначе длина 0
  5. Копируем данные. Если память выделить не удалось - копирования не будет, безопасно

Перемещение #

Что касается перемещения: если объект является rvalue, значит он будет удалён после этой операции, значит его данные ему "не нужны" - их можно не копировать (что занимает время), а переместить - записать себе значение его указателя, а у rvalue-объекта этот указатель обнулить в nullptr, чтобы он не освободил его в своём деструкторе. Про длину тоже не забываем:

Array(Array&& other) noexcept {
    _len = other._len;
    other._len = 0;
    _buf = other._buf;
    other._buf = nullptr;
}

Array& operator=(Array&& other) noexcept {
    if (this == &other) return *this;   // такая же защита
    delete[] _buf;                      // освобождаём свой буфер
    _len = other._len;
    other._len = 0;
    _buf = other._buf;
    other._buf = nullptr;
    return *this;
}

Ну вот, теперь наш класс полностью укомплектован и умеет копироваться и перемещаться, теперь ему не страшны никакие операции между объектами!

Array arr1(10);
Array arr2 = Array(20);
// arr1.length() == 10, arr2.length() == 20

arr1 = arr2;            // копирование
// arr1.length() == arr2.length() == 20

arr1 = (Array&&)arr2;   // имитируем перемещение
// arr1.length() == 10, arr2.length() == 0
// тут arr2 по сути уже удалён
class Array
class Array {
   public:
    Array(int len) {
        _buf = new int[len];
        if (_buf) _len = len;
    }
    ~Array() {
        delete[] _buf;
    }

    Array(const Array& other) : Array(other._len) {
        memcpy(_buf, other._buf, _len * sizeof(int));
    }

    Array& operator=(const Array& other) {
        if (this == &other) return *this;
        delete[] _buf;
        _buf = new int[other._len];
        _len = _buf ? other._len : 0;
        memcpy(_buf, other._buf, _len * sizeof(int));
        return *this;
    }

    Array(Array&& other) noexcept {
        _len = other._len;
        other._len = 0;
        _buf = other._buf;
        other._buf = nullptr;
    }

    Array& operator=(Array&& other) noexcept {
        delete[] _buf;
        _len = other._len;
        other._len = 0;
        _buf = other._buf;
        other._buf = nullptr;
        return *this;
    }

    operator int*() {
        return _buf;
    }

    size_t length() {
        return _len;
    }

   private:
    int* _buf = nullptr;
    size_t _len = 0;
};

Всё хорошо, но можно заметить некоторое дублирование кода и лишние конструкции - можно оптимизировать.

Идиома copy and swap #

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

// поменять значения переменных текущего объекта с другим
void swap(Array& other) {
    size_t len_temp = other._len;
    other._len = _len;
    _len = len_temp;

    int* buf_temp = other._buf;
    other._buf = _buf;
    _buf = buf_temp;
}

И вот наши конструкторы:

Array(const Array& other) : Array(other._len) {
    memcpy(_buf, other._buf, _len * sizeof(int));
}

Array(Array&& other) noexcept {
    swap(other);
}

Конструктор перемещения теперь работает так: нам дают rvalue и мы меняемся с ним местами, теперь у нас указатель на его выделенные данные, а у него - nullptr, который он проигнорирует при удалении в своём деструкторе.

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

Array& operator=(Array other) {
    swap(other);
    return *this;
}

Здесь и происходит "copy and swap": оператор принимает объект по значению, то есть будет вызван конструктор - копирования или перемещения, с этим компилятор разберётся сам. При копировании получим копию, при перемещении - по сути тоже копию, отобранную у rvalue. А дальше swap - меняемся с ней местами. Если у нашего объекта была выделена память - она освободится в деструкторе other, и всё!

Рассмотрим поэтапно работу следующей строки:

arr = Array(10);

Компилятор может оптимизировать некоторые этапы, но формально происходит следующее:

  1. Код Array(10) создаст новый объект через конструктор с размером. Это временный безымянный объект, условно назовём его temp
  2. Попадаем в оператор =(Array other), здесь other создаётся через конструктор Array other(temp), в данном случае - перемещения, т.к. temp является rvalue: temp перемещается в other
  3. Теперь меняемся местами с other - получаем новый пустой массив, а other получает что было у нас и освобождает в своём деструкторе
class Array
class Array {
   public:
    Array(int len) {
        _buf = new int[len];
        if (_buf) _len = len;
    }
    ~Array() {
        delete[] _buf;
    }

    Array(const Array& other) : Array(other._len) {
        memcpy(_buf, other._buf, _len * sizeof(int));
    }

    Array(Array&& other) noexcept {
        swap(other);
    }

    Array& operator=(Array other) {
        swap(other);
        return *this;
    }

    void swap(Array& other) {
        size_t len_temp = other._len;
        other._len = _len;
        _len = len_temp;

        int* buf_temp = other._buf;
        other._buf = _buf;
        _buf = buf_temp;
    }

    operator int*() {
        return _buf;
    }

    size_t length() {
        return _len;
    }

   private:
    int* _buf = nullptr;
    size_t _len = 0;
};

Вложенные объекты и наследование #

Итак, у нас есть умный объект, который умеет распоряжаться своими данными. Что будет, если вложить его в другой класс?

class Foo {
   public:
    Foo(int s1, int s2) : arr1(s1), arr2(s2) {}

    Array arr1, arr2;
};

Всё будет работать как ожидается - объекты класса Foo могут присваиваться друг к другу и создаваться в конструкторе, а вложенные в них экземпляры Array будут вести себя подобающе - если Foo копируется, то и они копируются, если Foo перемещается, то и члены Array становятся Array&&.

При наследовании тоже всё ожидаемо и происходит автоматически: если Foo копируется, то входящий в его состав Array - тоже.

class Foo : public Array {
};

Я запрещаю вам copy-ать #

Операторы кстати можно "удалять" - указывать компилятору, что их нельзя вызвать. Например, запретить копировать объект - можно будет только перемещать:

class Foo {
   public:
    Foo() {}
    ~Foo() {}

    Foo(const Foo& other) = delete;
    Foo(Foo&& rval) noexcept {}

    Foo& operator=(const Foo& other) = delete;
    Foo& operator=(Foo&& rval) noexcept {}
};
Foo f1;
Foo f2(f1);     // ошибка
f1 = f2;        // ошибка

Готовые инструменты #

В стандартной библиотеке C++ std есть готовые инструменты (динамические массивы и прочие похожие), которые устроены внутри подобным образом, при их наличии стоит отдавать предпочтение им (например в компиляторе AVR Arduino, avr-gcc, их нет). Но даже при их использовании нужно понимать, как они работают и что происходит в программе на уровне синтаксиса языка. Например std::move(obj) позволяет "переместить" объект вместо копирования: foo1 = std::move(foo2), а на самом деле просто кастует foo2 к rvalue, как мы делали выше, т.е. условно foo1 = (Foo&&)foo2.

Полезные страницы #

0 0 голоса
Рейтинг статьи
Подписаться
Уведомить о
guest

0 комментариев
Старые
Новые Популярные
Межтекстовые Отзывы
Посмотреть все комментарии
Прокрутить вверх