![[personal profile]](https://www.dreamwidth.org/img/silk/identity/user.png)
Rvalue References і Move Semanics, мабуть, одні із найскладніших найпростіших нововведень стандарту C++11. Про них усі говорять, про них написано багато статей і туторіалів. Я уже принаймі чотири рази все прояснив для себе але кожного разу коли стикаюсь з цими поняттями виникає відчуття що я нічорта не розумію. Судячи із кількості туторіалів, я такий не один :)
- Оптимізація: RVO, NRVO
- Семнатика move, Rvalue References
- Perfect Forwarding
- "Канонічне Присвоювання"
- Move Enabled Swap
- Матеріали
Кажуть, щоб добре розібратись у якій-небуть темі треба її пояснити іншій людині. Я зараз постійно стикаюсь з таким на роботі: коли тобі задають питання по коду в процесі рев’ю намагаєшся зробити відповідь максимально повною і обґрунтованою, щоб уникнути наступних таких-же питаннь, і в процесі написання відповіді часто знаходяться найтупіші помилки або непахані поля для оптимізації. Так що вибачте, присяду на вуха і спробую розкрити цю темну тему.
Спочатку наведу приклад простенького коду і зроблю певні припущення про те як він буде працювати:
#include <iostream>
class Foo {
public:
Foo(int a) : v(a) { std::cout << "Foo::Foo(" << v << ")\n"; }
Foo(const Foo & f) : v(f.v) { std::cout << "Foo::Foo() - copy, v = " << v << "\n"; }
Foo(Foo && f) : v(f.v) { std::cout << "Foo::Foo() - move, v = " << v << "\n"; }
~Foo() { std::cout << "Foo::~Foo() - v = " << v << "\n"; }
void set(int a) { v = a; }
private:
int v;
};
Foo create(int v)
{
return Foo(v); // Construction, no copies, no destruction - RVO
}
/*
Foo create(int v)
{
Foo foo(v); // Construction
return foo; // No copies, no destruction - NRVO
}
*/
Foo mutate(Foo foo) // Copy construction
{
foo.set(1);
return foo; // Move construction
} // Destruction
int main()
{
Foo foo = create(5); // Construction
Foo bar = mutate(foo); // One copy construction, one move construction, one destruction
Foo baz = mutate(create(6)); // Construction, one move construction, one destruction
return 0; // Three destructions
}
|
_Winnie C++ Colorizer |
Давайте для початку розберемось, що таке lvalue і що таке rvalue. Дати точне визначення lvalue досить складно, тому я почну з rvalue: rvalue це все що не lvalue :) А що ж тоді lvalue? Я іноді зустрічав таке визначення: lvalue може знаходитись по обидва боки оператора присвоювання, тоді як rvalue тільки справа. Та це не завжди так! Згадайте про кваліфікатор const і про типи даних користувача для яких не визначено конструктор копії і оператор присвоювання. Значення таких типів запросто можуть бути lvalue, але ніколи не стануть зліва від оператора присвоювання. Найбільш точним визначенням я вважаю таке: lvalue це дані, для яких ми завжди можемо визначити адресу розташування. На противагу цьому rvalue це майже завжди тимчасові об’єкти. За часів стандарту C++03 до категорії rvalue попадали виключно тимчасові об’єкти, але у C++11 це вже не так. Давайте розглянемо кілька прикладів:
class Bar {
public:
Bar() : v(2) {}
int & value() { return v; }
private:
int v;
};
int foo()
{
return 1;
}
int main()
{
int a;
a = 0; // 'a' is a lvalue, '0' is a rvalue
1 = a; // '0' is not a lvalue, compilation error
Bar bar;
a = bar.value(); // Both 'a' and 'bar.value()' are lvalues
bar.value() = 3; // 'bar.value()' is a lvalue, '3' is a rvalue
a = foo(); // 'a' is a lvalue
foo() = 4; // 'foo()' is not a lvalue, compilation error
a = 2 + 2; // 'a' is lvalue, '2 + 2' is rvalue
(2 + 2) = a; // '(2 + 2)' is not a lvalue, compilation error
return 0;
}
|
_Winnie C++ Colorizer |
Тут, думаю, все очевидно і зрозуміло. Досвідчені C++-програмісти знають, що у C++ основа всього копіювання. З ним ми зустрічаємось усюди: у передачі аргументів у функцію, у поверненні знічення із функції, у операторі присвоювання, у алгоритмах і контейнерах STL... Але копіювання часто призводить до зменшення швидкодії, і його намагаються уникати. Так, "важкі" значення передають у функції по константному посиланню. Посилання працює як безпечний вказівник, а константність гарантує що функція не вплине на контекст виклику. Із повертанням даних із функції усе складніше... Уявіть що у нас є функція яка генерує растрове зображення на пару гігабайт копіювати його дуже дорого і неефективно! Тому часто можна побачити такий код:
void getImage(std::vector<uint32_t> & image)
{
...
}
...
std::vector<uint32_t> image;
getImage(image);
|
_Winnie C++ Colorizer |
Не дуже красиво, еге-ж? На скільки все-таки зручніше і очевидніше просто повертати у цьому випадке std::vector<uint32_t>! І, насправді, це можна робити! У стандарті це не обумовлено, але більшість компіляторів вміють RVO і NRVO "Return Value Optimization" і "Named Return Value Optimization". Давайте поглянемо на функцію "create" із першого прикладу. Foo(v) представляє собою тимчасовий об’єкт, у функції він більше не доступний і ніхто не зможе отримати до нього доступ звідси, тому компілятор створює цей об’єкт не в середині функції (на її стеку) а у контексті її виклику (на стеку викликаючої функції). Це і є RVO. У нього є кілька обмеженнь. А саме: функція повина мати тільки одну точку виходу, повертатися має тимчасовий об’єкт. Звісно, у реальному житті таке буває не часто, і щоб обійти друге обмеження придумали NRVO. Подивіться на закоментований варіант реалізації "create" тут ми повертаємо уже зовсім не тимчасовий об’єкт. Здавалося б, foo буде створено на стеку "create" і після return буде двічі викликано оператор копії: перший скопіює foo у тмчасовий об’єкт, а другий скопіює тимчасовий об’єкт у повноцінне lvalue. В реальності ця реалізація працює так само як і перша. У NRVO теє є обмеження: відсутність умовної ініціалізації, відстуність exceptions, єдина точка вихода... В принципі, кожен компілятор сам вирішує коли можна застосовувати RVO/NRVO а коли ні.
Проблему копій у більшості випадків допомагає вирішити move-семантика. Це дуже проста штука! Уявіть що у нас є "важкий" об’єкт (наприклад, те саме зображення на пару Гб) і після передачі його у функцію (або при поверненні із функції) воно нам більше не потрібне. Навіщо його копіювати, коли можна просто перемістити? У більшості випадків "важкі" об’єкти зберігають не дані а тільки певний handle вказівник, файловий дескриптор, ідентифікатор нитки... І сам по собі цей handle копіюється без зайвих проблем. Мабуть, першою спробою реалізації move-семантики були auto_ptr і CoW "Copy-on-Write". Але через свою неочевидну поведінку вони створювали проблеми при використанні. Так, наприклад, auto_ptr не можна використовувати у контейнерах STL, а CoW... Про CoW у мене є стара байка: "exit vs. return and copy-on-write ". Нічого страшного, звісно, але поведінка не стандартна.
Для повноцінної підтримки move-семантики як раз і знадобились Rvalue References. Вони дозволяють відрізнити lvalue від rvalue на рівні типу. Тобто визначити, коли ми працюємо з тимчасовим об’єктом, а коли зі звичайним. Звучить досить просто. Та воно і дійсно просто:
int foo()
{
return 2;
}
int main()
{
int a = 0;
a = foo();
int & b = a;
/*
int & c = foo(); // Oops...
*/
int && d = foo(); // ok
const int & e = foo(); // Hm...
/*
e = 3; // Oops...
*/
d = 4; // ok!
return 0;
}
|
_Winnie C++ Colorizer |
Не дуже-то воно виглядає корисним, еге-ж? Але так їх ніхто не використовує, насправді. Найбільша користь від rvalue references у сигнатурах функцій. Давайте ще раз поглянемо на перший приклад. Там визначено конструктор копії, і ще один конструктор такий же як конструктор копії, але з неконстантим rvalue reference в якості аргументу. Взагалі-то кажучи, константний rvalue reference це абсолютно безґлузда штукенція, я пізніше поясню чому. Повернемось до нашго конструктора. Конструктор такого виду називається move-конструктором, по аналогії з copy-конструктором. Виклик такого конструктору означає що об’єкт конструюється із тимчасового об’єкту, нікому вже не потрібного і до якого ніхто не матиме доступу (я не пишу "гарантовно"). А значить "важку" частину об’єкту можна безсовісно і безнаказно вкрасти! Що, зазвичай і робиться. Тягнуть усе вказівники на пам’ять, файлові дескриптори, хендли. Дійшло до того що крадуть навіть звичайні POD-значення! "А дай телефончик, мамі подзвонити. Та дай, не бійся, тобі він все одно уже не потрібен. Навіщо мертвим телефони...". І дійсно, ніж розподіляти 2 Гб пам’яті під зображення, копіювати туди дані а потім звільняти пам’ять з-під оригінального зображення (бо тимчасовий об’єкт не живе, він народжений одразу вмерти) краще забрати собі оригінальний вказівник, а його володарю якось пояснити щоб він пам’ять не чіпав. Бо то не його вже пам’ять. "Это наша корова, и мы ее доим!". Зазвичай для цього вказівник у колишнього володаря просто занулюють, адже delete можна викликати з nullptr (чи NULL для слоупоків) у якості аргументу. Іноді доадтково встановлюють флаг, на кшталт "bool moved" щоб позначити об’єкт як "переміщений" і не виконувати зайвої і вкрай небезпечної роботи у деструкторі. Власне тому ніхто не використовує rvalue refernces з кваліфікатором const щоб була можливість змінити об’єкт "по той бік Стіксу".
Добре, досить чорного гумору. Rvalue references корисні не тільки для move-конструкторів чи відповідних операторів присвоювання. Їх можна використовувати для звичайних функцій, методів чи конструкторів ініціалізації для ефективної передачі параметрів. Але тут є один нюанс дуже неприємний.
#include <iostream>
#include <string>
class Stock {
public:
Stock(const std::string & name, const std::string & issuer)
: m_name(name),
m_issuer(issuer)
{
std::cout << "Stock('" << name << "', '" << issuer << "'), by const ref\n";
}
Stock(std::string && name, std::string && issuer)
: m_name(name),
m_issuer(issuer)
{
std::cout << "Stock('" << name << "', '" << issuer << "'), by rvalue ref\n";
}
private:
std::string m_name;
std::string m_issuer;
};
std::string getIssuer()
{
return "test";
}
std::string getStockName()
{
return "GOOG.O";
}
int main()
{
std::string issuer = "mms";
std::string name = "IBM.N";
Stock ibm(name, issuer); // ok
Stock goog(getStockName(), getIssuer()); // ok
Stock goog1(getStockName(), issuer); // hm...
}
|
_Winnie C++ Colorizer |
goog1 буде сконструйовано конструктором із константними посиланнями, хоча попередній виклече конструктор із rvalue references. Проблема у тому що один із параметрів не є rvalue. Що ж робити? Робити overload конструкторів для всіх можливих комбінацій? Це не вихід.
Насправді, у наведеному вище прикладу все навіть гірше. Ніякої користі від конструктора з rvalue references немає. Як ви думаєте, параметри name і issuer у цьому конструкторі lvalue чи rvalue? Насправді lvalue. Бо вони вже іменовані. А там де є ім’я там можна і адресу отримати, і ніякого тимчасового об’єкту там уже немає. Не існує у природі іменованих rvalues. І конструктори std::string будуть викликані самі звичайні. Що ж робити? Для вирішення цієї проблеми є функція std::move. Вона перетворює будь-яке lvalue на rvalue. Тому перед передачею у конструктори std::string ці параметри треба "обгорнути" у std::move. Тільки тут треба бути дуже обережним, адже після цього параметри стануть "мертвими"! Краще за все вважати що після std::move імена більше ні з чим не зв’язані і для об’єктів на які вони посилались викликана деструкція. Це не завжди так, але так безпечніше.
А що робити із попередньою проблемою? Звісно, робити overload по всім комбінаціям параметрів не треба. Все виявляється набагато простіше, достатньо лише одного варіанту конструктора. Із передачею аргументів по значенню.
#include <iostream>
#include <string>
#include <utility>
class Stock {
public:
Stock(std::string name, std::string issuer)
: m_name(std::move(name)),
m_issuer(std::move(issuer))
{
std::cout << "Stock('" << m_name << "', '" << m_issuer << "'), by value\n";
}
private:
std::string m_name;
std::string m_issuer;
};
std::string getIssuer()
{
return "test";
}
std::string getStockName()
{
return "GOOG.O";
}
int main()
{
std::string issuer = "mms";
std::string name = "IBM.N";
Stock ibm(name, issuer); // ok
Stock goog(getStockName(), getIssuer()); // ok
Stock goog1(getStockName(), issuer); // hm...
}
|
_Winnie C++ Colorizer |
Сивобороді старці будуть протестувати як же так, передавати такі "важкі" об’єкти як std::string по значенню! Але давайте розберемось. Перший виклик конструктора обидва параметри lvalue. Для кожного із них викликається конструктор копії для передачі в конструктор, а потім move-конструктор для відповідного члену класу. Якщо б ми мали конструктор з константними посиланнями у нас би був тільки виклик конструктора копії, але, зазвичай, move-конструктор надзвичайно ефективний, бо оперує лише POD-типами, і не вносить додаткового overhead. Тепер другий виклик обидва параметри rvalue. Буде викликано move-конструктор для передачі всередину і move-конструктор для відповідного члена класу. У випадку константних посиланнь ми б тут мали виклик конструктора копії. Третій виклик: name rvalue, issuer lvalue. Для першого буде copy + move, для другого move + move. Начебто непогано...
Із rvalue references зв’язане ще одне нове поняття Perfect Forwarding. Розглянемо наступний код:
#include <iostream>
#include <string>
#include <memory>
#include <utility>
class Stock {
public:
Stock(std::string name, std::string issuer)
: m_name(std::move(name)),
m_issuer(std::move(issuer))
{}
private:
std::string m_name;
std::string m_issuer;
};
template <typename C, typename ... Args>
std::unique_ptr<C> create(Args & ... args)
{
return std::unique_ptr<C>(new C(args ...));
}
std::string getName()
{
return "IBM.N";
}
std::string getIssuer()
{
return "mms";
}
int main()
{
std::unique_ptr<Stock> ps1(create<Stock>("GOOG.O", "test"));
std::unique_ptr<Stock> ps2(create<Stock>("MSFT.O", getIssuer()));
std::unique_ptr<Stock> ps3(create<Stock>(getName(), getIssuer()));
}
|
_Winnie C++ Colorizer |
"create" простенька "фабрика" яка вміє конструювати різні об’єкти з різними параметрами. Як передавати параметри у "фабрику"? Хтось хоче параметри по значенню (тут немає проблем), хтось по посиланню, хтось по константному посиланню, а хтось по rvalue reference. Наведений приклад не компілюється, неправильні типи аргументів для ps2 і ps3. Шаблон захоплює аргумент разом із посиланням. У стандарті C++03 було неможливо отримати reference від reference. У стандарті C++11 у нас стало 2 види reference, і їх дозволили комбінувати. Правила прості:
Ref1 | Ref2 | Result |
---|---|---|
& | & | & |
&& | & | & |
& | && | & |
&& | && | && |
Із цих правил слідуе, що якщо захоплювати аргумент по rvalue reference то після комбінації отримаємо правильний тип. Залишається його у правильному вигляді передати у конструктор не забуваємо що після іменування вони всі стали lvalue. Для цього існує std::forward шаблонна функція яка у якості шаблонного аргументу отримає захоплений тип, а у якості звичайного аргументу value і повертає його у правильному вигляді. Perfect Forwarding виглядає ось так:
#include <iostream>
#include <string>
#include <memory>
#include <utility>
class Stock {
public:
Stock(std::string name, std::string issuer)
: m_name(std::move(name)),
m_issuer(std::move(issuer))
{}
private:
std::string m_name;
std::string m_issuer;
};
template <typename C, typename ... Args>
std::unique_ptr<C> create(Args && ... args)
{
return std::unique_ptr<C>(new C(std::forward<Args>(args) ...));
}
std::string getName()
{
return "IBM.N";
}
std::string getIssuer()
{
return "mms";
}
int main()
{
std::unique_ptr<Stock> ps1(create<Stock>("GOOG.O", "test"));
std::unique_ptr<Stock> ps2(create<Stock>("MSFT.O", getIssuer()));
std::unique_ptr<Stock> ps3(create<Stock>(getName(), getIssuer()));
}
|
_Winnie C++ Colorizer |
Я тут використав "магію" Variadic Templates, особливо при розгортанні аргументів. Просто уявіть що всі аргументи в місці розгортання будуть обгорнуті у std::forward із захопленим типом (той що після typename) у якості шаблонного аргументу. Тепер пропоную проаналізувати перший приклад. Функція "create" тут нічого цікавого, просто RVO. Ця оптимізація спрацьовує бо ми повертаємо тимчасовий об’єкт без усяких умов і маємо лише одну точку виходу. Тому буде викликано лише конструктор об’єкту. Аналогічно для закоментованого варіанту, тільки буде використано NRVO. Функція "mutate" для lvalue-параметру буде викоикано конструктор копії під час передачі, для rvalue-параметру буде викликано move-конструктор під час передачі. У точці виходу компілятор, достатньо розумний щоб зрозуміти що foo нам більше не знадобиться, замість конструктора копії покличе move-конструктор, після чого foo буде знищено штатним деструктором. Тут є маленька деталь. Можливо хтось хотів би повернути foo через копію, а у деструкторі зробити щось корисне, але... Чесно кажучи, я сходу не можу придумати як заставити тут повертати значення через копію. Тож майте на увазі. Тепер використання. foo просто створення, без копії (RVO). bar копія у mutate бо foo це lvalue, move у return і деструктор для параметру. baz move у mutate, бо аргумент тимчасовий об’єкт, rvalue. Потім move у return і деструктор для параметру. В кінці три деструктори для foo, bar і baz. Перевіряємо:
Foo::Foo(5) Foo::Foo() - copy, v = 5 Foo::Foo() - move, v = 1 Foo::~Foo() - v = 1 Foo::Foo(6) Foo::Foo() - move, v = 1 Foo::~Foo() - v = 1 Foo::~Foo() - v = 1 Foo::~Foo() - v = 1 Foo::~Foo() - v = 5
На цьому можна було б і закінчити, якби не Дейв Абрахамс. Є така штука, називається "канонічний оператор присвоювання". Виглядає він так:
T& operator=(T rhs) { std::swap(*this, rhs); return *this; }
|
_Winnie C++ Colorizer |
Можливо ви знаєте, що std::swap спеціалізовано для всіх контейнерів STL і він для них надзвичайно ефективний. Такий оператор присвоювання робить одну копію даних, потім швиденько міняє місцями копію і оригінал (тепер оригінал "начебто" розподілено на стеку оператора, а копія сидить де потрібно) і в кінці знищує "начебто" оригінал. Із справжньою move-семантикою можливі два варіанта реалізації оператору присвоювання:
T& operator=(T&& t)
{
T(std::move(t)).swap(*this);
return *this;
}
T& operator=(T&& t)
{
// Destruct local data
std::swap(*this, t);
return *this;
}
|
_Winnie C++ Colorizer |
Здається що другий варіант не дуже-то і зручний, бо потребує виклику "тіпа деструктора" перед std::swap, але часто виявляється швидшим за перший. Ну і на останок канонічний std::swap:
template <typename T>
void swap(T & a, T & b)
{
T temp(std::move(a));
a = std::move(b);
b = std::move(temp);
}
|
_Winnie C++ Colorizer |
Перший рядок move-конструктор для "temp", "a" знищено. Другий рядок move-присвоювання для "a", "b" знищено. Третій рядок move-присвоювання для "b", "temp" знищено. Красиво, просто, ефективно.
"Забыл предупредить. Ровно в полночь...". Конструювання чи присвоювання з std::move має ще одну корисну властивість. Аргумент завжди буде приведений до rvalue, але якщо move-конструктор чи move-присвоювання не доступні то будуть викликані звичайний конструктор копії чи оператор присвоювання.
Коротенько підсумую. Семантика move дозволяє позбутись зайвих копій що особливо актуально для "жирних" об’єктів. Rvalue references дозволяють створювати функції з такими сигнатурами щоб викликати їх тільки для тимчасових об’єктів, для яких можна застосувати семантику move. Більше нічого вони не вміють. Іменована rvalue reference стає lvalue. std::move кастує тип до типу rvalue reference. Можна обійтись static_cast, але так більш наглядно. Коли типи аргументів виводяться для шаблонної функції можливі 4 комбінації посиланнь. Вони всі "згортаються" до звичайного посилання крім випадку коли обидва посилання rvalue references. Щоб правильно передавати такі шаблонні аргументи використовується std::forward. Передавати аргументи у конструктор треба по значенню і застосовувати до них std::move.
Матеріали:
- Блог "C++ Truths", запис від 09/03/2012: "Rvalue references in constructor: when less is more " (саме цей запис надихнув мене на дослідження).
- Стаття від Alex Allain на www.cprogramming.com: "Move semantics and rvalue references in C++11".
- Блог Pizer'а, запис від 13/04/2009: "C++0x: Do people understand rvalue references?" (останнє оновлення 27/05/2009).
- Стаття на EfNet #C++ Wiki: "Return value optimization".
- Стаття на домашній сторінці Thomas'а Becker'а: "C++ Rvalue References Explained".
- Блог Dave'а Abrahams'а "C++Next", серія записів (15/08/200907/12/2009): "RValue References: Moving Forward»".
- Стаття від Howard E. Hinnant, Bjarne Stroustrup і Bronek Kozicki на "Artima Developer": "A Brief Introduction to Rvalue References".
- Стаття від Andrei Alexandrescu на "Dr. Dobbs": "Move Constructors" (стаття для ознайомлення, бо на сьогодні вона втратила свою актуальність).
На цьому, нарешті, байку закінчено, дякую за увагу, не хворійте.
Післямова: підозрюю що я ще довго не зможу "на автоматі" правильно використовувати цей підхід.