Вы находитесь на странице: 1из 39

Категории выражений и move семантика

Всем привет! На связи Грокаем C++. Этот гайд посвящён категориям


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

Содержание:

1. Категории выражений: lvalue и rvalue


2. CV-специфицированные значения
3. Категории выражений: xvalue
4. Универсальные ссылки
5. Идеальная передача
6. Исключения в перемещающем конструкторе
7. Оптимизации RVO / NRVO
8. Пропуск конструкторов копирования и перемещения

Так же оставляю ссылку на оригинальный пост.


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

int a, b;

a = 3; // Корректно
b = a; // Корректно
3 = b; // Ошибка

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

Такая классификация действительно возможна и она называется категориями


выражений. Итак, встречайте:

lvalue

Так называются те выражения, которыМ задают значение. Зачастую они


располагаются слева от знака равенства, поэтому и получили такое название
left-hand value.

lvalue
a = 3;

Представители этой категории расположены на стеке или куче, к которым


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

rvalue

К этой категории относятся выражения, которыЕ задают значения. Обычно они


расположены справа от знака равенства - отсюда название right-hand value.
rvalue
a = b;

С представителями этой категории необходимо работать независимо от


возможности доступа к стеку или куче; иметь возможность читать эти значения,
без права изменять их как-либо.

prvalue

К этой категории относятся выражения, которые только задают значения. К


такой категории относятся constexpr , литералы и т.д. Например:

prvalue
a = 3;

Они являются подмножеством rvalue , и в дальнейшем мы не будем делать на


этом акцент.

xvalue

К этой категории относятся временные выражения, которые будут в скором


времени уничтожены (eXpiring value). В некоторых случаях, их ресурсы могут
быть эффективно переиспользованы. Пока оставлю вас без примера

Думаю, не будет ошибкой заявить, что отношение к какой-либо категории


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

При разборе этой темы так же рекомендую размышлять не только в рамках


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

Мне так же следует сделать акцент на том, что категория выражений


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

Так, например, мы знаем, что нет никаких ограничений, чтобы скопировать


переменную a в b . Значит, переменная a может быть преобразована к rvalue :

lvalue rvalue
a = 3;

lvalue lvalue -> rvalue


b = a;

Действительно, lvalue может быть неявно приведено к rvalue , но не


наоборот! Так, например, численная константа 3 независимо от контекста
всегда будет rvalue , т.к. её значение нельзя поменять ни при каких
обстоятельствах. Если это правило нарушается, компилятор вполне
заслуженно бьет по рукам.

Рассмотрим другой пример:

rvalue rvalue
(a + b) = a // Ошибка!

Хоть сумма a + b и может быть образована из двух lvalue , но оператор +


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

С другой стороны, это не означает, что от rvalue нельзя никак перейти к


lvalue :

lvalue rvalue
*(pointer + 1) = a // Ок
Однако, это ни разу не преобразование! Мы знаем, что pointer указывает на
область памяти, в которой лежит какой-то объект. К нему мы имеем доступ и
можем изменять. Поэтому оператор разыменования указателя возвращает
lvalue .

Есть более приятная глазу конструкция — обращение к элементу массива:

lvalue rvalue
pointer[1] = a // Аналогично, ок

Ведь это как раз и разворачивается в разыменование указателя со смещением.


Вот вам ещё приколямба:

array[3] = 4; // Ок
3[array] = 5; // Ок

Опять же, никакой магии По сути это влияет лишь на порядок "слагаемых":

*(array + 3) = 4;
*(3 + array) = 5;

Живой пример: https://compiler-explorer.com/z/PWr3oEaY5 . Априори, это не


работает с std::vector и прочими контейнерами. В их случае квадратные
скобки — это перегруженный оператор класса.

Так же давайте еще поставим точки над i в отношении возвращаемых значений.


Функции возвращают rvalue по умолчанию:

int foo() { return some_value; }

value = foo();
~~^~~ ~~^~~
lvalue rvalue

В случае со ссылками, возвращаемый адрес, по которому лежит значение,


так же дополнительно разыменовывается:
int& foo() { return some_value; }

foo() = 42;
~~^~~ ^~~~~
lvalue rvalue

*(&some_value) = 42;
~~~~~~~^~~~~~~ ^~~
rvalue -> lvalue rvalue

Аналогично, для возвращаемых значений с неизменным типом:

cpp
int& foo() { return some_value; }

value = foo();
~~^~~ ~~^~~
lvalue rvalue

value = *(&some_value);
~~^~~ ~~~~~~~^~~~~~~~
lvalue rvalue -> lvalue -> rvalue

Что еще можно вытащить из знания rvalue и lvalue ? В свое время был
популярен Yoda-style при сравнении переменных. Смысл его в том, чтобы
rvalue и константы всегда находились слева от оператора == . Так, в случае
опечатки, программист мог написать оператор присвоения = , но компилятор
сообщил бы ошибку. Не скажу, что сейчас это популярная практика, есть все
таки предупреждения компилятора.

Итак, мы познакомились с lvalue и rvalue . Компиляторы нередко прибегают к


этой терминологии для объяснения ошибок. Надеюсь, теперь эти сообщения
стали для вас чуть более понятны. Остается разобраться, что же такое xvalue ?
Поговорим об этом, а так же про cv-специфицированные значения,
универсальные ссылки и т.д. далее.
Вопрос: не совсем понятно, почему в выражении вида a = b, b считается rvalue

Ответ: В процессорах, которые не имеют инструкции [mem] -> [mem] , т.е.


работают через регистры, процедура a = b выполняется 2 этапа:

1. [mem (адрес b)] -> registr


2. registr -> [mem (адрес a)]
Именно b - это lvalue и a - lvalue , а та штука, которая является
зеркалом b и содержится в регистре, является rvalue .
Грубо говоря, получается преобразование lvalue (по адресу b ) в rvalue
(которое содержится в регистре).
Аналогично с наличием [mem] ->[mem] , только rvalue содержится на шине
данных во время такта чтения-записи.

Вопрос: pointer + 1 будет равносильно pointer[1] только если массив из


char или других 1 байтовых типов, не так ли?

Ответ: В соответствии с арифметикой указателей, компилятор автоматически


домножает единичку на размер типа, на который он указывает. Таким образом,
происходит правильное вычисление адреса.
CV-специфицированные значения
В предыдущей главе мы начали говорить о категориях выражений. Я привел
примеры, в которых, на мой взгляд, достаточно легко определить
принадлежность к той или иной категории. На их основе компилятор проверяет
ограничения, оценивая правомерность написанного кода.

В С++ есть способы наложить дополнительные ограничения на действия над


данными. Например, запретить пользователю изменять значения с помощью
ключевого слова const . Вероятно, что это как-то должно повлиять на категорию
выражения, не так ли?

Стандарт языка использует термин «cv-специфицированный» для описания


типов с квалификаторами const и volatile . Пример:

// Запрещаем изменять значение


const int a = 1;

// Запрещаем кешировать значение в регистрах


volatile int b = 2;

// Комбинация двух предыдущих


const volatile int c = 3;

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

Стоит подумать, для каких категорий выражений такие квалификаторы будут


приносить пользу? Ограничить возможность изменять значение или запретить
кеширование логично для lvalue :

// Returns const reference


// to access for reading only
const std::string& foo() { return lvalue; }
// Accepts const reference
// to access for reading only
void bar(const std::string &lvalue)

// Spawns read-only value


const int magic = 3;

Несмотря на то, что переменной magic нельзя присвоить новое значение, она
всё ещё принадлежит категории lvalue :

const int magic = 3;

// lvalue rvalue
magic = 5;
// ~~^~~
// Error: assignment of
// read-only variable 'magic'

Нельзя сказать, что неизменяемый тип является rvalue. Нет, это просто другое
свойство, которое накладывает ограничения на действия над данными. Однако,
такие выражения могут быть использованы только как rvalue . Т.е. могут быть
только прочитаны, скопированы. Это позволяет ослабить ограничения в таких
ситуациях:

const int &d = 2; // Ok

Это может показаться странным, ведь d должна ссылаться на какое-то


значение в памяти. Да и в остальных случаях это работает иначе:

int a = 1; // Ok
int &b = a; // Ok
int &c = 2; // Error!

В отношении с все вполне логично и понятно — нельзя сослаться и изменять


память, которая не выделена под неё. Почему же всё работает для d ? Тут мы
видим, что эти данные запрещено изменять и нет запрета на кеширование.
Следовательно, при соблюдении этих ограничений дальше, выражение может
быть использовано только как rvalue , т.е. без перезаписи значений в памяти.
Компилятор либо подставит это значение по месту требования, либо создаст
вспомогательную локальную копию. В общем случае, ни логика, ни
работоспособность приложения не нарушится. Живой пример: https://compiler-
explorer.com/z/qsxrMYPb3

Априори, в совокупности с volatile квалификатором такой трюк не прокатит


из-за требований volatile:

const volatile int &f = 4; // Error!

Конечно, неприятный казус может случиться, если мы попытаемся обойти это


ограничение — применим const_cast<int&> , т.е. осознанно выстрелим себе в
ногу снимем ограничение на изменение данных. По сути, это прямое
игнорирование ограничений, которые по каким-то причинам вводились в код
проекта ранее. И вот желательно их выяснить и обойти иначе, а не
использовать такие грязные трюки. Короче, это UB!

Наглядный пример, почему использование этого каста является дурным тоном


в программировании на C++: https://compiler-explorer.com/z/qK1z3q89q. В
общем, на языке переживших новогодние праздники: «главное не смешивать»
Категория выражений xvalue
Да кто этот ваш xvalue ?! Пора разбираться! Появление этой категории
обусловлено некоторыми издержками копирования, которые свойственны
выражениям других категорий.

Как уже было сказано выше, к категории xvalue относятся временные


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

1. Существует временный объект класса string, который хранит 10 Мб


текста на куче.
2. Строчку хотят сохранить в другом объекте, а временный объект удалить.

В прямой постановке задачи, мы как раз оперируем категориями lvalue и


rvalue :

std::string nstr = tstr;


// ~~^~~ ~~^~~
// lvalue lvalue -> rvalue

// Then destroy temporary string 'tstr'

Но неужели мы реально будем копировать 10 Мб текста с кучи в другое место,


чтобы потом удалить исходные данные? То есть мы сделаем лишний
системный вызов на выделение 10 Мб памяти, потом будем посимвольно
копировать каждый байт 10 000 000 раз, а затем мы просто удалим
источник?...

По сути, это и есть те накладные расходы, которые тормозят нашу программу.


Кажется, что этого можно избежать. Например, можно сказать другому объекту,
что теперь он новый владелец данных временного объекта! То есть мы
передадим другому объекту указатель на текст и сделаем так, чтобы
временный объект его не удалял. Новый объект сможет дальше продолжить
пользоваться текстом, возможно, очень долго, когда старый уже исчезнет.
Формально, поменяется лишь оболочка над текстом.
Исходя из этой логики пример может быть эффективно решен следующей
последовательностью действий:

1. Инициализируем новый объект string, скопировав указатель на текст и


счетчики размера из временного объекта.
2. Во временном объекте установим указатель на текст nullptr и занулим
счетчики размера строки, чтобы при вызове деструктора наши данные не
потёрлись.
3. Разрушим временный объект.
4. Радуемся новому объекту, которых хранит ресурсы временного объекта!

Таким образом, мы сэкономили время на выделении памяти и его копировании,


и даже ни в чем не проиграли. Мы можем написать отдельную функцию или
метод, который будет выполнять этот алгоритм передачи данных. Однако,
удобно ли нам вызывать такую функцию каждый раз? Будет ли этот механизм
удобно использовать во всем проекте?

Начиная с C++11 вводится специальная категория выражений для обработки


таких временных объектов — xvalue . Так же вводится специальный тип rvalue
reference , для которого можно добавить перегрузки операторов и
конструкторов:

class string
{
public:
// Constructor for
// rvalue reference of string 'other'
string(string &&other) noexcept
{ ... }

// Assign operator for


// rvalue reference of string 'other'
string& operator=(string &&other) noexcept
{ ... }
};

Ранее мы использовали rvalue , как имя категории выражений. Теперь


появляется ТИП rvalue reference , который относится к категории выражения
xvalue . Не путайтесь, пожалуйста! Я считаю это неудачной терминологией
стандарта, которую надо просто запомнить.

Тип rvalue reference задаётся с помощью && перед именем класса.


Например:

std::string &&value = other;


// ~~^~~
// rvalue reference

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

Обратите внимание, как легко и непринужденно тут проявляется идиома RAII.


Жизненный цикл объекта остается неизменным и предсказуемым, а ресурсы
передаются между объектами: один создал строчку, а другой её удалит.

Будь я на вашем месте, мне бы стало непонятно, как же использовать всю эту
лабуду? Есть специальная функция std::move :

std::string nstr = std::move(tstr);


// ~~^~~ ~~~~~~~^~~~~~~~~
// lvalue lvalue -> xvalue

// Then destroy temporary string 'tstr'

Её вызов позволяет перейти к категории выражения xvalue . Все что она делает
— это вот:

// Упрощенная!!! версия
template<class T>
T&& move(T& t)
{
return static_cast<T&&>(t);
}
Дословно, эта функция преобразует тип данных T& — lvalue к T&& — rvalue
reference . Шаблон позволяет её определять для разных типов, а CTAD не
указывать конкретный тип. Получается достаточно приятный глазу код :)

Итак, std::move вернет в правой части rvalue reference , а для левой будет
вызван специальный перемещающий конструктор:

// Псевдокод!
string(string &&other) noexcept
{
this->data = other.data;
this->size = other.size;

other.data = nullptr;
other.size = 0;
}

Этот конструктор предоставляет нам логику, приведенную в примере выше.


Сразу расставим точки над i:

1. xvalue — это категория выражений.


2. rvalue reference — это тип.

Если можно перейти к xvalue , тогда будет выведен соответствующий тип


rvalue reference .

Вы наверняка слышали про т.н. правило трех. Так вот теперь с нововведением
это правило расширяется до пяти. Конечно, необходимость их определения
вытекает из использования этой std::move семантики.

Далее нам предстоит поговорить про универсальные ссылки, исключения в


перемещающем конструкторе, а так же про оптимизации RVO/NRVO.
Универсальные ссылки
В предыдущей главе был сделан следующий акцент:

Тип rvalue reference задаётся с помощью && перед именем класса.

ОДНО БОЛЬШОЕ НО! Вместо имени класса может быть установлен


параметр-тип шаблона:

template<typename T>
void foo(T &&message)
{
...
}

Ожидается, что из него будет выведен тип rvalue reference , но это не всегда
так. Такие ссылки позволяют с одной стороны определить поведения для
работы с xvalue , а с другой, неожиданно, для lvalue .

В своё время Scott Meyers, придумал такой термин как универсальные


ссылки, чтобы объяснить некоторые тонкости языка. Рассмотрим на примере
вышеупомянутой foo :

std::string str = "blah blah blah";

// Передает lvalue
foo(str);

// Передает xvalue (rvalue reference)


foo(std::move(str));

Оба вызова функции foo будут корректны, если не брать во внимание


реализацию foo . Живой пример: https://compiler-explorer.com/z/aT9Y8cdbz

Универсальная ссылка (т.н. universal reference) — это переменная или


параметр, которая имеет тип T&& для выведенного типа T . Из неё будет
выведен тип rvalue reference , либо lvalue . Это так же касается auto
переменных, т.к. их тип тоже выводится.
Расставляем точки над i вместе со Scott Meyers:

Widget &&var1 = someWidget;


// ~~^~~
// rvalue reference

auto &&var2 = var1;


// ~~^~~
// universal reference

template<typename T>
void f(std::vector<T> &&param);
// ~~^~~
// rvalue reference

template<typename T>
void f(T &&param);
// ~~^~~
// universal reference

В соответствии с этим маленьким нюансом поведение может меняться внутри


функции foo . Банально, можно накодить тормозящее копирование вместо
производительной передачи ресурса.

Я немного изменил предыдущий пример: https://compiler-


explorer.com/z/EzddYhjdv. В зависимости от выведенного типа, строка будет
либо скопирована, либо перемещена. Соответственно, в области видимости
функции main объект либо выводит текст, либо нет (т.к. ресурс был передан
другому объекту внутри foo ).

Причем, это не работает, если T — параметр-тип шаблонного класса:

template<class T>
class mycontainer
{
public:
void push_back(T &&other) { ... }
~~~^~~~
rvalue reference
...
};

Пример: https://compiler-explorer.com/z/We4qzG5xG

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


же так было сделано? А за тем, что существуют template parameter pack :

template<class... Ts>
void foo(Ts... args)
{
bar(args...);
}

foo(std::move(string), value);
~~~~^~~~ ~~^~~~
xvalue lvalue

Как мы видим, разные аргументы вызова foo могут относиться к разным


категориям выражений.

Кстати, если не знать и не пытаться в эти тонкости, то можно вполне спокойно


использовать стандартные структуры. Если говорить с натяжкой, то можно,
конечно, сказать, что такая универсальность может снижать порог вхождения в
C++. Не знаешь — пишешь просто рабочий код, а знаешь — пишешь ещё и
эффективный.

Другое дело, непонятно, почему нельзя было для универсальных ссылок


сделать отдельный синтаксис? Например, T &&& . Т.к. сейчас это рушит всю
концептуальную целостность системы типов, а так же не позволяет
использовать её отдельно от шаблонов. Если это планировалось как гибкий
механизм, то он граничит с полной дезориентацией разработчиков

Пока будем развивать тему в сторону move семантики. Не забываем об


исключениях в перемещающем конструкторе, а так же про оптимизации
RVO/NRVO.
Вопрос: не понимаю, разве компилятор в первом случае не должен был
выбрать перегрузку foo(const MyStruct&) ?

struct MyStruct {};

template<typename T> void foo(T&&)


{
std::cout << "foo(T&&)\n";
}

void foo(const MyStruct&)


{
std::cout << "foo(const MyStruct&)\n";
}

int main()
{
MyStruct ms;
foo(ms); //calls foo(T&&)

const MyStruct cms;


foo(cms); //calls foo(const MyStruct&)

return 0;
}

Ответ: в таких ситуациях я обычно вспоминаю правило разрешения


перегрузки.
Его довольно легко запомнить: "Нешаблонная реализация всегда побеждает".

Т.е. если в момент вызова, компилятор выше видит подходящий шаблон


функции, а также удовлетворяющую нешаблонную реализацию этой же
функции. То компилятор предпочтет именно нешаблонную версию.

Однако, если же шаблон функции генерирует наилучшее соответствие, то будет


выбран именно он.
Идеальная передача — perfect forwarding
Мы теперь знаем, что универсальные ссылки могут работать с разными
категориями выражений lvalue и xvalue . При написании кода шаблонной
функции мы можем не знать, какие аргументы могут быть переданы в неё.
Соответственно, мы не знаем, можем ли мы распоряжаться её внутренними
ресурсами. Всё это сильно влияет на производительность нашего решения. Что
же делать в такой ситуации?

Конечно, как вы уже знаете, мы можем детектировать тип rvalue reference . И


да, мы можем написать два разных участка кода для двух разных категорий
выражений. Можно, но нужно ли? Это противоречит дублированию кода.

Функция std::forward используется для так называемой идеальной передачи


аргументов при вызове других методов, конструкторов и функций:

template<typename T>
void foo(T &&message)
{
T tmp(std::forward<T>(message));
...
}

В данном примере во временный объект tmp будет передано либо lvalue ,


либо xvalue . Следовательно, мы либо скопируем строку, либо переместим. Это
зависит от того, как вызвали foo :

cpp
std::string str = "blah blah blah";

// Передает lvalue => std::string tmp(str);


foo(str);

// Передает xvalue => std::string tmp(std::move(str));


foo(std::move(str));

То есть std::forward выполняет проброс информации о категории выражения


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

Пример I. Инициализация объекта по универсальной ссылке:

template<class T>
class wrapper
{
std::vector<T> m_data;
public:
template<class Y>
wrapper(Y &&data)
: m_data(std::forward<Y>(data))
{
// make a copy from `data` or move resources from `data`
}
};

Пример II. При работе с контейнерами STL я предпочитаю использовать


семейство функций emplace , т.к. они предоставляют возможность
сконструировать объект сразу там, где он будет потом храниться. В основе
таких методов лежит std::forward , который пробрасывает аргументы вплоть до
конструкторов. Смотрите сами тут.

Передачу аргументов таким способом называют идеальной передачей (т.н.


perfect forwarding), потому что она позволяет не создавать копии временных
объектов.

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


оптимизации RVO/NRVO.
Исключения в перемещающем конструкторе
Продолжаем разбираться с move семантикой. Как вы могли заметить, во всех
примерах с перемещающим конструктором был поставлен спецификатор
noexcept :

class string
{
public:
string(string &&other) noexcept
{ ... }
};

И неспроста я это делал! Я бы даже сказал, что где-то это является очень
важным требованием.

Возьмем в качестве примера всем нам известный std::vector . Одним из


свойств этой структуры данных является перевыделение памяти большего
размера, при увеличении количества объектов. При этом старые объекты
отправляются в новый участок памяти. Логично задаться вопросом — как? И
логично ответить, что в целях повышенной производительности нужно
выполнять перемещение каждого объекта, а не копирование, если есть
возможность.

Когда же есть возможность переместить объект? Оказывается, наличие


обычного перемещающего конструктора — это недостаточное условие!
Необходимо гарантировать, что перемещение будет выполнено успешно и без
исключений.

Про исключения мы пока не успели написать, но в рамках этой статьи можно


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

Представим ситуацию, что МЫ - ВЕКТОР. Вот мы выделили новую память и


начали туда перемещать объекты. И где-то на середине процесса получаем
исключение при перемещении одного из объектов. Что нам делать-то с этим?
Вообще говоря, надо разрушить все что переместили в новой памяти и
сообщить об этом пользователю. Т.е. откатить все назад. НО! Назад дороги нет
Разрушать объекты из новой области памяти нельзя — их ресурсы
перемещены из старой памяти. Обратно перемещать тоже нельзя — вдруг
опять исключение прилетит? Брать на себя ответственность сделать что-то
одно тоже нельзя — мы вектор из стандартной библиотеки. В общем, встаем в
аналитический ступор...

Таким образом, мы приходим к выводу, что перемещать можно, если есть


явные гарантии от пользовательского класса. И это действительно так,
взгляните на живой пример 1.

Конечно, если копирующий конструктор запрещен (например), то будет вызван


хоть какой-то, т.е. перемещающий с исключениями. Живой пример 2. Тут важно
отметить стремление разработчиков STL обезопаситься там, где это возможно.

Если мы тоже хотим по возможности не нести ответственность за касяки


стороннего класса, то нам приходит на помощь функция:

std::move_if_noexcept(object);

Она делает всё то же самое, что и классическая std::move , но только если


перемещающий конструктор клянётся не бросать исключение. Если внутри
метода, помеченного noexcept , исключение всё таки будет брошено, то будет
все очень очень плохо... Скажу по опыту, такое отладить достаточно тяжело.
Поговорим об этом, когда наступит время серии постов про исключения

Пользовательские классы очень и очень часто засовывают в стандартные


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

Надеюсь, что мне удалось вас убедить в важности noexcept в перемещающем


конструкторе. Осталось совсем немного - оптимизации RVO/NRVO.
Вопрос: как избежать копирования вектора при его росте? Это выше моего
понимания)) Не совсем понятно предложение просто, копирование заменить на
перемещение....

Ответ: при расширении памяти, вектор либо копирует, либо перемещает


каждый объект, который он в себе хранит.

Вектор может хранить в себе объекты классов, которые являются композицией


других контейнеров с другими классами, которые тоже в себе что-то
композируют...

struct seed {};

struct apple
{
std::vector<seed> seeds;
}

struct apple_bucket
{
std::vector<apple> bucket
}

struct truck_of_apple_buckets
{
std::vector<apple_bucket> buckets;
}

При расширении памяти вектора в truck_of_apple_buckets каждое копирование


будет вызывать соответствующую цепочку конструкторов всех вложенных
классов:

apple_bucket -> apple -> seed

Следовательно, при копировании накладные расходы будут пропорциональны


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

apple_bucket

Именно только один перемещающий конструктор ;) Нам ведь достаточно


"переставить" ведро с яблоками в другое место, а не сделать его полную копию.
Оптимизации RVO / NRVO
Далее мы поговорим об одной из самых нетривиальных оптимизаций в С++.

Я очень удивлюсь, если встречу человека, который по мере изучения


стандартных контейнеров никогда не задумывался, что эти ребята слишком
«жирные», чтобы их просто так возвращать в качестве результата функции или
метода:

std::string get_very_long_string();

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

void fill_very_long_string(std::string &);

Эта мысль волновала всех с давних времен... Поэтому она нашла поддержку от
разработчиков компиляторов.

Существует такие древние оптимизации, как RVO (Return Value Optimization) и


NRVO (Named Return Value Optimization). Они призваны избавить нас от
потенциально избыточных и лишних вызовов конструктора копирования для
объектов на стеке. Например, в таких ситуациях:

// RVO example
Foo f()
{
return Foo();
}

// NRVO example
Foo f()
{
Foo named_object;
return named_object;
}
// Foo no coping
Foo obj = f();

Давайте взглянем на живой пример 1, в котором вызов конструктора


копирования явно пропускается. Вообще говоря, эта информация немного
выбивается в контексте постов, посвященных move семантике C++11, т.к. это
работает даже на C++98. Вот поэтому я её называю древней

Немного теории. При вызове функции резервируется место на стеке, куда


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

Иными словами, компилятор пытается понять, можно ли "подсунуть" область


памяти lvalue при вычислении rvalue и гарантировать, что мы получим тот же
результат, что и при обычном копировании. Можно считать, что компилятор
преобразует код в следующий:

void f(Foo *address)


{
// construct an object Foo
// in memory at address
new (address) Foo();
}

int main()
{
auto *address = reinterpret_cast<Foo *>(
// allocate memory directly on stack!
alloca(sizeof(Foo))
);

f(address);
}

В конце главы потом почитайте ассемблерный код в комментариях, а пока


продолжим.
RVO отличается NRVO тем, что в первом случае выполняется оптимизация для
объекта, который создается при выходе из функции в return :

// RVO example
Foo f()
{
return Foo();
}

А во втором для возвращаемого именованного объекта:

// NRVO example
Foo f()
{
Foo named_object;
return named_object;
}

Но при этом замысел и суть остаются такими же! Тут важно отметить, что и вам,
и компилятору, по объективным причинам, намного проще доказать
корректность RVO, чем NRVO.

Давайте покажу, когда NRVO может не сработать и почему. Рассмотрим кусочек


из живого примера 2:

// NRVO failed!
Foo f(bool value)
{
Foo a, b;

if (value)
return a;
else
return b;
}

Оптимизация NRVO не выполнится. В данном примере компилятору будет


неясно, какой именно из объектов a или b будет возвращен. Несмотря на то,
что объекты БУКВАЛЬНО одинаковые, нельзя гарантировать применимость
NRVO. До if (value) можно было по-разному поменять каждый из объектов и
их память. Или вдруг у вас в конструкторе Foo зашит генератор случайных
чисел? Следовательно, компилятору может быть непонятно куда надо
конструировать объект напрямую из этих двух. Тут будет применено
копирование.

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


радовать вас супер оптимизацией, но пока это надо делать нам самим. Так как
это могло бы быть исправлено? Раз мы такие умные, то всё можно свести к
RVO:

// RVO works!
Foo f(bool value)
{
if (value)
return Foo();
else
return Foo();
}

Оптимизация RVO выполнится. В данном случае может быть создан только


один из двух объектов Foo .

Другой пример:

struct Bar : Foo {};

// RVO failed!
Foo f()
{
return Bar();
}

Оптимизация RVO не выполнится. Хоть Bar и является наследником Foo , он


может по-другому генерировать родительский класс Foo , иметь
дополнительные поля и потребовать больше памяти. Так что опять копируемся.
Как вы можете догадаться, компилятор можно легко спугнуть Без
дополнительных проверок лучше не рассчитывать на эти оптимизации.

Но давайте вернемся к нашим move-баранам. С выходом C++11 в качестве


более эффективной альтернативы копированию появляется перемещение.

Теперь в случае, если тип возвращаемого значения вызываемой функции/


метода совпадает с типом lvalue , тогда компилятор попробует применить
оптимизацию RVO/NRVO, иначе будет неявно вызван перемещающий
конструктор. И если перемещающего конструктора нет для данного типа, то
будет вызван конструктор копирования.

И это действительно помогает! Например, за такую функцию уже переживаешь


куда меньше:

Foo f(bool value)


{
Foo a, b;

if (value)
return a; // move `a`
else
return b; // move `b`
}

Почитав предыдущие посты из серии может захотеться использовать move


семантику везде, где это только возможно ААА MOVE Но, на всякий
случай, предостерегу вас от некоторых ситуаций. Например, таких:

Foo f(bool value)


{
Foo a;

// warning: moving a local object in


// a return statement prevents copy elision
return std::move(a);
}
Не стоит забывать, что NRVO все таки эффективнее любых перемещений,
поэтому не нужно нарочно срывать оптимизацию, заставляя выполнить явное
перемещение вместо прямого конструирования объекта. Благо, если вы
включаете предупреждения компилятора, то вам об этом сообщат

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

Foo f(bool value)


{
Foo a;
return a;
}

Если можно сделать оптимизацию NRVO, то будет выполнена она, а иначе


будет выполнено перемещение или, на крайняк, копирование — компилятор
решит сам за нас, как будет наиболее эффективно и безопасно.

Исключительная ситуация, когда НАДО использовать std::move :

Foo f(bool value)


{
Foo a, b;
// move `a` or `b`
return std::move(value ? a : b);
}

Как мы знаем, NRVO тут не выполнится... Но и без std::move тут перемещения


не произойдет При использовании тернарного оператора необходимо
указать std::move , иначе будет вызван конструктор копирования: Check
yourself. Объяснение тут простое: возвращаемое выражение не удовлетворяет
требованиям применения неявного перемещения. Контраргумент: вы можете
задать условие, когда будут возвращаться объекты std::move(a) или просто b .
Это будут разные типы, которые обязаны быть возвращены по-разному.

Выводы:

RVO - это просто и круто, но редко применимо.


NRVO - это круто, но сложно, поэтому если рассчитываете, то
перепроверяйте.
Если не сработает NRVO, то будет неявно вызван конструктор
перемещения, если он определен у класса (since C++11).
Если нет конструктора перемещения, то будет копирование!
Будьте аккуратнее с тернарным оператором!
Золотая середина: сочетать RVO/NRVO с заполнением объектов по
ссылке.

Картинка ниже должна описывать ваши состояния по мере прочтения статьи :)


Обещанный пример на ассемблере. Хочу обратить ваше внимание, что в
первом и втором случае тело main содержит одинаковые инструкции. Таким
образом, я хочу подчеркнуть насколько оптимизация незаметно применяется с
точки зрения вызывающего кода: https://godbolt.org/z/cYEz1hx1W

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


выполнения оптимизации?

Ответ: должен быть доступен хотя бы один из конструкторов: копирования или


перемещения.

Тут следует разделять пропуск конструктора - copy elision и move elision -


пропуск конструктора перемещения. В строгой постановке вопроса ответ
должен быть: "нет, нельзя". Т.к. copy elision и move elision — это две разные
техники. В менее строгой, можно ответить, что copy elision может быть
заменена на move elision. Это означает, что конструктор копирования не
обязательно должен быть доступен, если доступен move конструктор. Далее
картинкой процитирую кусок § 15.8.3 из C++17 стандарта:

Затем следует пример, когда конструктор копирования Thing недоступен, в


отличии от конструктора перемещения:

// OK: Thing(Thing&&) used (or elided) to return t


Пропуск конструкторов копирования и перемещения
В предыдущей главе мы говорили об RVO / NRVO. Какой еще можно сделать
вывод об этой оптимизации?

Конструкторы копирования/перемещения не всегда могут быть вызваны!


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

Вообще говоря, никогда не стоит определять никаких сайд эффектов в


конструкторах / деструкторах / операторах, если вы на них рассчитываете.
Иначе может случиться вот это.

Конечно же, такую оптимизацию можно отменить с помощью флага


компиляции:

-fno-elide-constructors

Тогда всё всегда будет вызываться, но при этом с потерей производительности.


С другой стороны, это в принципе кажется странным — конструкторы не
должны менять ничего снаружи себя. Соблюдайте это правило, и всё будет
хорошо!
Надеюсь, что данный гайд оказался для вас действительно интересным и
полезным! До новых встреч!

С уважением, команда Грокаем C++.

Вам также может понравиться