Вместо КДПВ — короткая драма для привлечения внимания, основанная на реальных событиях. Ее можно смело пропустить и перейти к статье, которая поможет вам разобраться в rvalue-ссылках, конструкторах перемещения, универсальных ссылках, идеальной передаче (perfect forwarding) и т. д.
Драма в трех действиях
Действие первое
Компилятор. Локальный объект x типа T, проживающий на стеке, вы приговариваетесь к изъятию у вас всего имущества в связи с тем, что не будете пользоваться им до конца своей жизни.
Объект x. Что? Я не какой-то там временный объект, у меня постоянная регистрация, вы не имеете права!
Компилятор. Никто вас не выселяет. Но согласно одиннадцатой редакции стандартного кодекса, все ваши вещи будут переданы другому объекту, которому они нужны больше.
Объект x. И как вы это сделаете? Все мои данные надежно инкапсулированы, я не позволю никому бесцеремонно обращаться с ними. Если уж они так вам нужны, то пусть приходит конструктор копирования со своей флешкой, я ему скопирую.
Конструктор. Это долго и неэффективно.
Объект x. Может быть, вы собираетесь разбить мне окно reinterpret_cast-ом, и шариться в темноте?
Компилятор. Нет-нет, ваш случай слишком заурядный, чтобы пользоваться услугами коллекторов. Мы просто вызовем функцию std::move, она наденет на вас static_cast<T&&>, и все будут думать, что вы являетесь временным объектом, то есть rvalue-выражением.
Объект x. Ну и что, static_cast никак меня не затронет. Как только я доберусь до первой точки следования, я сниму его.
Компилятор. Не сомневаюсь, но раньше чем вы это сделаете, вы встретите конструктор перемещения, уже поджидающего за дверью. Кажется вы еще не успели с ним познакомиться...
Действие второе
T(T&&). О, привет, временный объект!
Объект x. Не-не-не, я lvalue, просто на мне static_cast.
T(T&&). Все так говорят. Ну-ка, что у тебя есть? Давай сюда...
Действие третье (эпилог)
T(const T&). Я что-то не в восторге от твоих насильственных методов.
T(T&&). Ты преувеличиваешь, сам подумай, зачем умирающему объекту все это барахло? Если бы не было меня, ты бы долго делал копию, а сразу за тобой деструктор бы уничтожил оригинал. Глупо.
T(const T&). Так может он не врал, и он не временный?
T(T&&). Мне без разницы. Значит кто-то решил, что так надо. Все законно, я просто делаю свою работу.
T(const T&). Но раньше же прекрасно справлялись без тебя. Как-то организовывали передачу и возврат, или выделяли объекту жилплощадь в динамической памяти. Да и компилятор помогал с оптимизацией.
T(T&&). Да ну, вспомни всю эту бюрократию. Объект заселяли, потом запись о регистрации теряли и все, он там так и жил до конца, и не прогнать. Да хватит уже жалеть эти объекты, а то ты превратишься в моего братца T(const T&&) — тот еще более сердобольный. Я ему говорю: "Все, объект уже не жилец, забирай его пожитки", а он все мнется, мол неудобно, давай я просто скопирую.
T(const T&). У меня тоже есть есть брат T(T&), самый настоящий бандит. Маскируется под меня, под конструктор копирования… Дальше придумывать лень.
Предисловие
Новые концепции не всегда ровно укладываются в голове. У меня так произошло с rvalue-ссылками. Вроде все очень просто, ясны предпосылки их появления, но при попытке чтения кода, насыщенного различными &&, обернутыми в кучу шаблонов, понимаешь, что ничего, в общем-то, и не понимаешь.
Моя ошибка при изучении этой темы была в том, что я представлял rvalue-ссылки как принципиально новую сущность. Возможно это покажется вам странным, ведь во всех руководствах явно говорят, что это просто ссылка на rvalue. Это я понимал. Но ведь вместе с ними появилась куча новых понятий, типа универсальных ссылок и perfect forwarding. А еще, вызов функции, возвращающей &&, стал каким-то мистическим xvalue-выражением. Короче, было бы слишком просто считать их обычными ссылками.
Так вот, самое главное — не усложняйте! Если вы увидели T&& ref = foo()
, и не знаете как теперь относиться к ref, то относитесь к нему как к старой-доброй константной ссылке на rvalue: const T& ref = foo()
, только без const.
А почему нельзя было просто разрешить брать ссылку на rvalue? Иначе мы бы сразу теряли информацию о том, было ли выражение lvalue или rvalue. Сейчас же rvalue "предпочитает" быть переданным в функцию c аргументом T&&, а lvalue — с T&. Это дает нам возможность поступать с объектами по-разному, что особенно важно для реализации конструкторов копирования и перемещения.
Еще одна моя ошибка — проверка примеров в Visual Studio. Примеры в статьях, например std::string &str = std::string("42")
, которые не должны компилироваться, у меня компилировались. Это происходило из-за нестандартного расширения языка от Visual Studio. Я еще буду говорить об этом, потому понимание такого поведения очень важно, если VS — ваша среда разработки.
Лучший способ чтения этой статьи — не верить мне, и все проверять самостоятельно. Советую в отладочной сборке. Если вы используете GCC, то будет неплохо ключом -fno-elide-constructors
отключить технику Copy Elision, подавляющую вызов конструкторов копирования и перемещения там где можно. А если VS, то включите 4-й уровень предупреждений, для отлова использований нестандартного расширения.
Введение
При изучении rvalue-ссылок и конструкторов перемещения, часто можно встретить подобные примеры:
Matrix m = m1 * m2;
std::string s = s1 + s2;
std::vector<BigInt> primes = getAllMersennePrimes();
Временный объект копируется и сразу уничтожается. Это, конечно, явно избыточная операция, и работа конструктора перемещения здесь довольно наглядна. Однако, добавив конструктор перемещения в свой класс, вы можете не заметить ускорения. Ведь компилятор использует различные техники оптимизации, в частности Return Value Optimization, о которой мы немного поговорим в конце статьи. Я предлагаю следующий пример. Представим, что мы заполнили большой локальный вектор:
std::vector<int> items(1000000);
fill(items);
В памяти он выглядит так (не будем переусложнять схему третьим указателем на конец зарезервиронной памяти):
И хотим передать его некоторому объекту, через setter:
storage->setItems(items);
//далее items уже не нужен
Как было раньше: вектор передавался по константной ссылке (что позволяло использовать и lvalue и rvalue), а затем вызывался конструктор копирования, который создавал такой же большой вектор. А оригинал удалялся только после выхода из области видимости. Хотя хотелось бы просто передать новому вектору указатель на данные:
Сейчас это легко:
std::vector<int> items(1000000);
fill(items);
storage->setItems(std::move(items));
//далее items уже не нужен
А в методе setItems:
Storage::setItems(std::vector<int> items)
{
items_ = std::move(items);
}
Обратите внимание, что вектор передается по значению, что обеспечивает копирование для lvalue и перемещение для rvalue (в частности временных объектов). Таким образом, если локальный вектор вам еще нужен, то просто вызывайте setItems без std::move. Небольшой накладной расход в том, что аргумент еще раз перемещается с помощью оператора перемещения.
Инициализация
Все что нам нужно сделать, чтобы разобраться со всеми концепциями — сфокусировать внимание на различных способах инициализации. Это может быть инициализация переменной:
T x = expr;
//или
T x(expr);
Передача аргумента:
void foo(T x);
foo(expr);
Возврат значения:
T bar()
{
return expr;
}
Все эти случаи будем называть инициализацией, так как они семантически идентичны. Затем рассмотрим тип T
. Он может быть одим из следующих:
- бессылочным типом (A)
- lvalue-ссылкой (A&)
- rvalue-ссылкой (A&&)
Уточню, что тип с указателем тоже относится к одному из них. Например, A*
— это бессылочный тип, A*&
— lvalue-ссылка, и т.д. Далее обратим внимание на выражение expr
. Выражение имеет тип и категорию значения (rvalue или lvalue). Тип выражения для нас будет не так важен, как можно было бы подумать. В данной теме основную роль играет категория значения выражения: rvalue или lvalue, которую для краткости будем называть категорией выражения.
Таким образом, у нас слева 3 варианта, справа 2. Итого 6. Прежде чем рассматривать их подробнее, научимся определять категорию.
Категория значения выражения
lvalue и rvalue
До c++11 существовали только эти 2 категории. С появлением move-семантики категория rvalue разбилась еще на 2 категории, о которых мы поговорим в следующем подразделе. Итак, категория каждого выражения является либо lvalue либо rvalue. Стандарт, конечно, описывает, что относится к каждому из них, но читать его сложно.
Скотт Майерс предлагает следующие правила:
- Если возможно взять адрес выражения, то оно lvalue.
- Иначе, если тип выражения является lvalue-ссылкой (т. е. T& или const T& и т.п.), то оно так же lvalue.
- В противном случае, выражение — rvalue.
Мне они не очень нравятся, так как они нестрогие, и существует много тонкостей, которые иногда меняются. И, самое главное, при изучении темы, как я могу понять, можно ли взять адрес? Ну, хорошо, мы знаем, что у временного объекта нельзя. А у str
?
std::string &&str = std::string("Hello");
std::string *psrt = &str;
Выясняется, что да, можно, так как str
— lvalue, хотя его тип rvalue-ссылка. Запомните, это важно.
Если нужно, я обращаюсь к cppreference: value category, где приведен перечень выражений. Некоторые примеры оттуда:
- lvalue:
- Имя переменной или аргумента (даже если их тип rvalue-ссылка). Например
std::cin
. - Вызов функции или оператора, тип возвращаемого значения которых является lvalue-ссылкой. Например
std::cout << 1
,++it
. - Строковые литералы, например
"Hello, world!"
.
- Имя переменной или аргумента (даже если их тип rvalue-ссылка). Например
- rvalue:
- Нестроковый литерал, например
42
,true
,nullptr
. - Вызов функции или оператора, тип возвращаемого значения которых не является ссылочным
- Взятие адреса:
&a
. - Вызов функции или оператора, тип возвращаемого значения которых является rvalue ссылкой. Например
std::move(x)
. a.m
, где a — rvalue.
- Нестроковый литерал, например
xvalue, prvalue и glvalue
Как уже упоминалось, rvalue разбилась на две категории xvalue (eXpiring lvalue) и prvalue (pure rvalue). А lvalue вместе с xvalue стала называться glvalue (generalized lvalue). Теперь, для наибольшей точности, следует относить выражения к одной из 3 категорий. На диаграмме это выглядит так:
К xvalue относятся следующие выражения:
- Вызов функции или оператора, тип возвращаемого значения которых является rvalue-ссылкой. Например
std::move(x)
. a.m
, где a — rvalue.
Для чего были нужны дополнительные категории? Выражение xvalue, хотя и является rvalue, но имеет некоторые свойства lvalue, например, оно может быть полиморфным. Далее нам не понадобятся эти дополнительные категории, но они бывают полезны для повышения своего авторитета во время жарких дискуссий.
Способы инициализации
По нашей оценке вышло 6 вариантов. На самом деле нужно рассмотреть всего 3. Потому что, во-первых, нельзя инициализировать rvalue-ссылку lvalue-выражением. А во-вторых, инициалиция объекта (нессылочный тип) некоторым выражением, производится с помощью конструктора копирования или перемещения через передачу выражения (в конструктор) по ссылке, что сводит эти случаи к одному из трех вариантов. В качестве первоначального ориентира такая схема:
T& x = lvalue
Обычная ссылка.
T& x = rvalue
Это работает только в двух случаях.
Случай 1. Для константной ссылки
const T& x = rvalue;
До c++11 это был единственный способ передать куда-то временный объект. Например, в конструктор копирования:
T::T(const T&);
T x = T(); //Временный объект T передается по константной ссылке в конструктор копирования
или куда-нибудь еще:
void readFile(const std::string &);
std::string filename = "data";
readFile(filename + ".txt"); //Временный объект std::string передается по константной ссылке в readFile
Как вы знаете, константная ссылка продлевает жизнь временного объекта. Да, но только пока эта ссылка еще находится в области видимости. Так как временный объект располагается на стеке, то при выходе из области видимости или из функции, компилятор уже не обязан отвечать за судьбу объекта.
Случай 2. В Visual Studio
Пусть вас не удивляет прекрасно работающий в Visual Studio пример:
std::vector<int> &v = std::vector<int>({ 1, 2, 3 });
v.push_back(4);
Ответ появится только на 4 уровне предупреждений:
warning C4239: nonstandard extension used
note: A non-const reference may only be bound to an lvalue
Это нестандартное для C++ расширение отключается ключом /Za (Disable Language Extensions)
, но некоторые хедеры, типа Windows.h
, не будут компилироваться, так как в расширение входят и другие фичи.
T&& x = lvalue
Тут все просто: так писать нельзя. Будьте внимательны, на Хабре есть перевод статьи «A Brief Introduction to Rvalue References» (статья аж 2008 года), который находится первым в выдаче поисковиков по запросу "rvalue". Пример оттуда ошибочен:
A a;
A&& a_ref2 = a; // это rvalue ссылка
Также, там приведена неверная реализация std::move
. Впрочем, в комментариях указали на ошибки.
T&& x = rvalue
Подобрались к самому интересному. Начнем с простейших примеров:
std::string foo();
std::string&& str = foo();
int&& i = 5;
Эти ссылки ведут себя как обычные. Можно представить ссылку const T&, которую можно менять, или вспомнить расширение Visual Studio. Может возникнуть вопрос, почему же нельзя было просто использовать обычную ссылку для всех категорий выражений? В VS это же прекрасно работало (для классов). Ссылки && дают возможность перегружать функции и особенно конструкторы не только по типу выражения, но и по категории (lvalue или rvalue). Пример с 2 конструкторами:
string(const string&);
string(string&&);
string s1 = "Hello ";
string s2 = "world!";
string s3 = s1 + s2;
Выражение s1 + s2
является rvalue, и для него подходят оба конструктора (см. схему в начале раздела). Приоритет будет отдан типу string&&. Те, кто знаком с конструкторами перемещения, знает почему это важно. Перед тем как поговорить об этом подробнее, разберемся с приоритетами.
Приоритет
В большинстве ситуаций достаточно знать, что T&&
"приоритетнее" const T&
. Но желательно разобраться и с const T&&
и с T&
. Вот расширенная схема:
Правила простые (в порядке убывания приоритета):
- В первую очередь, подобное стремится к подобному (горизонтальные стрелки).
- Константный вариант тоже подойдет.
- И если все остальные варианты отсутствуют, то тип
const T&
будет рад всем выражениям. - Для VS: не забываем про неконстантные ссылки на rvalue (пунктирная стрелка).
Конструкторы копирования и перемещения (T x = lvalue; T x = rvalue;)
Когда вы пишете:
T x = expr;
//или
T x(expr);
вызывается конструктор. Какой? У класса их может быть несколько:
T(const T&); //copy-конструктор
T(T&); //коварный copy-конструктор
T(const T&&); //как назвать этот?
T(T&&); //move-конструктор
При вызове конструктора происходит передача выражения по ссылке согласно схеме выше, в зависимости от категории выражения и типа ссылки аргументов. Теперь о том, что делает каждый конструктор.
1. T(const T&)
Обычный конструктор копирования. Имеет самый низкий приоритет, зато может принимать любые выражения.
2. T(T&)
Я называю его коварным конструктором копирования, потому что он может совершенно спокойно модифицировать источник, если изначально он не был константным. Имеет приоритет выше обычного конструктора копирования. Сомневаюсь, что он используется на практике. Если есть хорошие примеры его применения, то напишите.
3. T(const T&&)
Этот может принимать rvalue-выражения, т. е. объекты срок жизни которых подошел к завершению. Умирающий объект говорит: я умираю, возьми мои гранаты указатели на данные, мне они уже не нужны. А конструктор отвечает: нет, я так не могу, они же твои и останутся с тобой, я могу только сделать копию. Мне также неизвестен практический пример использования.
4. T(T&&)
Этот конструктор вызывается только для неконстантного rvalue. Такое выражение представляет собой временный объект или объект, приведенный к rvalue-ссылке с помощью static_cast<T&&>
. Подобное преобразование никак не затрагивает объект, однако такое выражение-обертка может быть передано по rvalue-ссылке в конструктор перемещения (или в какую-нибудь функцию). Конструктор забирает все указатели на данные и другие члены класса и передает их новому объекту. Поэтому, для эффективной работы move-конструктора, членов класса должно быть меньше. В предельном случае можно лишь хранить один указатель на реализацию. Здесь хорошо подходит идиома Pimpl (pointer to implementation).
Например, рассмотрим класс строки, заданной с помощью указателя на данные в куче и длины строки (это только концептуальный пример):
class String
{
char* data_;
size_t length_;
public:
explicit String(size_t length)
{
data_ = static_cast<char*>(::operator new(length));
length_ = length;
}
~String()
{
::free(data_);
}
};
Так может выглядеть конструктор перемещения:
String(String&& other)
{
data_ = other.data_; // Забираем указатель на строку
length_ = other.length_; // Копируем длину
other.data_ = nullptr; // Обязательно обнуляем (подробности далее)
other.length_ = 0; // И длину тоже
}
Что бы получилось, если бы мы не обнулили его данные (3-4 строки конструктора)? Рано или поздно для двух объектов-двойников был бы вызван деструктор, который бы попытался два раза удалить одни и те же данные. Но что если обнулить только указатель на данные, но не длину? Ведь деструктор отработает дважды нормально — конкретно этот не пользуется длиной. Тогда пользователь объекта, который вдруг воспользуется функцией getLength
получит неверные сведения. Поэтому, нельзя варварски обращаться с объектом, который уже не нужен. В любом случае, необходимо оставлять его пустым, но в корректном состоянии. К тому же, можно вызывать перемещение несколько раз на протяжении его жизни.
static_cast<T&&> и std::move
Мы уже говорили про static_cast<T&&>
при обсуждении конструктора перемещения. Как вы помните, выражение "обернутое" в static_cast<T&&>
, сигнализирует, что объект может быть перемещен. Тогда, если инициализируется новый объект, будет вызван конструктор перемещения, а не копирования. Теперь мы можем реализовать задачу из начала статьи:
std::vector<int> items(1000000);
fill(items);
static_cast< std::vector<int>&& > (items); // Здесь ничего не произойдет, просто для примера
storage->setItems( static_cast< std::vector<int>&& > (items) );
//далее items уже не нужен
Вы наверняка знаете, что есть более удобный способ — вызов функции std::move
, которая является оберткой над static_cast. Чтобы понять как она работает, мы будем писать ее самостоятельно и наступим на все грабли, зато это будет полезным уроком. Прежде чем читать дальше, вы можете написать ее сами. Конечно эта функция должна быть шаблонной, чтобы принимать разные типы. Но пока, для простоты, сделаем для одного конкретного класса A. Порассуждаем. Если мы хотим передать lvalue, то сделать это можно только с помощью аргумента типа A&. Далее, мы преобразуем его к A&&, и типом возврата тоже будет A&&. Вызов функции, которая возвращает &&, является rvalue-выражением (точнее xvalue), как мы и хотели.
A&& my_move(A& a)
{
return static_cast<A&&>(a);
}
Но стандартная функция std::move также принимает и rvalue, поэтому она универсальна, чего нельзя сказать о нашей. Одно из решений простое — добавить еще одну функцию с аргументом A&&:
A&& my_move(A&& a)
{
return static_cast<A&&>(a);
}
Работает и для lvalue и для rvalue. Но, подождите, настоящая std::move только одна и имеет шаблонный тип аргумента T&&. Как так? Будем разбираться дальше.
Сжатие ссылок и универсальные ссылки
Итак, мы выяснили, что шаблонная std::move принимает lvalue для аргумента T&&. Значит, T&& — это не rvalue-ссылка. T&& каким-то образом превращается в T&. Но каким типом инстанцирован T? Чтобы понять что к чему, попробуем вызывать две перегруженные функции для двух категорий выражений (rvalue и lvalue):
template<class T> void foo(T&);
template<class T> void foo(T&&);
A a;
foo(a); //lvalue;
foo(A()); //rvalue;
В самих функциях будем проверять каким типом инстанцирован Т. Тип ссылки можно проверить так:
bool is_lvalue = std::is_lvalue_reference<T>::value;
bool is_rvalue = std::is_rvalue_reference<T>::value;
Выясняется, что подходят 3 варианта:
- Запись
foo(lvalue)
, при вызовеfoo(T&)
, эквивалентнаfoo<T>(lvalue)
. - Запись
foo(rvalue)
, при вызовеfoo(T&&)
, эквивалентнаfoo<T>(rvalue)
. - Запись
foo(lvalue)
, при вызовеfoo(T&&)
, эквивалентнаfoo<T&>(lvalue)
.
На следующей схеме подписи на стрелках означают каким типом был инстанцирован T в том или ином случае.
Первые два варинта предсказуемые, мы на них и полагались. Третий использут правило reference collapse — сжатие ссылок, которое определяет поведение при появлении ссылки на ссылку. Такая конструкция сама по себе является запрещенной, но возникает в шаблонах. Поэтому было задано, что если T инстанцирован A&&, то T&& (A&& &&) = A&&, а остальных случаях (ссылки на ссылку) тип равен A&, то есть:
T = A& => T& = A&
T = A& => T&& = A&
T = A&& => T& = A&
T = A&& => T&& = A&&
Таким образом, аргумент типа T&&, где T — шаблонный тип, может принимать оба типа ссылок. Поэтому ссылку типа T&& называют универсальной ссылкой.
std::move (продолжение)
Теперь понятно, что std::move может примать T&& (см. foo(T&&) на схеме выше). Дабавим шаблонности нашей my_move:
template<class T>
T&& my_move(T&& t)
{
return static_cast<T&&>(t);
}
Это ошибочная реализация, ведь T&& превращается в A& для lvalue, и, в этом случае, инстанс функции будет таким:
A& my_move(A& t)
{
return static_cast<A&>(t);
}
Тип аргумента корректный, но для остальных местах нам было нужно оставить A&& вместо A&. Пора смотреть реализацию std::move:
template<class T>
typename remove_reference<T>::type&& move(T&& _Arg)
{
return (static_cast<typename remove_reference<T>::type&&>(_Arg));
}
Думаю, понятно: remove_reference убирает все ссылки, а затем к получившемуся типу добавляется &&. Кстати, класс remove_reference устроен очень просто. Это три специализации шаблонного класса с параметрами T, T&, T&&.
template<class T> struct remove_reference
{
typedef T type;
};
template<class T> struct remove_reference<T&>
{
typedef T type;
};
template<class T> struct remove_reference<T&&>
{
typedef T type;
};
Perfect forwarding и std::forward
Казалось бы, разобрались со всеми проблемами, но нет. Мы научились передавать все выражения по шаблону T&&, но до сих пор не интересовались, как работать с ними дальше. Функция std::move не считается, потому что она не задумываясь приводит все к rvalue. Зачем нужно различать категории выражений? Представим, что мы пишем аналог make_shared
. Напоминаю, что make_shared<T>(...)
сам вызывает конструктор T с заданными аргументами. Сейчас не время разбираться с variadic templates, поэтому, для простоты предположим, что аргумент всего один. Также будем возвращать самый обычный указатель. Возьмем простой класс:
class A
{
public:
A(); //Обычный конструктор
A(const A&); //Конструктор копирования
A(A&&); //Конструктор перемещения
};
Мы хотим делать так:
A a;
A* a1 = make_raw_ptr<A>(a); //Должно произойти копирование
A* a2 = make_raw_ptr<A>(A()); //Должно произойти перемещение
delete a1; delete a2;
Воспользовавшись сжатием ссылок, пишем реализацию:
template<class T, class Arg>
T* make_raw_ptr(Arg &&arg)
{
return new T(arg);
};
Ну, работает, конечно. Только всегда происходит копирование. Нашли ошибку? Все просто — arg всегда lvalue. Кажется, что мы потеряли информацию о категории исходного выражения. Не совсем — ее еще можно извлечь из Arg. Ведь для lvalue: Arg=A&, для rvalue: Arg=A. Получается, что нам нужна функция, восстанавливающая тип ссылки, то есть:
- При передаче lvalue типа A& возвращает A&
- При передаче lvalue типа A возвращает A&&
- При передаче rvalue… Пока не будем заморачиваться.
Попробуем реализовать для типа A (без шаблонов):
//Для 1-го случая
A& my_forward(A& a)
{
return a;
}
//Для 2-го случая
A&& my_forward(A& a) //не забываем, что передаем lvalue
{
return static_cast<A&&>(a); //припомним, что исходное выражение было rvalue
}
Пока это не работает, так как сигнатуры одинаковы. Вспомним, что для первого случая у нас есть A&, а для второго просто A. Значит, нужно одним движением получить из A& — A&, а из A — A&&. Просто используем сжатие ссылок, "нацепив" на исходные типы &&.
template<class T>
T&& my_forward(T& a)
{
return static_cast<T&&>(a);
}
template<class T, class Arg>
T* make_raw_ptr(Arg &&arg)
{
return new T(my_forward<Arg>(arg));
};
Стандартная версия std::forward немного сложнее. Она перегружена для различия & и &&, что позволяет организовать дополнительные проверки.
Возврат объекта
Если проверять вызовы конструкторов/деструкторов при возврате объекта, то окажется, что в разных компиляторах при разных настройках количество вызовов будет различаться. Это сбивает с толку при изучении move-семантики. Сначала рассмотрим как происходит возврат объекта без оптимизаций. Для этого в GCC можно задать ключ -fno-elide-constructors
. Затем изучим техники оптимизации, которые применяет компилятор.
Возврат без оптимизации
В следующем примере возвращаемое значение никак не используется.
T foo()
{
T result;
return result;
}
foo();
Во фрейме стека функции foo создается объект. Так как выполнение функции подходит к завершению, то ее память совсем скоро будет использована для других нужд. Поэтому, чтобы сохранить объект, компилятор делает примерно так:
T temp = result; //вызов конструктора происходит в вызываемой функции
Это очень условная запись, которая нужна лишь для того, чтобы понять принцип. temp — временный объект, размещенный во фрейме вызывающей функции. Важно, что result, скорее всего, будет интерпретироваться компилятором как rvalue, если он является локальным объектом foo. Поэтому temp может быть создан с помощью конструктора копирования или перемещения. На всякий случай напомню, что возвращать ссылку (неважно & или &&) на локальный объект нельзя.
Если нам нужно использовать результат, то такая запись:
T x = foo();
Превращается в:
T temp = result; //вызов конструктора происходит в вызываемой функции
T x = std::move(temp); //вызов конструктора происходит в вызывающей функции
Такая двойная работа не обязательно будет применяться в вашем компиляторе. Однако, для изучения бывает полезно представлять возврат объекта именно так.
Как видите, даже с отключенными оптимизациями можно возвращать большой объект (в смысле его данных, а не sizeof, конечно), если для него существует конструктор перемещения.
Copy elision и return value optimization
Каждый C++ программист задумывался: можно ли возвращать большой вектор? Иногда страх пересиливал и вместо std::vector<int> get();
мы писали void get(std::vector<int> &);
С move-конструкторами стало полегче. Но и до них компиляторами применялась copy elision — техника избавления от лишних конструкторов, там где можно. В частности, для возвращаемых значений используется return value optimization (RVO). Вместо возврата локального объекта через конструктор копирования (или перемещения), компилятор поступает примерно как и мы, когда передавали объект по ссылке. То есть, возвращаемый объект сразу создается в вызывающей функции, а заполняется в вызываемой. Можно ли полагаться на RVO? Пример в Visual Studio 2015:
T foo()
{
return T();
}
class T
{
public:
T() = default; //RVO не работает даже в релизе
T() {}; //А так работает
};
Скажем так, полагаться только на оптимизацию не стоит. Даже если она хорошо отрабатывает в релизной версии, то в отладочной она обычно не применяется, что может затянуть процесс. Пишите конструкторы перемещения или создавайте динамические объекты и заворачивайте их в std::shared_ptr или std::unique_ptr.
Комментарии (19)
dendron
20.02.2017 17:34Что если я хочу написать так?
class T { public: T(); T( const T& ) = delete; }; T&& foo() { return T(); } T&& t = foo();
Фактически я хочу легализовать поведение компилятора, когда место под объект выделяется в стеке вызывающей функции, а вызов конструктора происходит в вызываемой функции.
При этом я не хочу разрешать перемещать или копировать объект, или использовать placement new (не хочу руками вызывать деструктор).
Fil
20.02.2017 18:12На секунду появилась мысль написать так:
void bar(T& t) { t.T::T(); }
Но это не вариант, непонятно на что ссылаться. Мне неизвестно как создать объект с выделением памяти и вызовом деструктора, но без вызова конструктора.
dendron
20.02.2017 18:23Парадоксально, что судя по вашей статье именно это и происходит (выделение на стеке вызывающей функции, конструирование в вызываемой функции), но в 2017 году до сих пор нет варианта легализовать данное поведение.
Мы всё ещё делаем вид, что есть какое-то мнимое копирование, сдувая пыль со стандартов 20-летней давности.
Я даже не уверен, что если я напишу конструктор перемещения, то это не сделает ситуацию ещё хуже, вынудив компилятор отказаться от RVO. Это какой-то провал!
Fil
20.02.2017 19:38На всякий случай, чтобы не быть голословным. В main:
main: lea rax, [rbp-17] //выделили место на стеке mov rdi, rax //передаем адрес через rdi call foo() lea rax, [rbp-17] mov rdi, rax call T::~T() //вызов деструктора для того же адреса
теперь смотрим foo:
foo: mov QWORD PTR [rbp-8], rdi //копирование туда-сюда mov rax, QWORD PTR [rbp-8] mov rdi, rax call T::T() //вызов конструктора для адреса //в rdi в стеке main
Иногда действительно хочется отложенную инициализацию.
antonkrechetov
21.02.2017 18:56+1Фактически я хочу легализовать поведение компилятора, когда место под объект выделяется в стеке вызывающей функции, а вызов конструктора происходит в вызываемой функции.
Это невозможно легализовать средствами языка (синтаксически), поскольку в стандарте C++ стек вообще не упоминается применительно к вызову функций, и компиляторы вольны реализовывать функции, как им угодно.
Однако, в C++17 все будет работать так, как вы хотите, и не иначе: возвращаемые значения будут сразу конструироваться на нужном месте (guaranteed copy elision).Fil
21.02.2017 19:20Хороший комментарий. Вот, что интересно: если вы разбирались с этим — заданы ли четкие требования к работе copy elision? Например, в таком случае:
T foo() { T t; if (rand() % 2) { T t; return t; } else { return t; } }
sergegers
22.02.2017 12:01+1Для возвращаемого значения copy elision делается для RVO
auto foo() -> T { return T{}; }
и для NRVO
auto foo() -> T { T t; return t; }
В вашем случае copy elision скорее всего сделан не будет.
NeoCode
20.02.2017 18:41Мне вот что интересно — если бы язык С++ можно было проектировать с нуля, не оглядываясь на «обратную совместимость», то как бы следовало спроектировать rvalue references? Так как сделано сейчас или как-то иначе?
encyclopedist
20.02.2017 19:04+3Вероятно, как в Rust.
DarkEld3r
20.02.2017 20:05Как раз недавно задумывался про это. В смысле, в расте всё удобно сделано, но задать своё "поведение перемещения" нельзя. Интересно можно ли придумать не сильно извращённую логику, когда этого в языке будет не хватать. Я так сходу не смог.
infrapro
20.02.2017 19:50Мне нравится cpp тем, что в нем новые фичи реализованы средствами самого языка, как например, в тексте выше реализация std::move по сути каст к rvalue через иснтанцирование нужной специализации remove_reference. Но все это негативно сказывается на время компиляции… И вот скажите, как бы улучшилось время компиляции, если бы std::move был чем-то вроде зарезервированного компилятором имени, и преобразования делались на уровне парсера кода?
Satus
20.02.2017 22:16+1Едва ли приведение типа на этапе компиляции хоть сколько бы значимо влияет на время компиляции.
infrapro
21.02.2017 12:07Так помимо приведения типа здесь также есть инстанцирование шаблона, что уже влияет существенно на время компиляции
Satus
21.02.2017 18:06Докажете?
infrapro
21.02.2017 19:55Прям так сходу, с графиками и примерами не смогу. Но основываюсь на своем опыте и опыте коллег, которые жалуются, что с обильным использованием шаблонов в С++, время компиляции существенно вырастает. И сами посудите, вот такое неявное использование шаблонов в std::move при использовании его на большом кол-ве различных типов (а это в контексте использования std::move добиться, в приницпе, легко) генерит кучу дополнительного кода. На каждый уникальный тип объекта по инстансу remove_reference, когда могли бы просто получить подстановку нужного типа простой подменой на уровне парсинга кода.
izvolov
Вообще говоря, можно. Даже стандартная библиотека, которая гарантирует "действительное, но не оговорённое" (valid but unspecified) состояние перемещённого объекта (раздел "Moved-from state of library types" [lib.types.movedfrom]), делает это исключительно по своей доброй воле.
Для обычного объекта вполне достаточно того, что перемещённый объект можно уничтожить (выполнить деструктор) или заново проинициализировать (выполнить любое присвоение).
Потому что в целом было бы странно продолжать нормальную работу с объектами, из которых вытащили все внутренности.
izvolov
Немного уточню.
Указатели обнулять, конечно, нужно, потому что мы не хотим получить повторное удаление одного и того же объекта.
А вот сопутствующие данные, например, ту же длину, трогать не обязательно.
Fil
Спасибо, важное замечание! Добавлю в статью.