Move семантика

Семантика перемещения появилась с выходом стандарта C++11, чтобы дополнить RVO в C++98; Её также можно считать оптимизацией на подобии RVO, которую определяет пользователь (создавая конструкторы перемещения и перемещающий оператор копирования).

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

Причина появления в стандарте

Как мы видели ранее, RVO применяется не всегда. Когда компилятор не cмог применить RVO, то при возвращении объекта из функции происходило лишнее копирование.

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

#include <iostream>
#include <cstdlib>
#include <string>

// Перегружаем операторы new и delete для вывода информации
void* operator new(std::size_t n) {
    std::cout << "[Аллоцировано " << n << " байт]\n";
    return std::malloc(n);
}
void operator delete(void* p) { std::free(p); }

std::string getLongString() {
    return "Эта строка настолько длинная, что не может быть встроенной (SSO)";
}

int main() {
    getLongString();
}
  1. C++98 с применением RVO: одна копия!
    g++ -std=c++98 main.cpp && ./a.out
    [Аллоцировано 114 байт]
  2. C++98 без применения RVO: 2 копии – разница заметна.
    g++ -std=c++98 -fno-elide-constructors main.cpp && ./a.out
    [Аллоцировано 114 байт]
    [Аллоцировано 114 байт]
    
  3. C++11 c применением RVO: одна копия – ничего нового.
    g++ -std=c++11 main.cpp && ./a.out
    [Аллоцировано 114 байт]
    
  4. C++11 без применения RVO: одна копия — это что-то новое!
    g++ -std=c++11 main.cpp && ./a.out
    [Аллоцировано 114 байт]
    

Используя семантику перемещения (которая есть у std::string), мы можем избежать выделения данных, даже когда RVO отключен.

Немного информации о SSO…

SSO — Short/Small String Optimization. Эта оптимизация добавляет в класс std::string небольшой массив, который позволяет хранить маленькие строки без вызова дорогостоящего оператора new.
Вот пример исходного кода строки libc++ (искать по макросу _LIBCPP_ABI_ALTERNATE_STRING_LAYOUT)

Move Constructor / Move Assignment

Чаще всего семантика перемещения используется для создания специального конструктора, называемого конструктором перемещения . Конструкторы перемещения похожи на конструкторы копирования как синтаксически, так и логически. Они могут быть реализованы в дополнение или вместо конструктора копирования. Аналогичным образом можно реализовать оператор присваивания перемещением.

class MyClass {
public:
    MyClass();                            // Default constructor

    MyClass(const MyCass& o);             // Copy constructor
    MyClass(MyClass&& o);                 // Move constructor

    MyClass& operator=(const MyClass& o); // Copy assingment
    MyClass& operator=(MyClass&& o);      // Move assignment
};

Как вы видели выше, MyClass&& это синтаксис для специальной ссылки на объект MyClass — т.е. это ссылка на rvalue.

Конструктор перемещения / перемещающий оператор присваивания будут автоматически вызываться компилятором, только если переданный им параметр (o в примере выше) является rvalue. В противном случае компилятор вызовет безопасный, но медленный конструктор / оператор присваивания.

Давайте предположим, что перед нами стоит задача реализовать оператор присваивания для std::string (и в этом классе 3 поля — _data, _size, _capacity). Так же мы знаем, что передаваемый параметр больше не будет использоваться. Обладая этими знаниями, мы можем реализовать оператор присваивания очень оптимизированным способом:

std::string& operator=(str::string&& o) { // 'o' - временный объект
    // Забираем данные из временного объекта
    _data = o._data; // _data - char*
    _size = o._size; // _size - size_t
    _capacity = o._capacity; // _capacity - size_t

    // Убираем данные у временного объекта - они ему больше не нужны
    o._data = nullptr;
    // Так же можно сделать o._size = o._capacity = 0;

    return *this;
}

В примере выше нет выделения памяти, нет копирования — т.е. сложность операции О(1). Это намного лучше, чем обычное копирование!

std::string&& — это специальный синтаксис, использующийся в семантике перемещения. Кроме того, эта операция присваивания является не копированием, а перемещением, т.к. у нас в руках особая ссылка — ссылка на временный объект.

Какие объекты являются временными?

Возможно, вы видели термин rhs (right hand side) или rvalue (rigth value) в некоторых ошибках компилятора. Например, при попытке скомпилировать такой код:

int foo() { return 42; }

// ...
foo() = 5;  // Error: expression is not assignable

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

Это первое правило при определении является ли объект временным или нет: можно ли использовать его в левой части уравнения при присваивании. Это именно то, что мы попытались сделать выше foo() = 5;. Если это сделать нельзя, то объект временный.

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

int foo() { return 42; }

// ...
int i = foo();
int* p = &i;  // OK: `i` является lvalue

p = &foo();  // Error: cannot take the address of an rvalue of type 'int'

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

auto t = &foo;    // OK - получили адрес функции foo
auto v = &foo();  // Error - не получилось взять адрес возвращаемого значения из функции, т.к. возвращаемое значения является rvalue

std::move()

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

void foo(std::string&& s) { /* ... */ }

foo("hello");  // Ok - временные объекты всегда rvalue

// Возвращаемые значения тоже rvalue
// За исключением случаев, когда возвращаем ссылку на объект
foo(getLongString()); // Ok

std::string s;
foo(s); // Compile error - `s` не rvalue

Однако есть случаи, когда мы хотим преобразовать объект в rvalue тип (когда мы знаем, что объект больше не будет использоваться). Для этого создали std::move():

// std::move() превращает объект в rvalue
foo(std::move(s)); // Ok

Правило 5

Если вы когда-нибудь слышали о правиле трех, то с семантикой перемещения у нас появляется дополнительное правило — правило 5.

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

  • Деструктор
  • Конструктор копирования
  • Оператор присваивания копированием
  • Конструктор перемещения
  • Оператор присваивания перемещением

Move семантика: 2 комментария

  1. Недавно добавили предложение для гарантирования copy elision
    Её внедрение позволит создавать функции фабрики для объектов без copy и move конструкторов. Будет круто, если примут

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *