Pull to refresh

Подробное введение в rvalue-ссылки для тех, кому не хватило краткого

Reading time 17 min
Views 72K

Вместо КДПВ — короткая драма для привлечения внимания, основанная на реальных событиях. Ее можно смело пропустить и перейти к статье, которая поможет вам разобраться в 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:
    • Нестроковый литерал, например 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 варианта:


  1. Запись foo(lvalue), при вызове foo(T&), эквивалентна foo<T>(lvalue).
  2. Запись foo(rvalue), при вызове foo(T&&), эквивалентна foo<T>(rvalue).
  3. Запись 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. Получается, что нам нужна функция, восстанавливающая тип ссылки, то есть:


  1. При передаче lvalue типа A& возвращает A&
  2. При передаче lvalue типа A возвращает A&&
  3. При передаче 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.

Tags:
Hubs:
If this publication inspired you and you want to support the author, do not hesitate to click on the button
+31
Comments 19
Comments Comments 19

Articles