今回は最近の規格 (C++11) で導入された「右辺値参照 (rvalue reference)」という機能について説明します。
C/C++の場合、左辺値 (lvalue) はもともと「代入式の左側に置けるもの」という意味で使われていて、特定のメモリ領域を指し示す式が左辺値になります。たとえば、変数、ポインタの間接参照、配列の添字演算子などが左辺値になります。C++であれば参照や参照を返す関数も左辺値になります。
右辺値 (rvalue) は代入式の右側に置かれる値のことで、左辺値を含めすべての式が右辺値になります。式は評価されて値となりますが、このとき値を保持するための一時的なデータが生成されます。たとえば int x = 1 + 2; であれば、右辺式 1 + 2 の計算結果 3 をどこかのメモリ領域 [*1] に保持します。それから、その値を左辺値 (変数 x) が示すメモリ領域に代入します。これはインスタンスの場合も同じで、右辺式で生成されるインスタンスは一時的なものになります。その後、生成された一時的なデータは廃棄されます。
C++の場合、一時的なインスタンスを関数に参照渡しするとき、const を指定しないとコンパイルエラーになります。今までは、すぐに廃棄される一時的なインスタンスの内容を変更するのは意味がない、と考えられてきました。次の例を見てください。
リスト : 右辺値の参照渡し #include <iostream> using namespace std; struct Foo { }; void foo(const Foo& x) { } void bar(Foo& x) { } int main() { Foo a; foo(a); // OK foo(Foo()); bar(a); bar(Foo()); // NG }
関数 foo() の引数は const を指定しています。当然ですが、foo(a) で変数 a の参照を受け取ることができます。foo(Foo()) の場合、コンストラクタ Foo() で一時的なインスタンスが生成され、その参照が foo() に渡されます。関数 bar() は引数に const を指定していないので、bar(Foo()) のように一時的なインスタンスを渡すとコンパイルエラーになります。
C++11 では、右辺値の参照を受け取るために新しい参照型が定義されました。データ型 T の右辺値参照は && を付けて T&& とします。この場合、左辺値の参照を受け取ることはできません。また、const を付けるとコンパイルエラーになります。右辺値参照を使うと、引数に渡されたインスタンスのメンバ変数を書き換えることができます。次の例を見てください。
リスト : 右辺値参照 (sample2101.cpp) #include <iostream> using namespace std; struct Foo { int a; }; void foo(const Foo& x) { cout << x.a << endl; } void bar(Foo& x) { x.a *= 10; cout << x.a << endl; } void baz(Foo&& x) { x.a *= 20; cout << x.a << endl; } int main() { Foo a = {1}; foo(a); foo(Foo{2}); bar(a); //bar(Foo()); // NG //baz(a); // NG baz(Foo{3}); }
関数 baz() は右辺値参照を受け取ります。これで、受け取ったインスタンスのメンバ変数 x の値を書き換えることができます。
実行結果を示します。
$ clang++ sample2101.cpp $ ./a.out 1 2 10 60
C++では、自分で取得したメモリ領域はデストラクタで解放するのが普通です。この場合、メモリの所有権はそれを保持しているインスタンスが持っていると考えることができます。コピーコンストラクタはその中身を新しいメモリ領域にコピーしますが、コピー元のインスタンスが不要になる (廃棄される) ことがわかっているならば、中身をコピーせずにポインタ変数のつけ替えだけで済ますことができるはずです。
一時的に生成されたインスタンスはすぐに廃棄されるので、この場合に当てはまります。つまり、メモリの所有権を他のインスタンスに移すわけです。参考 URL によると、このような考え方を「所有権の移動」とか「ムーブセマンティクス (Move Semantics)」と呼びます。
たとえば、次のプログラムを見てください。
リスト : マッピング (sample2102.cpp) #include <iostream> #include <vector> #include <list> using namespace std; template<class C, class F> C mapcar(const C& seq, F func) { C temp; for (const auto& x : seq) temp.push_back(func(x)); return temp; } template<class T> T square(T x) { return x * x; } int main() { vector<int> a = {1, 2, 3, 4, 5, 6, 7, 8}; list<double> b = {1.1, 2.2, 3.3, 4.4, 5.5}; vector<int> c = mapcar(a, square<int>); for (int x : c) cout << x << " "; cout << endl; list<double> d = mapcar(b, square<double>); for (double x : d) cout << x << " "; cout << endl; }
$ clang++ sample2102.cpp $ ./a.out 1 4 9 16 25 36 49 64 1.21 4.84 10.89 19.36 30.25
関数 mapcar はコンテナクラス seq の各要素に関数 func を適用し、その結果を新しいコンテナクラス temp に格納し、return でそれを返します。このような操作を「マッピング (写像)」といいます。
マッピングは関数型言語でよく使われる高階関数です。名前は Common Lisp の関数 mapcar から拝借しました。他の関数型言語では map と呼ばれることが多いです。auto に & を付けると、推論したデータ型の参照になります。const を付けると、const のデータ型になります。
C++11 よりも前のC++では、mapcar に大きなコンテナを渡すと、効率がとても悪くなることがあります。mapcar の返り値で変数を初期化する場合、コピーコンストラクタで temp の要素が新しいメモリ領域にコピーされます。変数に代入する場合は、代入演算子で同様な処理が行われます。temp は局所変数なので、関数の実行が終了すると temp は廃棄されます。つまり、temp で取得した大量のメモリは無駄になるわけです。もちろん、コンテナのサイズが大きければ、要素のコピーにも時間がかかるでしょう。
このような場合、temp が確保したメモリの所有権を移動すれば、mapcar を効率的に実行することが可能です。temp は関数が実行されている間だけ有効な一時的なインスタンスです。つまり、mapcar の返り値は右辺値として扱うことができるわけです。
このとき、廃棄されるインスタンスのポインタ変数を nullptr (または 0) に書き換えておかないと、デストラクタが実行されたときにメモリ領域が解放されてしまいます。移したメモリ領域は他のインスタンスで使用中なので、勝手に解放されてしまうと使っている側が困ってしまいます。つまり、右辺値のインスタンスを書き換えることができないと、所有権を移動することはできないのです。
所有権の移動にコピーコンストラクタや代入演算子を利用することはできません。どちらの場合も引数のデータ型に const を付けるのが普通ですが、const を外しても呼び出すことは可能です。そうすると、引数 (インスタンス) のメンバ変数を書き換えることができるので、所有権を移動することができるように思われますが、今度は普通のコピー処理や代入処理が必要になるときに困ってしまいます。
そこで、右辺値参照型を使うことにします。引数のデータ型が右辺値参照のコピーコンストラクタを「ムーブコンストラクタ」とか「移動コンストラクタ」といい、右辺値参照の代入演算子を「ムーブ代入演算子」とか「移動代入演算子」といいます。データ型を T とすると、これらのメンバ関数のプロトタイプは次のようになります。
1 がムーブコンストラクタ、2 がムーブ代入演算子になります。clang++ の標準ライブラリ (STL) は C++11 に対応しているので、mapcar のようなプログラムでも効率的に実行することができます。
簡単な例を示しましょう。次のリスを見てください。
リスト : ムーブコンストラクタとムーブ代入演算子の使用例 (sample2103.cpp) #include <iostream> #include <cstring> using namespace std; class cstr { char* str; public: cstr() : str(0) {} cstr(const char* s) : str(new char [strlen(s) + 1]) { strcpy(str, s); } ~cstr() { delete[] str; } // コピーコンストラクタ cstr(const cstr& s) : str(new char [strlen(s.str) + 1]) { cout << "copy\n"; strcpy(str, s.str); } // 代入演算子 cstr& operator=(const cstr& s) { if (this != &s) { cout << "set\n"; delete[] str; str = new char [strlen(s.str) + 1]; strcpy(str, s.str); } return *this; } // ムーブコンストラクタ cstr(cstr&& s) : str(s.str) { cout << "move copy\n"; s.str = nullptr; } // ムーブ代入演算子 cstr& operator=(cstr&& s) { if (this != &s) { cout << "move set\n"; delete[] str; str = s.str; s.str = nullptr; } return *this; } // 出力演算子 friend ostream& operator<<(ostream& output, const cstr& s) { output << s.str; return output; } };
クラス cstr はCスタイル文字列をメンバ変数 str に格納します。コピーコンストラクタはコピー元の文字列 s.str を自分のメンバ変数 str にコピーします。代入演算子は自分の文字列 str を delete[] で削除してから、再度 new でメモリ領域を取得して、そこに文字列 s.str をコピーします。
これに対し、ムーブコンストラクタは移動元のポインタ変数 s.str の値を自分自身の str にセットするだけです。そして、s.str を nullptr に書き換えます。同様に、ムーブ代入演算子も str を delete[] で解放してから、s.str の値を str にセットし、s.str を nullptr に書き換えます。
それでは実際に動かしてみましょう。次のリストを見てください。
リスト : 簡単なテスト (sample2103.cpp) int main() { cstr a("foo"); cstr b("bar"); { cstr c = a; a = b; b = c; } cout << a << endl; cout << b << endl; { cstr c = move(a); a = move(b); b = move(c); } cout << a << endl; cout << b << endl; }
$ clang++ sample2103.cpp $ ./a.out copy set set bar foo move copy move set move set foo bar
変数 a, b の値を変数 c を使って交換します。cstr c = a; の場合、変数 a の値が不要になるとは限らないので、ここではムーブコンストラクタは呼び出されず、通常のコピーコンストラクタが呼び出されます。同様に、a = b; と b = c; も通常の代入演算子が呼び出されます。変数 a と b の値を交換するだけなので、ムーブコンストラクタを呼び出したほうが効率的です。
このような場合、左辺値の変数を右辺値参照に型変換 (キャスト) します。いちいち static_cast を記述するのは面倒なので、それを行ってくれる関数 move() が用意されています。cstr c = move(a); とすると、ムーブコンストラクタが呼び出されます。a = move(b), b = move(c) とすると、ムーブ代入演算子が呼び出されます。これで変数 a, b のメンバ変数 str の値を交換することができます。また、move() を使うことで、所有権を移動していることがプログラム上でも明確になります。
C++11 に対応している標準ライブラリ (STL) であれば、コンテナクラスにデータをセットするとき、それが右辺値であればムーブ代入演算子が呼び出されます。また、メンバ関数にも右辺値参照に対応しているものがあります。たとえば、vector<T> の push_back() は引数の型が const T& だけではなく、T&& でも受け付けてくれます。
簡単な例を示しましょう。次のリストを見てください。
リスト : vector のムーブ代入演算子 (sample2104.cpp) #include <iostream> #include <vector> #include <cstring> using namespace std; // Cスタイル文字列 class cstr { // // ・・・ 省略 ・・・ // }; int main() { vector<cstr> a; a.push_back(cstr("foo")); a.push_back(cstr("bar")); a.push_back(cstr("baz")); a[0] = cstr("FOO"); a[1] = cstr("BAR"); a[2] = cstr("BAZ"); while (!a.empty()) { cstr x = move(a.back()); cout << x << endl; a.pop_back(); } }
$ clang++ sample2104.cpp $ ./a.out move copy move copy copy move copy copy copy move set move set move set move copy BAZ move copy BAR move copy FOO
push_back() で要素を末尾に挿入するとき、データが右辺値であればムーブコンストラクタ (move copy と表示) が呼び出されます。ベクタの容量を拡張するとき要素がコピーされますが、clang++ の STL ではコピーコンストラクタ (copy と表示) が呼び出されています。必要な大きさがわかっているならば、メンバ関数 reserve() であらかじめ容量を増やしておくといいでしょう。
a[0] = cstr("FOO") の右辺値は一時的なインスタンスなので、ムーブ代入演算子 (move set と表示) が呼び出されます。a.back() で末尾データを参照するとき、move() でキャストするとムーブ代入演算子が呼び出されます。このとき、ベクタ内のインスタンスはメンバ変数 str が nullptr に書き換えられます。所有権を移動したので、このデータを使用してはいけません。a.pop_back() で末尾要素が取り除かれ、ここでデストラクタが実行されます。
C++11 では、インスタンスをコンテナクラスに挿入するとき、メンバ関数 emplace() や emplace_back() などを使うと、コンテナ内でインスタンスを生成することができます。この機能を Emplacement といいます。
void emplace(iterator, コンストラクタの引数, ...); void emplace_back(コンストラクタの引数, ...);
簡単な使用例を示します。
リスト : emplace_back の使用例 (sample2105.cpp) #include <iostream> #include <vector> #include <cstring> using namespace std; class cstr { // // ・・・ 省略 ・・・ // }; int main() { vector<cstr> a; a.reserve(4); a.emplace_back("foo"); a.emplace_back("bar"); a.emplace_back("baz"); while (!a.empty()) { cstr x = move(a.back()); cout << x << endl; a.pop_back(); } }
$ clang++ sample2105.cpp $ ./a.out move copy baz move copy bar move copy foo
emplace_back を使うと一時的なインスタンスが生成されないので、ムーブコンストラクタは呼び出されません。また、一時的なインスタンスを生成すると、それを廃棄するためにデストラクタが呼び出されますが、その呼び出しもありません。効率的にインスタンスをコンテナに挿入することができます。