Ідеальна передача і універсальні посилання в C++

Нещодавно на isocpp.org була опублікована посилання на статтю Eli Bendersky «Perfect forwarding and universal references in C++». У цій невеликій статті є проста відповідь на просте питання — для вирішення яких завдань і як потрібно використовувати rvalue.

Одне з нововведень C++11, яке націлене на збільшення ефективності програм — це сімейство методів emplace біля контейнерів STL. Наприклад, у std::vector з'явився метод emplace_back (практично аналог методу push_back) і метод emplace (практично аналог методу insert).
Ось невеликий приклад, що показує призначення цих нових методів:
class MyKlass {
public:
MyKlass(int ii_, float ff_) {...}
private:
{...}
};

some function {
std::vector<MyKlass> v;
v.push_back(MyKlass(2, 3.14 f));
v.emplace_back(2, 3.14 f);
}

Якщо простежити за викликами конструкторів і деструкторів класу MyKlass, під час виклику push_back можна побачити наступне:

  • Спочатку виконується конструктор тимчасового об'єкта класу MyKlass
  • Потім, для об'єкта, розташованого безпосередньо всередині вектора, викликається конструктор переміщення (якщо такий визначено в MyClass, якщо не визначений, тоді викликається конструктор копіювання)
  • Деструктор тимчасового об'єкта
Як видно, виконується досить багато роботи, більшу кількість якої не дуже то й потрібно, так як об'єкт, який передається в метод push_back, очевидно є rvalue-посиланням, і знищується відразу після виконання цього виразу. Таким чином, немає ніякої причини створювати і знищувати тимчасовий об'єкт. Чому ж, в цьому випадку, не створити об'єкт відразу усередині вектора? Це саме те, що робить метод emplace_back. Для висловлення з прикладу v.emplace_back(2, 3.14 f) виконається тільки один конструктор, який створює об'єкт всередині вектора. Без використання тимчасових об'єктів. emplace_back сам викликає конструктор MyKlass і передає йому потрібні аргументи. Це поведінка стало можливим завдяки двом нововведенням С++11: шаблони із змінною кількістю аргументів (variadic templates) і ідеальна передача (perfect forwarding). В даній статті я хочу пояснити, як працює ідеальна передача і як її використовувати.

Проблема ідеальної передачі

Припустимо, що є деяка функція func, приймаюча параметри типів E1, E2, ..., En. Потрібно створити функцію wrapper, приймаючу такий же набір параметрів. Іншими словами — визначити функцію, яка передасть прийняті параметри в іншу функцію, не створюючи тимчасові змінні, тобто виконає ідеальну передачу.
Для того щоб конкретизувати завдання, розглянемо метод emplace_back, який був описаний вище. vector::emplace_back передає свої параметри конструктору T не знаючи нічого про те, чим є T.
Наступним кроком розглянемо кілька прикладів, які показують, як можна домогтися подібної поведінки без використання нововведень З++11. Для спрощення не будемо враховувати необхідність використання шаблонів із змінною кількістю параметрів аргументів, наприклад, потрібно передати тільки два аргументи.
Перший варіант, який приходить на розум:
template < typename T1, typename T2>
void wrapper(T1, e1, T2 e2) {
func(e1, e2);
}

Але це, очевидно, не буде працювати як треба, якщо func приймає параметри за посиланням, так як wrapper приймає параметри за значенням. У цьому випадку, якщо func змінює одержувані за посиланням параметри, це не позначиться на параметрах, переданих у wrapper (будуть змінені копії, створені всередині wrapper).
Добре, тоді ми можемо переробити wrapper, щоб він приймав параметри за посиланням. Це не буде перешкодою, якщо func буде приймати не по посиланню, а за значенням, так як func всередині wrapper зробить собі необхідні копії.
template < typename T1, typename T2>
void wrapper(T1& e1, T2& e2) {
func(e1, e2);
}

Тут інша проблема. Rvalue не може бути передано в функцію в якості посилання. Таким чином цілком тривіальний виклик не відбудеться створення:
wrapper(42, 3.14 f); // помилка: ініціалізація неконстантной посилання rvalue-значенням
wrapper(i, foo_returning_float()); // та ж помилка

І відразу немає, якщо прийшла думка зробити ці посилання константними — це теж не вирішить проблему. Тому що func може вимагати в якості параметрів неконстантние посилання.
Залишається тільки грубий підхід, який використовується в деяких бібліотеках: перевантажити функцію для константных не неконстантных посилань:
template < typename T1, typename T2>
void wrapper(T1& e1, T2& e2) { func(e1, e2); }

template < typename T1, typename T2>
void wrapper(const T1& e1, T2& e2) { func(e1, e2); }

template < typename T1, typename T2>
void wrapper(T1& e1, const T2& e2) { func(e1, e2); }

template < typename T1, typename T2>
void wrapper(const T1& e1, const T2& e2) { func(e1, e2); }

Експонентний ріст. Можна уявити, скільки веселощів це доставить, коли потрібно обробити якесь розумне кількість параметрів реальних функцій. Щоб погіршити ситуацію З++11 додає rvalue посилання, які теж потрібно врахувати функції wrapper, і це точно не є розширюваним рішенням.

Стиснення посилань і особливий висновок типу для rvalue-посилань

Для пояснення того, як в С++11 реалізується ідеальна передача, потрібно спочатку зрозуміти два нових правила, які були додані в цю мову програмування.
Почнемо з простого — стиснення посилань (reference collapsing). Як відомо, взяття посилання на посилання в С++ не допускається, але це іноді може відбуватися при реалізації шаблонів:
template < typename T>
void baz(T t) {
T& k = t;
}

Що трапиться, якщо викликати цю функцію наступним чином:
int ii = 4;
baz<int&>(ii);

При инстанцировании шаблону T встановиться рівним int&. Який же тип буде у змінній k всередині функції? Компілятор «побачить» int& & — а так як це заборонена конструкція, компілятор просто перетворює це в звичайну посилання. Фактично, до С++11 таку поведінку не було стандартизованим, але багато компілятори брали і перетворювали такий код, так як він часто зустрічається в метапрограммировании. Після того, як в С++11 були додані rvalue-посилання, стало важливим визначити поведінку при суміщенні різних типів посилань (наприклад, що означає int&& & ?).
Так з'явилося правило стиснення посилань. Це правило дуже просте — одиночний амперсанд (&) завжди перемагає. Таким чином — ( & & ) це (&), також як і ( & & & ), і ( & & & ). Єдиний випадок, при якому в результаті стиснення виходить (&&) — це ( & & & & ). Це правило можна порівняти з результатом виконання логічного АБО, у якому & це 1, а && це 0.
Інше доповнення З++, що має пряме відношення до розглянутої теми — це правила особливого виводу типу (special type deduction rules) для rvalue-посилань у різних випадках [1]. Розглянемо приклад шаблонної функції:
template < class T>
void func(T&& t) {
}

Не дозволяйте подвійним амперсанду обдурити Вас — t тут не є rvalue-посиланням [2]. При появі в даній ситуації (коли необхідний особливий висновок типу), T&& приймає особливе значення — коли func инстанцируется, T змінюється в залежності від переданого типу. Якщо була передана lvalue типу U, то Т стає U&. Якщо ж U це rvalue, то Т стає просто U. Приклад:
func(4); // 4 це rvalue: T стає int

double d = 3.14;
func(d); // d це lvalue; T стає double&

float f() {...}
func(f()); // f() це rvalue; T стає float

int bar(int i) {
func(i); // i це lvalue; T стає int&
}


Це правило може здатися незвичайним і навіть дивним. Воно таке і є. Але, тим не менш, це правило стає цілком очевидним, коли приходить розуміння, що це правило допомагає вирішити проблему ідеальної передачі.

Реалізація ідеальної передачі з використанням std::forward

Тепер давайте повернемося до нашої описаної вище шаблонної функції wrapper. Ось як вона повинна бути реалізована з використанням С++11:
template < typename T1, typename T2>
void wrapper(T1&& e1, T2&& e2) {
func(forward<T1>(e1), forward<T2>(e2));
}

А ось як реалізований forward [3]:
template < class T>
T&& forward(typename std::remove_reference<T>::type& t) noexcept {
return static_cast<T&&>(t);
}

Розглянемо наступний виклик:
int ii ...;
float ff ...;
wrapper(ii, ff);

Розглянемо перший аргумент (другий аналогічний): ii є lvalue, таким чином T1 стає int& згідно з правилом особливого виводу типу. Виходить виклик func(forvard<int&>(e1), ...). Таким чином, шаблон forward инстанцирован типом int& і отримуємо наступну версію цієї функції:
int& && forward(int& t) noexcept {
return static_cast<int& &&>(t);
}

Час застосувати правило стиснення посилань:
int& forward(int& t) noexcept {
return static_cast<int&>(t);
}

Іншими словами, аргумент переданий по посиланню в func, як і потрібно для lvalue.
Наступний приклад:
wrapper(42, 3.14 f);

Тут аргументи є rvalue, таким чином T1 стає int. Отримуємо виклик func(forward(e1), ...). Таким чином, шаблонна функція forward инстанцирована типу int і отримуємо наступну версію функції:
int&& forward(int& t) noexcept {
return static_cast<int&&>(t);
}

Аргумент, отриманий за посиланням, приводиться до rvalue-ссылке, яку і потрібно отримати від forward.
Шаблонну функцію forward можна розглядати як деяку обгортку над static_cast<T&&>(t), коли T може прийняти значення U& або U&&, в залежності від типу вхідного аргументу (lvalue або rvalue). Тепер wrapper є одним шаблоном, який обробляє будь-які поєднання типів аргументів.
Шаблонна функція forward реалізована в С++11, заголовочном файлі, в просторі імен std.

Ще один момент, який потрібно відзначити: використання std::remove_reference. Насправді forward може бути реалізований і без використання цієї функції. Стиснення посилань виконає всю роботу, таким чином, застосування std::remove_reference для цього надміру. Однак, ця функція дозволяє вивести T& t в ситуації, коли цей тип не може бути виведений (згідно стандарту З++, 14.8.2.5), тому необхідно явно вказувати параметри шаблону при виклику std::forward.

Універсальні посилання

У своїх виступах, постах у блозі і книгах, Скотт Майєрс дає найменування «універсальні посилання» (universal reference) для rvalue, які в контексті виведення типів. Вдале це найменування чи ні, важко сказати. Що стосується мене, коли я перший раз прочитав відноситься до даної теми голову з нової книги «Effective C++», я відчув, що заплутався. Більш-менш стало зрозуміло пізніше, коли я розібрався з лежачими в основі цього механізмами (стиснення посилань і правил особливого виведення типів).
Пастка полягає в тому, що фраза «універсальні посилання» [4] звичайно, більш коротка і красива, ніж «rvalue-посилання в контексті виведення типів». Але якщо є бажання насправді зрозуміти деякий код, не вийде уникнути повного опису.

Приклади використання ідеальної передачі

Ідеальна передача досить корисна, тому що робить можливим програмування на більш високому рівні. Функції вищого порядку — це функції, які можуть прийняти інші функції в якості аргументів або повертати їх. Без ідеальної передачі, застосування функцій вищого порядку досить обтяжливо, так як немає зручного способу передати аргументи на функцію всередині функції-обгортки. Під терміном «функція» я тут крім самих функцій також маю на увазі і класи, конструктори яких фактично теж є функціями.
На початку цієї статті я описував метод контейнерів emplace_back. Інший хороший приклад — це стандартна шаблонна функція make_unique, яку я описував в попередній статті:
template < typename T, typename... Args>
unique_ptr<T> make_unique(Args&&... args)
{
return unique_ptr<T>(new T(std::forward<Args>(args)...));
}

Зізнаюся чесно, що в тій статті я просто ігнорував дивний подвійний амперсанд і фокусувався на змінному кількості аргументів шаблону. Але зараз зовсім нескладно повністю зрозуміти код. Само собою зрозуміло, що ідеальна передача і шаблони із змінною кількістю аргументів дуже часто використовуються разом, тому що, в більшості випадків невідомо, яку кількість аргументів приймають функція або конструктор, яким ми передаємо ці аргументи.
В якості прикладу зі значно більш складним використанням ідеальної передачі Ви можете подивитися реалізацію std::bind.

Посилання на джерела

Ось деякі джерела, які мені дуже допомогли при підготовці матеріалу:
  1. The 4th edition of «The C++ Programming Language» by Bjarne Stroustrup
  2. The new «Effective Modern C++» by Scott Myers. У цій книзі широко обговорюються «універсальні посилання». Фактично, цій темі присвячено більше п'ятої частини цієї книги.
  3. Technical paper n1385: «The forwarding problem: Arguments».
  4. Thomas Becker C++ Rvalue references explained — чудово написана і дуже корисна стаття
Примітки:
[1] Також може застосовуватися auto і decltype, тут я описую тільки випадок використання шаблону.
[2] Я вважаю невдалим рішення комітету стандартизації С++ за вибором для позначення rvalue (перевантаження &&). Скотт Майерс зізнався у своєму виступі (і трохи коментував у своєму блозі), що після 3 років цей матеріал досі непростий у вивченні. І Бьерн Страуструп в The 4th edition of «The C++ Programming Language» при описі std::forward забув вказівка аргументу шаблону. Можна зробити висновок, що це дійсно досить непроста область.
[3] Це спрощена версія std::forward з STL C++11. Там ще є додаткова версія, явно перевантажена для rvalue аргументів. Я до сих пір намагаюся розібратися, навіщо вона потрібна. Дайте знати, якщо є яка-небудь ідея.
[4] Передаються посилання (forwarding references) — ще одне позначення, яке я зустрічав

Від перекладача: на CppCon2014 багатьма (у тому числі Меєрсом, Страуструпом, Саффером) було прийнято рішення використовувати термін forwarding references замість universal references.

Пара статей на хабре по даній темі:
Короткий вступ в rvalue-посилання
«Універсальні» посилання в C++11 або T&& не завжди означає «Rvalue Reference»

Джерело: Хабрахабр

0 коментарів

Тільки зареєстровані та авторизовані користувачі можуть залишати коментарі.