目的
この記事は、C++0xのrvalue referenceを完全に解説せんとする目的を以て書かれた。サンプルコードは最小に留め、エラー処理等は省いた。この記事さえ読めば、今日からrvalue referenceを恐れることなく使う本物のC++0xプログラマになれるだろう。
lvalueとrvalueについて
Cの時代では、lvalueとrvalueの違いは、代入演算子の左側か右側かという違いだけであった。つまり、left hand value, right hand valueの略である。従って、訳語も、左辺値、右辺値であった。C++においては、これはもはや正しくはない。従って、右辺値、左辺値というのも、誤訳である。それ故に、ここでは、これ以上、左辺値、右辺値という名称を使用しない。
誤解を恐れずにいえば、lvalueとは、明示的に実体のある、名前付きのオブジェクトであり、rvalueとは、一時的に生成される無名のオブジェクトである。
struct X{} ; int f() { return 0 ; } int main() { int i = 0 ; i ; // lvalue 0 ; // rvalue X x ; x ; // lvalue X() ; // rvalue f() ; // rvalue }
上記のコードを読めば、lvalueとrvalueの違いが、なんとなく分かってくれる事と思う。lvalueはrvalueに変換できるが、その逆、rvalueをlvalueに変換することは出来ない。
referenceについて
C++98のreferenceは、C++0xにおいては、lvalue referenceと呼ばれるものである。
struct X{ } ; void f( X & ) { } void g( X const & ) { } int main() { X x ; f( x ) ; // 1. OK f( X() ) ; // 2. Error g( X() ) ; // 3. OK }
1.は問題がない。lvalueだからだ。
2.はコンパイルエラーになる。rvalueを渡しているからだ。
3.は問題がない。constなreferenceは、rvalueを参照できるからだ。
ちなみに、VC++の独自拡張(/Zaで無効にできる)では、2.のコンパイルが通ってしまうので注意されたい。真のC++プログラマは、コンパイラを信用しないものである。
ここで、3.は、言語的には、汚い仕様である。本来、rvalueなものを、lvalue referenceで参照しているのである。そこで、rvalue referenceの出番となる。
Rvalue Reference
rvalue referenceとは、その名の通り、rvalueに対する参照である。文章で説明するより、コードを示した方が分かりやすい。
struct X{ } ; int main() { X x ; // lvalue reference X & lr1 = x ; // 1. OK X & lr2 = X() ; // 2. Error // rvalue reference X && rr1 = x ; // 3. Error X && rr2 = X() ; // 4. OK }
ごらんのように、rvalue referenceは、アンパサンドを二つ使う文法になっている。
1. は問題ない。xはlvalueだからだ。
2. はコンパイルエラーである。X()はrvalueであり、lvalue referenceでは参照できないからだ。
3. はコンパイルエラーである。xはlvalue referenceであり、rvalue referenceでは参照できないからだ。
4. は問題ない。X()はrvalueだからだ。
Rvalue Referenceの存在意義
実は、rvalue referenceとは、これだけの事なのである。その名前通り、rvalueに対する参照なのだ。とはいっても、これだけでは、存在意義が分からないであろうと思う。「一体何の役に立つのだ? const lvalue referenceでなくてもrvalueを参照できるようになっただけではないか?」と思うことだろう。実際、その通りで、「const lvalue referenceでなくてもrvalueを参照できるようになる」だけなのである。
そもそも、rvalueのオブジェクトには名前がなく、参照されなくなった時点で、自動的に破棄されるものである。勝手に破棄されるなら、書き換えても無駄である。constではなくなったからといって、何がそんなに嬉しいのか。
Move Semantics
以下のようなクラスを考える。
class X { private : char * ptr ; public : X() { ptr = new char[1000] ; // バッファに対して、時間のかかる書き込みを実行 } // コピーコンストラクタ X( X const & r ) { ptr = new char[1000] ; std::copy( &ptr[0], &ptr[1000], &r.ptr[0] ) ; } // デストラクタ ~X() { delete[] ptr ; } } ;
このクラスは、明らかにコンストラクタとコピーコンストラクタの実行が遅い。もし、コピーコンストラクタを、ポインタのすげ替えだけにすれば、パフォーマンスが大いに向上するだろう。ところが、そんなことをしてしまっては、コピー元のオブジェクトが使えなくなってしまうので、それは出来ない相談である。
しかし、よく考えると、安全に、コピーをポインタのすげ替えだけで済ませられる場合が存在するのである。
struct X {} ; X f(){ return X() ; } int main() { // 関数の戻り値はrvalueである。 X a( f() ) ; // 1. X tmp ; X b( tmp ) ; // 2. // これ以降、tmpはもう使わない。 }
ここで、関数の戻り値はrvalueなので、安全にポインタをすげ替えられる。また、tmpは、もうこれ以上使わないので、ポインタをすげ替えても差し支えない。問題は、一体どうやって、その意図を表現すればいいのだろうか。
そこで、rvalue referenceの出番である。rvalueであれば、そのオブジェクトは、ポインタを横取りしても問題ないのである。
Move コンストラクタ
1. のコピーを、ポインタのすげ替えにするために、クラスXに、rvalue referenceを引数に取るコンストラクタを追加する。
class X { public : // Move コンストラクタ X( X && r ) { ptr = r.ptr ; r.ptr = nullptr ; } } ;
これをmoveコンストラクタと呼ぶ。1. は、このmoveコンストラクタが呼ばれ、ポインタのすげ替えになる。コピー元のオブジェクトのポインタを、nullptrにするのを忘れないこと。さもなくば、デストラクタが走る際に、ランタイムエラーになるだろう。
lvalueをmoveせよ
さて、2. はどうしたらいいだろう。moveコンストラクタを実装したものの、コンパイラは2. の場合には、moveコンストラクタを呼び出してくれない。なぜなら、コンパイラは、プログラマの脳内仕様を読んではくれないからだ。tmpが、その後に使われていないかどうかは、コンパイラは静的に決定できないのである。
そこで、プログラマが意図を伝えてやらなければならない。
X b( static_cast<X &&>(tmp) ) ;
この様に、rvalueにキャストしてやれば、moveコンストラクタを呼び出すことが出来る。
std::move()
とはいえ、これは甚だしく面倒である。タイプミスもしやすい。そこで、標準ライブラリには、便利な関数が用意されている。std::move()だ。
X b( std::move(tmp) ) ;
何のことはない、std::move()とは、本質的にはキャストなのである。実装例を以下に示す。
namespace std { template <class T> inline typename std::remove_reference<T>::type&& move(T&& t) { return static_cast< std::remove_reference<T>::type&& >(t) ; } }
これだけの事なのである。単なるキャストである。自前でキャストを書くのは、エラーの元なので、std::move()を使うべきである。
ひとたび、変数に対してstd::move()を呼び出すと、それ以降、その変数を使える保証はなくなる。なぜなら、すでにmoveされているかもしれないからだ。
賢いコンパイラの場合
ちなみに、コンパイラによっては、上記のコードは、そもそもコピーコンストラクタもmoveコンストラクタも呼び出されない可能性がある。というのも、ある種の状況においては、コンパイラは安全且つ静的に、オブジェクトをコピーせずに、使い回せることを決定できるのである。たとえコンストラクタにサイドエフェクトがあったとしても、コンストラクタの呼び出しを省略できるのである。この種の最適化は、規格で保証されている。
(N3000 § 12.8 Copying class objects p19)
手持ちのコンパイラが優秀で、上記のコードでは、コンストラクタが呼び出されない場合、rvalue referenceの勉強のためには、以下のように書くとよい。
class X { public ; // moveな代入演算子 X & operator = (X && r) { if ( this == &r ) return *this ; delete[] ptr ; ptr = r.ptr ; r.ptr = nullptr ; return *this ; } } ; int main() { X tmp ; X x ; x = std::move(tmp) ; }
これは、最適化できないはずである。また、実際のコードではこのように、movableにしたければ、move コンストラクタの他に、move 代入演算子も定義するべきである。
オーバーロード
lvalue referenceとrvalue referenceは、もちろん、関数のoverload resolutionの際に、考慮される。
struct X {} ; void f( X & x ) { std::cout << "lvalue reference" << std::endl ; } void f( X && x ) { std::cout << "rvalue reference" << std::endl ; } int main() { X x ; f( x ) ; // lvalue reference f( X() ) ; // rvalue reference }
これは、さほど驚くに当たらないだろう。なぜなら、lvalueかrvalueかは、コンパイル時に静的に決定できるのだから。
テンプレート関数の引数におけるrvalue referenceのargument deduction
テンプレート関数の場合はどうなるだろうか。以下のコードを考えてもらいたい。
struct X {} ; template < typename T > void f( T && t ) {} int main() { X x ; f( x ) ; // lvalue reference f( X() ) ; // rvalue reference }
果たして、これはコンパイルが通るのだろうか。
実は、このコードはコンパイルが通る。規格には特別なルールがあり、テンプレート引数を、rvalue referenceとして関数の引数に使った場合のargument deductionで、lvalueを渡すと、lvalue referenceとなるのである。
(§ 14.9.2.1 Deducing template arguments from a function call p3)
つまり、上記のコードの場合、f()に、lvalue referenceを渡すと、TがX &になり、続く&&は無視され、lvalue referenceとして取り扱われる。
実に不思議なルールである。しかし、これも理由あってのことなのだ。もし、これが出来ないとなると、プログラマは、わざわざ、lvalue referenceとrvalue referenceとで、似たようなコードを複数書かなければならなくなる。すべての組み合わせを網羅するには、膨大なオーバーロード関数が必要になる。引数が1個の場合は、オーバーロード関数は2個、引数が2個の場合は、4個、引数が3個の場合は、8個、引数が4個の場合は、16個もの、オーバーロード関数を書かなければならない。これでは、一体何のためのテンプレートなのだろうか。
幸いなことに、テンプレート関数の場合は、rvalue referenceでlvalue referenceも参照できるので、そのようなオーバーロード関数の指数関数的な増加は起こらない。しかし、ここでひとつ問題がある。
Perfect Forwarding
template < typename T > void f( T && t ) { X x(t) ; }
f()の中で、Xをコピーしたい。ここまで読み進めた者ならば、当然、rvalueの際には、moveしたいところであろう。ところが残念なことに、std::move()は使えないのである。
なぜだろうか。
struct X {} ; template < typename T > void f( T && t ) { X x( std::move(t) ) ; // これ以降、tは使用不可 } int main() { X x ; f( x ) ; // lvalue reference //これ以降、xは使用不可 }
なぜなら、引数はlvalue referenceである可能性もあるからだ。main()側で、std::move()していないのに、xが勝手にmoveされて使用不可になったのでは、たまったものではない。main()側でstd::move()したときのみ、moveしてもらいたい。
ところが、f()側からみれば、引数はlvalueかrvalueか、テンプレートがインスタンス化されるまで分からないのである。lvalue referenceならコピーし、rvalue referenceの時のみmoveしたい。さて困った。一体どうしよう。
メタプログラミングを試す
メタプログラミングである。メタプログラミングの理解出来ないプログラマは、もはやC++プログラマとして認められないのである。ポインタの理解できないCプログラマと同じぐらい、役立たずのゴミ虫のすかしっ屁である。メタプログラミングこそ正義ィィッ! メタプログラミングに不可能はないィィッ!
では、さっそくメタプログラミングで問題を解決しようッ!
template < typename T > void f( T && t ) { if ( std::is_lvalue_reference<T>::value ) X x( t ) ; else X x( std::move(t) ) ; }
残念ながら、これは問題を多数のオーバーロード関数から、多数のメタプログラムに移しただけである。引数をひとつひとつ、このような方法で調べていくのは、面倒だし、引数が増えれば、オーバーロード関数と同じく、if文も爆発的に増えていく。
必要なのは、lvalueの場合はlvalue、rvalueの場合はrvalueを渡す方法である。
キャストを使う
以下のようなキャストを使えば、それが実現できる。
template < typename T > void f( T && t ) { X x( static_cast<T &&>(t) ) ; }
なぜこのキャストが、lvalueの時はlvalueを返し、rvalueの時はrvalueを返すのか。それは、argument deductionのおかげである。
もし、引数にlvalueが渡された場合、TはX &となり、&&は無視される。それ故、このキャストは、lvalueをlvalueにキャストするのである。rvalueが渡された場合は、当然、rvalueとなる。
このようにすれば、テンプレート関数に渡された引数を、そのまま別の関数に渡すことが出来る。
std::forward()
とはいえ、キャストを使うのは面倒であるし、エラーの元である。そのために、標準ライブラリには、便利な関数が用意されている。std::forward()だ。
template < typename T > void f( T && t ) { X x( std::forward<T>(t) ) ; }
何のことはない。std::forward()とは、本質的にはキャストなのである。実装例を以下に示す。
namespace std { template <class T, class U, class = typename enable_if< (is_lvalue_reference<T>::value ? is_lvalue_reference<U>::value : true) && is_convertible<typename remove_reference<U>::type*, typename remove_reference<T>::type*>::value >::type> inline T&& forward(U&& u) { return static_cast<T&&>(u); } }
恐ろしげなメタプログラムに面食らうかも知れないが、本質的には単なるキャストである。メタプログラムの意味は、
Tがlvalue referenceならば、Uもlvalue referenceでなければならない。
参照を取り除いた状態で、UからTに変換できなければならない。
という意味である。この二つの条件を満たさない場合、std::forward()はoverload resolutionのcandidateから外される。則ち、コンパイルエラーとなる。これにより、典型的なタイプミスなどによるエラーを防ぐことが出来るのである。
std::forward()は、テンプレート関数の引数を、田の関数にそのまま渡す際に使うものである。これをPerfect Forwardingという。
最後に
rvalue referenceは、実に単純なのである。名前の通り、rvalueへの参照に過ぎないのである。std::move()もstd::forward()も、単なるキャストに過ぎないのである。
std::move()は、lvalueをmoveしたいときに使い、std::forward()は、テンプレート関数の引数を、そのまま別の関数に渡したい時に使う。
9 comments:
> // rvalue reference
> X && rr1 = x ; // 3. Error
> X && rr2 = X() ; // 4. OK
あれ?3はOKだったはずじゃ…と思ったら今はドラフトの文言変わってダメになってたんですね。
gcc4.4.1ではコンパイル出来ちゃいます。
あれ、最新ドラフトに矛盾がありますね。
§ 8.5.3 References p5では、確かに
>the reference shall be an rvalue reference and the initializer expression shall be an rvalue.
と書いてあるのに、
§ 5 Expressions p6のコード例で、rvalue referenceにlvalueを代入している。
しかし、どうも文脈から判断するに、コード例が間違っているような気がします。
これは・・・・さすがに難しい
C++0xのD&Eを書いてもらってそれをじっくり読まないとだめかも
わたすは画面上に書いてある文を読み解くのは苦手なんです
本を寝っ転がって読むと不思議と頭に入るんですね
そんなことありませんよ。
rvalue referenceは単純です。
たんなるrvalueへの参照なのです。
Move SemanticsとかPerfect Forwardingとか、いかにも仰々しく名付けているけれど、
その本質はキャストに過ぎないのです。
D&Eのような歴史書が読みたければ、
公開されているペーパーを追いかければいいのです。
X( X const & r )
{
ptr = new char[1000] ;
std::copy( &ptr[0], &ptr[1000],
&r.ptr[0] ) ;
}
上記の部分は、ひょっとして、
{
ptr = new char[1000] ;
std::copy( &r.ptr[0],
&r.ptr[1000],
&ptr[0] ) ;
}
の間違いでしょうか??
std:copy() って、引数のうち、左の2つが src, 一番右がdst ですよね。
なんか、英語を日本語に翻訳したような文章が不自然で読みにくいです。格好いいかもしれないけど、判りにくいなぁ。
なるほど、全然わからない
rvalue参照とは何なのかずっと疑問を抱いていたのですが、この記事を見てそれがすっきりしました。
本当にありがとうございます。
とてもわかりやすかったです!
template の部分に関しては、引数を受け取った時点でそれはもとが右辺値であれ左辺値であれ、左辺値になってしまうので、もとが右辺値の参照であった場合に限り、move する必要があり、それは forward で達成できるということなのですね。
Post a Comment