Pull to refresh

«Универсальные» ссылки в C++11 или T&& не всегда означает «Rvalue Reference»

Reading time 14 min
Views 41K
Не так давно Скотт Майерс (англ. Scott Meyers) — эксперт по языку программирования C++, автор многих известных книг — опубликовал статью, описывающую подробности использования rvalue ссылок в C++11.
На Хабре эта тема еще не поднималась, и как мне кажется, статья будет интересна сообществу.
Оригинал статьи: «Universal References in C++11—Scott Meyers»

«Универсальные» ссылки в C++11


T&& не всегда означает “Rvalue Reference”

Автор: Scott Meyers

Возможно, наиболее важным нововведением в C++11 являются rvalue ссылки. Они служат тем фундаментом, на котором строятся «семантика переноса (англ. move semantics)» и «perfect forwarding». (Вы можете ознакомится с основами данных механизмов в обзоре Thomas’а Becker’а).

Синтаксически rvalue ссылки объявляются также, как и «нормальные» ссылки (теперь называемые lvalue ссылками), за исключением того, что вы используете два амперсанда вместо одного. Таким образом, эта функция принимает параметр типа rvalue-reference-to-Widget:
void f(Widget&& param);

Учитывая, что rvalue ссылки объявляются с помощью “&&”, было бы разумно предположить, что присутствие “&&” в объявлении типа указывает на rvalue ссылку. Но это не так:
Widget&& var1 = someWidget;         // здесь “&&” означает rvalue ссылку

auto&& var2 = var1;                 // здесь “&&” НЕ означает rvalue ссылку

template<typename T>
void f(std::vector<T>&& param);     // здесь “&&” означает rvalue ссылку

template<typename T>
void f(T&& param);                  // здесь “&&” НЕ означает rvalue ссылку

В этой статье я опишу два значения “&&” в объявлении типа, разъясню как отличить их друг от друга и введу новую терминологию, которая позволит однозначно определять какое значение “&&” используется. Выделение разных значений важно, потому что если Вы думаете про «rvalue ссылку» когда видите “&&” в объявлении типа, Вы неправильно поймете большое количество C++11 кода.

Суть вопроса в том, что “&&” в объявлении типа означает rvalue ссылку, но иногда это же может означать либо rvalue ссылку, либо lvalue ссылку. Таким образом, в некоторых случаях “&&” в исходном коде может иметь значение “&”, т.е. синтаксически иметь вид rvalue ссылки (“&&”), а в действительности быть lvalue ссылкой (“&”).

Ссылки являются более гибким понятием, чем lvalue ссылки или rvalue ссылки. Так rvalue ссылки могут быть связаны только с rvalue, а lvalue ссылки, в добавление к возможности привязки к lvalue, могут быть связаны с rvalue при ограниченных условиях (ограничения на связывание lvalue ссылок и rvalue заключается в том, что такое связывание допустимо только тогда, когда lvalue ссылка объявлена как ссылка на константу, т.е. const T&.) Ссылки же, объявленные с “&&”, которые могут быть либо lvalue ссылками, либо rvalue ссылками, могут быть связаны с чем угодно. Такие необычно гибкие ссылки заслуживают своего названия. Я назвал их «универсальными» ссылками.

Подробности, когда “&&” означает универсальную ссылку (т.е. когда “&&” в исходном коде может реально означать “&”) достаточно сложны, так что я отложу их описание. А сейчас давайте сосредоточимся на следующем правиле, потому что это то, что Вы должны помнить при ежедневном программировании:

Если переменная или параметр объявлены с типом T&& для некоторого выводимого типа T, такая переменная или параметр является универсальной ссылкой.

Требование выведения типа ограничивает круг ситуаций, где могут быть универсальные ссылки. Практически, почти все универсальные ссылки — это параметры шаблонов функций. И поскольку правила выведения типа для авто-объявляемых переменных в основном те же, что и для шаблонов, возможны авто-объявленные универсальные ссылки. Они не часто встречаются в продакшн коде, но я приведу некоторые в этой статье, поскольку они менее многословны примеров с шаблонами. В разделе «Мелкие детали» данной статьи я покажу возможность возникновения универсальных ссылок в связи с использованием typedef и decltype, но пока мы не добрались до «Мелких деталей», я буду исходить из того, что универсальные ссылки относятся только к шаблонам функций и авто-объявленным переменным.

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

Как и все ссылки, универсальные ссылки должны быть инициализированы, и именно инициализатор универсальной ссылки определяет будет ли она представлять из себя lvalue ссылку или rvalue ссылку:
  • Если выражение, которым инициализируется универсальная ссылка является lvalue, то универсальная ссылка становиться lvalue ссылкой.
  • Если выражение, которым инициализируется универсальная ссылка является rvalue, то универсальная ссылка становиться rvalue ссылкой.

Эта информация полезна только в том случае, если Вы в состоянии отличить lvalue от rvalue. Точное определение этих терминов сложно выработать (С++11 стандарт дает общее определение того, является выражение lvalue или rvalue от случая к случаю), но на практике хватает следующего:
  • Если можно взять адрес выражения, то это выражение lvalue.
  • Если тип выражения является lvalue ссылкой (т.е. T& или const T&, и т.п.), то это выражение lvalue.
  • В противном случае выражение является rvalue. Концептуально (и, как правило, на самом деле), rvalue соответствуют временным объектам, таким как возвращаемым из функций или созданным путем неявного преобразования типов. Большинство литералов (например, 10 и 5.3), также rvalue.

Посмотрим еще раз на код из начала статьи:
Widget&& var1 = someWidget;
auto&& var2 = var1;

Вы можете взять адрес var1, соответственно var1 — это lvalue. Объявление типа var2 как auto&& делает var2 универсальной ссылкой, и так как она инициализируется var1 (lvalue), var2 становится lvalue ссылкой.

Небрежное чтение исходного кода может заставить Вас поверить, что var2 является rvalue ссылкой; “&&” в объявлении, конечно, наводит на эту мысль. Но так как var2 — универсальная ссылка, инициализированная lvalue, она является lvalue ссылкой. Это как если бы var2 была объявлена следующим образом:
Widget& var2 = var1;

Как отмечалось выше, если выражение имеет тип lvalue ссылки, это lvalue. Рассмотрим такой пример:
std::vector<int> v;
...
auto&& val = v[0];	// val становится lvalue ссылкой (см. ниже)

val является универсальной ссылкой и инициализирована v[0], т.е. результатом вызова std::vector<int>::operator[]. Эта функция возвращает lvalue ссылку на элемент vector (я игнорирую выход за пределы массива, что приведет к неопределенному поведению).

Так как все lvalue ссылки являются lvalue, и так как это lvalue используется для инициализации val, val становится lvalue ссылкой, хотя объявление типа val выглядит как rvalue ссылка.

Я отмечал, что универсальные ссылки наиболее распространены в параметрах шаблонов функций. Рассмотрим еще раз шаблон из начала этой статьи.
template<typename T>
void f(T&& param);	// “&&” может означать rvalue ссылку

При таком вызове f,
f(10);	// 10 является rvalue

param инициализирован литералом 10, который, по той причине, что нельзя взять его адрес, является rvalue. Это означает что в вызове f универсальная ссылка param инициализирована rvalue и, таким образом, становится rvalue ссылкой -–в частности int&&.

С другой стороны, если f вызывается как то так:
int x = 10;
f(x);		// x является lvalue

param инициализирован переменной x, которая, по той причине, что можно взять ее адрес, является lvalue. Это означает, что в данном вызове f универсальная ссылка param инициализирована lvalue, и param поэтому становится lvalue ссылкой -– int&, если быть точным.

Комментарий рядом с объявлением f теперь должен быть понятен: будет тип param lvalue ссылкой или rvalue ссылкой зависит от того, что было передано в f при вызове. Иногда param становится lvalue ссылкой, а иногда rvalue ссылкой. То есть param действительно является универсальной ссылкой.

Помните, что “&&” обозначает универсальную ссылку только тогда, когда имеет место выведение типа. Там, где нет выведения типа, нет и универсальной ссылки. В таких случаях “&&” в объявлении типа всегда означает rvalue ссылку. Следовательно:
template<typename T>
void f(T&& param);         // выведенный тип параметра ⇒ выведение типа; && ≡ универсальная ссылка

template<typename T>
class Widget {
    ...
    Widget(Widget&& rhs);   // полностью определенный тип параметра ⇒ нет выведение типа;
    ...                     // && ≡ rvalue ссылка
};
 
template<typename T1>
class Gadget {
    ...
    template<typename T2>
    Gadget(T2&& rhs);        // выведенный тип параметра ⇒ выведение типа; && ≡ универсальная ссылка
};

void f(Widget&& param);      // полностью определенный тип параметра ⇒ нет выведение типа;
                             // && ≡ rvalue ссылка

Нет ничего удивительного в этих примерах. В любом случае, если Вы видите T&& (где T — это параметр шаблона), присутствует выведение типа, поэтому Вы смотрите на универсальную ссылку. А если Вы видите “&&” после определенного имени типа (например, Widget&&), Вы смотрите на rvalue ссылку.

Я заявил, что форма декларации ссылки должна быть «T &&» для того, чтобы ссылка была универсальной. Это важный нюанс. Посмотрите еще раз на декларацию из начала этой статьи:
template<typename T>
void f(std::vector<T>&& param);  // “&&” означает rvalue ссылку

Здесь у нас есть и выведение типа и “&&”-описанный параметр функции, но форма декларации параметра не “T&&”, а “std::vector<T>&&”. В результате параметр является нормальной rvalue ссылкой, а не универсальной ссылкой. Объявление универсальной ссылки может быть только в форме “T&&”! Даже простого добавление const спецификатора достаточно, чтобы не интерпретировать “&&” как универсальную ссылку.
template<typename T>
void f(const T&& param);	// “&&” означает rvalue ссылку

“T&&” является просто необходимой формой для объявления универсальных ссылок. Это не значит, что Вы должны использовать имя T для параметров шаблона.
template<typename MyTemplateParamType>
void f(MyTemplateParamType&& param);  // “&&” означает универсальную ссылку

Иногда вы можете увидеть T&& в декларации функции шаблона, где T является параметром шаблона, но пока еще нет выведения типа. Рассмотрим функцию push_back в std::vector (показана только интересующая нас версия
std::vector::push_back):
template <class T, class Allocator = allocator<T> >
class vector {
public:
    ...
    void push_back(T&& x);	// полностью определенный тип параметра ⇒ нет выведение типа;
    ...				// && ≡ rvalue ссылка
};

Здесь T является параметром шаблона, и push_back принимает T&&. Тем не менее параметр не является универсальной ссылкой! Как это может быть?

Ответ становится очевидным, если мы посмотрим на то, как push_back будет объявлена вне класса. Я буду делать вид, что параметр Allocator отсутствует, чтобы не загромождать код. Учитывая это, ниже приводится декларация этой версии
std::vector::push_back:
template <class T>
void vector<T>::push_back(T&& x);

push_back не может существовать без класса std::vector<T>, который его содержит. Но если у нас есть класс std::vector<T>, то мы уже знаем чем является T, и таким образом, нет необходимости выводить этот тип.

Посмотрим пример. Если я напишу,
Widget makeWidget(); 		// фабричная функция для Widget
std::vector<Widget> vw;
...
Widget w;
vw.push_back(makeWidget());	// создает Widget и добавляет в vw

то мое использование push_pack скажет компилятору инстанцировать эту функцию для класса std::vector<Widget>. Ее декларация вне класса будет выглядеть так:
void std::vector<Widget>::push_back(Widget&& x);

Понимаете? Как только мы знаем, что класс — это std::vector<Widget>, тип параметра push_back полностью определен. Выведения типа не производится.
Сравните это с методом emplace_back std::vector'а, которая объявлена следующем образом:
template <class T, class Allocator = allocator<T> >
class vector {
public:
    ...
    template <class... Args>
    void emplace_back(Args&&... args);  // выведенный тип параметра ⇒ выведение типа;
    ...                                 // && ≡ универсальная ссылка
};

Не обращайте внимание на то, что emplace_back принимает переменное число аргументов (как указано в декларации Args и args). Здесь важно то, что типы для каждого из аргументов должны быть выведены. Параметр шаблона функции Args не зависит от параметра шаблона класса T, таким образом, даже если класс полностью известен, скажем std::vector<Widget>, это ничего не говорит о типе (типах) аргументов emplace_back. Объявление emplace_back вне класса для std::vector<Widget> явно это показывает (я продолжаю игнорировать существование параметра Allocator):
template<class... Args>
void std::vector<Widget>::emplace_back(Args&&... args);

Очевидно, знание того, что класс — это std::vector< Widget >, не устраняет необходимости вывода типа (типов), передаваемых в emplace_back. В результате параметры std::vector::emplace_back являются универсальными ссылками в отличии от параметра версии std::vector::push_back, который, как мы увидели, является rvalue ссылкой.

Следует иметь в виду то, что является ли выражение lvalue или rvalue не зависит от его типа. Рассмотрим тип int. Есть lvalue типа int (например, переменные, объявленные int) и есть rvalue типа int (например литералы, например, 10). Это справедливо и для пользовательских типов, как Widget. Объект Widget может быть lvalue (например, переменная Widget) или rvalue (например, функция-фабрика вернула созданный Widget объект). Тип выражения не скажет Вам, является ли это lvalue или rvalue.
Widget makeWidget();                 // фабричная функция для Widget
Widget&& var1 = makeWidget();        // var1 является lvalue, но тип
                                     // var1 – это rvalue ссылка (на Widget)
Widget var2 = static_cast< Widget&& >(var1);
                                     // cast выражение дает rvalue, но
                                     // его тип - это rvalue ссылка (на Widget)

Общепринятым способом превратить lvalue (например var1) в rvalue является использование std::move, так var2 может быть определена следующим образом:
Widget var2 = std::move(var1);	// эквивалентно коду выше

Я изначально привел код с использованием static_cast только для того, чтобы явно показать, что типом выражения является rvalue ссылка (Widget&&).

Именованные переменные и параметры типа rvalue ссылка являются lvalue. (Вы можете получить их адрес.) Еще раз рассмотрим Widget и Gadget шаблоны:
template<typename T>
class Widget {
    ...
    Widget(Widget&& rhs);        // тип rhs - rvalue ссылка,
    ...                          // но rhs является lvalue
};
 
template<typename T1>
class Gadget {
    ...
    template <typename T2>
    Gadget(T2&& rhs);// rhs является универсальной ссылкой чей тип
    ...              // в конечном итоге станет rvalue ссылкой или
};                   // lvalue ссылкой, но rhs является lvalue

В конструкторе Widget rhs является rvalue ссылкой, так что мы знаем, что оно связано с rvalue (т.е. было передано rvalue), но само rhs является lvalue, поэтому мы должны преобразовать его обратно в rvalue, если мы хотим получить преимущества от того, что rhs связано с rvalue. Наше желание, как правило, обусловлено требованием использовать rhs в качестве источника переноса, поэтому для преобразования lvalue в rvalue применяется std::move. Подобным образом rhs в конструкторе Gadget является универсальной ссылкой, и следовательно оно может быть связано с lvalue или с rvalue, но в любом случае само rhs является lvalue. Если оно связано с rvalue и мы хотим получить преимущества от этого, мы должны преобразовать rhs обратно в rvalue. Однако, если оно связано с lvalue, мы конечно не хотим трактовать его как rvalue. Такая зависимость от того, с чем связана универсальная ссылка, служит причиной для использования std::forward: взять универсальную ссылку и преобразовать ее в rvalue только в том случае, если она связана с rvalue выражением. Название функции (“forward”) подтверждает наши ожидания, что она выполнит пересылку в другую функцию, всегда сохраняя тип ссылки аргумента (lvalue или rvalue).

Но std::move и std::forward не являются предметом данной статьи. Статья повествует о том факте, что "&&" в объявления типа может или не может описывать rvalue ссылку. Чтобы не отвлекаться я отсылаю Вас к ссылкам в разделе «Дополнительная информация» для подробного описания std::move и std::forward.

Мелкие детали

Суть вопроса в том, что некоторые конструкции в C++11 порождают ссылки на ссылки, а ссылки на ссылки не допускаются в C++. Если исходный код явно содержит ссылку на ссылку — код не верен:
Widget w1;
...
Widget&  & w2 = w1;	// Ошибка! Нет такого понятия как “ссылка на ссылку”

Однако есть случаи, где ссылки на ссылки возникают в результате манипуляций с типами, которые происходят во время компиляции, и в таких случаях, отвергнуть этот код будет проблематично. Мы это знаем из опыта первоначального стандарта для C++, т.е., C++98/C++03.

Во время выведения типа для параметров шаблона, который является универсальной ссылкой, lvalue и rvalue одного типа выводятся в несколько различных типов. В частности, lvalue типа T выводятся как тип T& (т.е. lvalue ссылка на T), а rvalue типа T выводятся просто как тип T. (Обратите внимание, что lvalue выводится как lvalue ссылка, rvalue не выводятся как rvalue ссылка!) Рассмотрим что происходит при вызове шаблонной функции, принимающей универсальную ссылку, с rvalue и lvalue:
template<typename T>
void f(T&& param);
...
int x;
...
f(10);         // вызывается f с rvalue
f(x);          // вызывается f с lvalue

В вызове f с rvalue 10 T выводится как int и интстансирование f выглядит так:
void f(int&& param);	// f инстанцированная из rvalue

Это хорошо. Однако в вызове f с lvalue x, T выводится как int&, и интстансирование f содержит ссылку на ссылку:
void f(int& && param);	// первоначальное инстанцирование f с lvalue

Из-за ссылки на ссылку этот код экземпляра выглядит на первый взгляд неверным, но исходный код “f(x)” – это вполне разумно. Чтобы не отвергать этот код, C++ выполняет “свертывание ссылок” когда возникает ссылка на ссылку в контекстах, таких как инстанцирование шаблона.

Так как есть два вида ссылок (lvalue ссылки и rvalue ссылки) существует четыре возможных комбинации ссылки на ссылку: lvalue ссылка на lvalue ссылку, lvalue ссылка на rvalue ссылку, rvalue ссылка на lvalue ссылку и rvalue ссылка на rvalue ссылку. Есть только два правила свертывания ссылок:
  • Rvalue ссылка на rvalue ссылку становится (“сворачивается в”) rvalue ссылкой.
  • Все остальные ссылки на ссылки (т.е. все комбинации с участием lvalue ссылки) сворачиваются в lvalue ссылку.

Применение этих правил к инстанцированию f с lvalue дает следующий правильный код:
void f(int& param);  // инстанцирование f с lvalue после свертывания ссылок

Это дает точный механизм, которым универсальная ссылка может (после выведения типа и свертывания ссылок) быть превращена в lvalue ссылку. В действительности универсальная ссылка — это просто rvalue ссылка в контексте свертывания ссылок.

Особая ситуация, когда выводится тип для переменных, которые являются ссылками. В таком случае, часть типа, обозначающая ссылку, игнорируется. Например, если
int x;
...
int&& r1 = 10;	// тип r1 - int&&
int& r2 = x;	// тип r2 - int&

то тип как для r1, так и для r2 при вызове шаблона f считается int. Такое поведение отбрасывания ссылок не зависит от правил выведения типа для универсальных типов, lvalue выводятся как тип T&, а rvalue как тип T, и таким образом в этих вызовах,
f(r1); 
f(r2);

выведенный тип как для r1, так и для r2 будет int&. Почему? Во-первых, ссылочная часть типов r1 и r2 отбрасывается (получается int в обоих случаях), затем, так как это lvalue, оба рассматриваются как int& во время выведения типа для параметра-универсальной ссылки в вызове f.

Свертывание ссылок происходит, как я отметил, в “контекстах, таких как инстанцирование шаблона”. Второй такой контекст – это определение “auto” переменных. Выведение типа для auto переменных, которые являются универсальными ссылками, по сути идентично выведению типа для параметров шаблонов функций, которые являются универсальными ссылками, так lvalue типа T выводится как имеющее тип T&, а rvalue типа T выводится как имеющее тип T&. Рассмотрим еще раз пример из начала статьи:
Widget&& var1 = someWidget; // var1 имеет тип Widget&& (auto не используется)
auto&& var2 = var1;	    // var2 имеет тип Widget& (см. ниже)

Тип var1 — Widget&&, но его “ссылочная часть” игнорируется во время выведения типа при инициализации var2; он считается типом Widget. Так как это lvalue, которое используется для инициализации универсальной ссылки (var2), выведенный тип будет Widget&. Подставляя Widget& вместо auto в определении var2, получим следующий неверный код,
Widget& && var2 = var1;		// обратите внимание на ссылку на ссылку

который после сворачивания ссылок станет
Widget& var2 = var1;		// var2 имеет тип Widget&

Третий контекст сворачивания ссылок – это формирование и использование typedef. Учитывая этот шаблон класса
template<typename T>
class Widget {
    typedef T& LvalueRefType;
    ...
};

и такое использование этого шаблона,
Widget<int&> w;

инстанцированный класс будет содержать такой (неверный) typedef:
typedef int& & LvalueRefType;

Сворачивание ссылок приводит к следующему верному коду:
typedef int& LvalueRefType;

Если мы затем будем использовать этот typedef в контексте с использованием ссылок на него, например,
void f(Widget<int&>::LvalueRefType&& param);

после развертывания typedef будет создан следующий неверный код,
void f(int& && param);

но сворачивание ссылок урежет его и окончательное объявление f будет:
void f(int& param);

Последний контекст, где применяется сворачивание ссылок – это использование decltype. Как и в случаях с шаблонами и auto, decltype выполняет выведение типа выражения, которое дает типы либо T, либо T&, и decltype затем применяет правила сворачивания ссылок C++11.

К сожалению, правила сворачивания ссылок, применяемые decltype не те, что используются при выведении типа для шаблона или auto типа. Подробности слишком сложны для обсуждения здесь (в разделе «Дополнительная информация» приводятся ссылки для деталей), но заметная разница в том, что decltype для именованной переменной не ссылочного типа выводит тип T (т.е. не ссылочный тип), когда при некоторых условиях шаблоны и auto-типы выводят тип T&. Другое важное различие в том, что выведение типа decltype зависит только от decltype выражения; тип инициализирующего выражения (если оно есть) игнорируется. Следовательно:
Widget w1, w2;
auto&& v1 = w1;	// v1 является универсальной ссылкой,
                // инициализированной lvalue, соответственно v1
                // является lvalue ссылкой на w1.
 
decltype(w1)&& v2 = w2; // v2 является универсальной ссылкой, и decltype(w1) это Widget, 
                        // таким образом v2 является rvalue ссылкой.
                        // w2 это lvalue, и недопустимо инициализировать
                        // rvalue ссылку lvalue, таким образом код не будет компилироваться.


Заключение

В описании типа “&&” означает либо rvalue ссылку, либо универсальную ссылку – ссылку, которая быть или lvalue ссылкой или rvalue ссылкой. Универсальные ссылки всегда имеют форму T&& для некоторого выведенного типа T.

Сворачивание ссылок – это механизм приведения универсальных ссылок (которые являются просто rvalue ссылками в ситуациях, когда применяется сворачивание ссылок) иногда к lvalue ссылкам, а иногда к rvalue ссылкам. Он используется в специальных контекстах, в которых в результате компиляции могут появиться ссылки на ссылки. Это контексты выведения типа шаблона, выведения auto-типа, формирования и использования typedef и выражения decltype.

Благодарности

Черновые версии данной статьи рецензировались Cassio Neri, Michal Mocny, Howard Hinnant, Andrei Alexandrescu, Stephan T. Lavavej, Roger Orr, Chris Oldwood, Jonathan Wakely и Anthony Williams. Их замечания способствовали существенным улучшениям статьи, а также ее презентации.

Дополнительная информация

C++11, Wikipedia.

Overview of the New C++ (C++11), Scott Meyers, Artima Press, last updated January 2012.

C++ Rvalue References Explained, Thomas Becker, last updated September 2011.

decltype, Wikipedia.

“A Note About decltype,” Andrew Koenig, Dr. Dobb’s, 27 July 2011.
Tags:
Hubs:
+50
Comments 68
Comments Comments 68

Articles