Hatena::ブログ(Diary)

ntnekの日記 このページをアンテナに追加 RSSフィード

2009-02-10

[] 和訳:Rvalue References: C++0x Features in VC10, Part 2

Stephan T. Lavavej のRvalue References: C++0x Features in VC10, Part 2適当な訳です。左辺値と右辺値、無駄なテンポラリが生成される問題、新たな参照「右辺値参照」の特徴と使い方、ムーブセマンティクスの意味と使い方、完全転送パターンの書き方、それらを可能にする仕組みと、これに関する C++0x の新たな文法ルールについて書かれています。

特に前半は、この手の記事としてはこれまでで一番分かりやすく丁寧に解説されていると思います。正直よく分かっていなかったんですけど、これを読んでだいぶスッキリしました。

で、ムーブセマンティクスによるテンポラリの除去が日用品のように使えるようになれば、LinusLinux カーネル開発者たちが「C++なんてクソ言語は使えねぇ!」と主張する(正当な)理由の一つを崩すことができますね。これを機会にさらにC++の適用分野が広がれば…。

あー、でも、これのおかげで複雑な型推論機構がさらにまた複雑になったから、今度はそのあたりを使いこなせる奴が少ないので使えない、とか言われるのかなあ…。翻訳元のコメント欄をいくつか拾ってみると:

  • で、またみんなが C++ は複雑だって言い出すわけね。誰が使うんだよ…
  • C++って複雑になりすぎて押しつぶされちゃうんじゃない?この先は悲観的だなあ…
  • 複雑すぎて手にあまるんだよ。C++の良いとこだけ抜き出した言語があればいいのに

なんて声がちらほら。ただ同じくコメント欄にあるように、そして記事の中でも書かれているように、使う側からすれば細かいところまでいちいち覚える必要はなくて、ポイントになるパターンだけ覚えれば良いわけですね。それだけであとは STL なり boost なりが上手いこと使ってくれる、と。とはいえ、そういうパターンがただでさえ多いのがC++なわけで、思わず悲観的になる人の気持ちも良く分かります。ええ。


右辺値参照:VC10のC++0x機能(その2)

このシリーズの第一回ではラム関数、auto、static_assertについて扱った。

今日は右辺値参照について取り上げたい。これを使うと、ムーブ・セマンティクスと完全転送*1という二つのことが可能になる。右辺値参照がどのように働くのか詳細に説明するつもりなので、この記事は長くなるだろうし、はじめは混乱させてしまうことになるだろう。というのも、まずは左辺値と右辺値の違いという、C++98/03プログラマでも広く馴染んでいる人がとても少ない話をすることになるからだ。

だが怖がることはない。右辺値参照を使うのは、最初に耳にしたときに感じるよりももっとずっと簡単だ。ムーブ・セマンティクスや完全転送をあなたのコードに実装するには、詰まるところ、これからお見せするような簡単なパターンに従うだけで良い。それに、右辺値参照の使い方を学ぶことは極めて有意義だ。ムーブ・セマンティクスとして使えばパフォーマンスを桁違いに改善することができるし、完全転送として使えば高度なジェネリックコードをとても簡単に書けるようになる。

C++98/03における左辺値と右辺値

C++0xの右辺値参照を理解するためには、まずC++98/03の左辺値と右辺値について理解しておく必要がある。

「左辺値」と「右辺値」という用語はややこしいが、これはその来歴もややこしいからだ。(ところで、英語ではこれらはそのまま「エル・バリュー」と「アール・バリュー」と発音する。にもかかわらず、書くときは1単語だ。)これらの概念はもともと C に由来し、C++ で詳細化された。時間節約したいので、その歴史や、「左辺値」や「右辺値」と呼ばれるワケなんかの説明はすっ飛ばして、C++98/03でそれらがどのように動いていたかに話を進めたい。(あー、はいはい、どうせ大した秘密ってわけでもない:"L"は左」で"R"は「右」だ。しかしこれらの概念が発展したのはこの名前が選ばれた後のことなので、どちらにしてもあまり正確な名前とは言えない。この名前の歴史を全部学ぶくらいなら、何でもいいから好きな名前で呼べばいい。例えば「アップクォーク」と「ダウンクォーク」とか。それで何も不自由しないから。)

C++03の3.10/1節に曰く:「すべての式は左辺値または右辺値である」。重要なことは、左辺値か右辺値かという性質は式に付随するものであって、オブジェクトに対してではない、ということだ。

左辺値とは、ある単独の式を超えて存続するオブジェクトに名前を与えたものだ。例えば、obj、 *ptr、 ptr[index]、それに ++x などはすべて左辺値になる。

右辺値とは、それが存在する完全式(full-expression)が終わる時点(「セミコロンの位置」)で消え去ってしまうテンポラリのことだ。例えば 1729、x+y、std::string("にゃ〜")、それに x++ などはすべて右辺値だ。

++x と x++ の違いに注意しよう。例えば int x = 0; であれば、x という式は存続するオブジェクトに名前をつけているわけだから、左辺値だ。++x という式も存続するオブジェクトを更新して名前をつけているので、左辺値になる。一方で、x++ という式は右辺値だ。この式は存続するオブジェクトの元の値をコピーして、そのオブジェクトを更新し、それからコピーしておいた値を返す。このコピーはテンポラリだ。++x と x++ はどちらも x をインクリメントするが、++x は存続するオブジェクトそのものを返すのに対し、x++ は一時的なコピーの方を返す。左辺値か右辺値かは、その式が何をするかとは関係なく、その式が何に名前をつけるか(存続するオブジェクトか、テンポラリか)によって決まる。

もう少し直感的に理解したいなら、ある式が左辺値であるかを判断する別の方法として、「これはアドレスを取れるだろうか?」と問うてみるというものがある。アドレスが取れるなら、それは左辺値で、取れなければ右辺値だ。例えば &obj、&*ptr、&ptr[index]、&++xはどれも(馬鹿馬鹿しいものもあるけど)有効だが、一方で &1729、&(x + y)、&std::string("にゃ〜")、&x++ はすべて無効だ。この方法で上手くいく根拠は、address-of 演算子の「オペランドは左辺値でなければならない」(C++03 5.3.1/2)という要求のためだ。なぜそんな要求があるのかといえば、存続するオブジェクトのアドレスを取るのは何の問題もないのに対し、テンポラリはすぐに消えてしまうということを考えれば、そのアドレスを取得することは極めて危険な行為だと言えるからだ。

ここまでに挙げた例では演算子オーバーロードのことは無視してきた。演算子オーバーロードは関数呼び出しの簡略記法なわけだが、「関数呼び出しは、戻り値の型が参照である場合、またその場合に限り、左辺値である」(C++03 5.2.2/10)。そんなわけで、vector<int> v(10, 1729); が与えられているとき、 v[0] は operator[]() が int& を返すので左辺値になる(そして &v[0] は有効な式だし、実際よく使われる)。その一方、 string s("foo"); かつ string t("bar"); な場合の s + t は、operator+() が string を返すので右辺値になり、&(s + t) は無効になる。

左辺値と右辺値はどちらも更新可能(non-const)にも更新不可能(const)にもなることがある。例を挙げる:

string one("かわいい");
const string two("ふわふわ");
string three() { return "子猫ちゃん"; }
const string four() { return "は、健全な食生活に欠かせないものだ"; }

one;     // 更新可能な左辺値
two;     // const な左辺値
three(); // 更新可能な右辺値
four();  // const な右辺値

"Type&" は更新可能な左辺値を束縛する(そしてそれらを観察し、変化させるのに使える)が、 const の一貫性*2に反するので const な左辺値は束縛できない。また、更新可能な右辺値を束縛することは、非常に危険なので、できない。誤ってテンポラリを更新してしまうと、その更新で消えてしまうテンポラリを持っているだけで捕らえにくくて面倒なバグ引き起こし兼ねないので、C++ はこれを禁止している。(VCには、これを許可するという性質の悪い拡張があることを言っておくべきだろう。でも /W4 でコンパイルすれば、このロクでもない拡張が有効な場合は警告がでるようになる。普通は、ね。)そして、const な右辺値を参照することは二重にロクでもないので、できない。(注意深い読者は、ここで言っているのがテンプレート引数の推論の話だと気づいただろう。)

"const Type&" はなんでも束縛できる。更新可能な左辺値でも、const な左辺値でも、更新可能な右辺値でも、const な右辺値でもかまわない(し、それを監視するのに使える)。

参照というのは名前のことだから、右辺値に束縛された参照それ自身は左辺値になる(そう、「左」だ)。(const な参照だけが右辺値を束縛することができて、それ自身は const な左辺値になる。)これは分かりにくい話で、後で大きな意味を持つことになるから、もう少し説明しよう。ある関数 void observe(const string& str) があるとき、 observe() の実装の内側では str は const な左辺値であり、observe() から戻る直前まではそのアドレスを取ったり利用したりすることができる。これは obseve() が右辺値を伴って呼ばれる場合でも変わらない。上の例での three() とか four() とかの場合だ。 observe("ゴロニャ〜ン") のように呼ばれることもあるが、この場合はテンポラリ文字列が構築され、 str はこのテンポラリを束縛する。three() や four() の戻り値は名前を持たないので右辺値だが、observe() の中では str という名前を持つことになるから左辺値になるわけだ。さっきも言ったように、「左辺値か右辺値かという性質は式に付随するものであって、オブジェクトに対してではない」。もちろん str は、揮発するテンポラリに束縛されることもあるが、そのアドレスは obseve() から戻った後に使われるような場所に格納すべきではない。

右辺値を const な参照に束縛して、そのアドレスを取ろうとしたことって、あるよね?いや、やったことあるって!これは自己代入チェック付きのコピー代入演算子を書くときにやることだよ。Foo& operator=(const Foo& other) の中で if (this != &other) { copy stuff; } return *this; とやるでしょ。で、like Foo make_foo(); Foo f; f = make_foo(); みたいに、テンポラリからコピー代入しようとしたら、ほらね。

このあたりでこんな疑問が沸いてくると思う。「じゃあ更新可能な右辺値と const な右辺値の違いって何なの?更新可能な右辺値に Type& を束縛することも、何かを代入することもできないんじゃ、それは更新可能って言えるの?」素晴らしい質問です! C++98/03 では、その答えは「非 const メンバ関数は更新可能な右辺値からなら呼べる」という微妙な違いだけ、となります。うっかりテンポラリを更新して欲しくはないけど、更新可能な右辺値の非 const メンバ関数は明らかなので、許可されているわけ。C++0x では、この答えが劇的に様変わりして、ムーブ・セマンティクス が可能になった。

おめでとう! これであなたは「左辺値/右辺値ビジョン」と私が呼んでいるもの、つまり式を見ただけでそれが左辺値か右辺値か判断できるという能力を身に着けたわけだ。これにあなたの「constビジョン」を組み合わせれば、void mutate(string& ref) という関数に対して mutate(one) が有効で、mutate(two)、 mutate(three())、mutate(four8))、mutate("ゴロニャ〜ン") が無効だと正しく判断できるようになった。あなたが C++98/03 プログラマなら、これらの呼び出しのどれが有効でどれが向こうかは既に知っていただろう。あなたのコンパイラが mutate(three()) は宜しくないと教えてくれなかったとしたら、第六感で。新しく身に着けた「左辺値/右辺値ビジョン」は、何故そうなのか(three()は右辺値で、更新可能な参照は右辺値を束縛できないから)を、より正確に教えてくれるはずだ。それが何かの役に立つのかって?言語オタクになら、イエス。でもそうじゃない普通のプログラマには、実はそうでもない。結局のところ、この左辺値と右辺値の話をまったく知らなくても何とかなっていたわけだし。しかし、問題はこういうことだ:C++98/03 に比べて、C++0x はもっとずっと強力な左辺値/右辺値ビジョンを持っている(具体的には、式を見ただけでそれが更新可能/不可能な左辺値/右辺値と判断し、それに対して何かするという能力のこと)。C++0x を効果的に使うためには、あなたも左辺値/右辺値ビジョンを持つ必要がある。で、今、それを手に入れた。さあ、これで先に進めるぞ!

コピーにまつわる問題

C++98/03 は、正気とは思えないほど強力な抽象化と、正気とは思えないほど効率的な実行を組み合わせる。しかしここに問題がある。それは過剰なコピーの源泉になっているということだ。「値」セマンティクスを持つものは、ちょうど int のように振舞うので、コピーの際にコピー元を更新せず、結果としてコピー元とコピー先は独立したものになる。値セマンティクスは良いものだが、string や vector やその他もろもろの重量級のオブジェクトであっても不必要なコピーを引き起こしがちな点は頂けない。(「重量級」というのは「コピーが高くつく」という意味だ。要素を百万個持っている vector は重量級だ。)戻り値最適化(RVO)や名前つき戻り値最適化(NRVO)のように特定の状況でコピーコンストラクタを取り除く機能はこの問題を軽減してくれるが、あらゆる不要なコピーを消し去ってくれるわけではない。

最も無駄なコピーは、コピー元がすぐに破棄される場合だ。複写機が問題なく動くとして、コピーをとった直後にコピー元の紙を捨てたりするだろうか?そんな必要はない。元の紙を持ったまま、複写機なんて放っておけば良いのだ。次のコードは、標準化委員会が(N1377の中で)挙げた例を基にしていて、私が "the killer example" と呼んでいるものだ。大量の文字列があるとしよう:

string s0("母さんが言うには、");
string s1("かわいい");
string s2("ふわふわ");
string s3("子猫ちゃん");
string s4("は、健全な食生活に欠かせないものだ");

そしてこれらをこんな感じで接続する:

string dest = s0 + " " + s1 + " " + s2 + " " + s3 + " " + s4;

これはどのくらい効率的だろうか?(この例に関して言えば数マイクロ秒で実行できるので気にすることもない。気にしているのは、これを一般化した場合で、そういうことはこの言語のあらゆる場所で起こる。)

operator+() の呼び出しごとにテンポラリ文字列が返る。ここでは operator+() が8回呼ばれるので、8つのテンポラリ文字列になる。それぞれが構築されるたびに、動的なメモリアロケーションと接続されたすべての文字のコピーが行われる。その後、それぞれが破棄されるたびに、動的なメモリのデアロケーションが行われる。(VCは短い文字列の動的なアロケーションやデアロケーションを回避するために「小さな文字列の最適化」を実行する。これについて聞いたことがあるのなら、s0 は注意深く選ばれていて十分に長いので、その機能は無効になっていると考えて欲しい。そうするとコピーは避けられない。また、Coyp-On-Write という「最適化」について聞いたことがあるなら、それは忘れて欲しい --- ここでは適用されないからだ。マルチスレッド環境下の「ヤバそうなので最適化しない化」のために、標準ライブラリの実装はそういうことはまったくしない。)

実際のところ、いずれの結合処理も結合される文字をすべてコピーするので、これは結合処理の数に対して二次の計算量がかかる。ひえぇぇっ!これはとんでもない無駄で、C++ にとっては殊更恥ずべきことだ。どうしてこんなことが起きてしまうのだろう?我々にできることは何だろう?

問題は operator+() だ。この関数は、const string& を二つ受け取るか、あるいは const string& と const char * を一つずつ受け取る(他のオーバーロードもあるが、ここでは使ってない)しかし、左辺値と右辺値のどちらを食わされているのかは分からないので、常に新たなテンポラリ文字列を生成して返している。左辺値であるか右辺値であるかが、なぜ問題になるのだろう?

s0 + " " を評価する場合、新しいテンポラリ文字列を作ることは絶対に必要だ。s0 は左辺値、つまり存続するオブジェクトに名前をつけることなので、これを更新することはできない。(誰かが気づくからだ!)しかし (s0 + " ") + s1 を評価する場合、第二のテンポラリを作成して最初のテンポラリを捨てるのではなく、単に s1 の中身を最初のテンポラリ文字列に追加することもできる。これが ムーブ・セマンティクス の背後にある鍵となる洞察だ。なぜなら s0 + " " は右辺値、つまりテンポラリオブジェクトを参照する式なので、プログラムのどこにもそのテンポラリオブジェクトを監視できるものは存在しないからだ。ある式が更新可能な右辺値であることを検知できるなら、そのテンポラリオブジェクトを、誰にも知られずに、好きなように更新できる。operator+() は引数を更新しない「かもしれない」が、その引数が更新可能な右辺値でも何も問題はない。この要領で、operator+() を呼び出すごとに、単一のテンポラリ文字列に文字を追加していくことができる。これで不要な動的メモリ管理やコピー処理を完全に取り除いて、計算量をリニアにすることができた。よっしゃ!

技術的に言えば、C++0x では、operator+() の各々の呼び出しはそれでも別々のテンポラリ文字列を返す。しかし、第二のテンポラリ文字列( (s0 + ") + s1 を評価して得られたもの)は第一のテンポラリ文字列( s0 + " " の評価で得られたもの)が所有していたメモリを奪い取って構築され、s1 の中身をそのメモリ上に追加する(通常の幾何学的な再配置は起こりえる)。「奪い取る」というのはポインタを弄るという意味だ:第二のテンポラリは第一のテンポラリの内部ポインタをコピーして、ナルを代入する。第一のテンポラリはそのうち(「セミコロンの位置で」)破棄されるが、内部ポインタはナルなので、デストラクタは何もしない。

一般に、更新可能な右辺値を検知できるようになるということは、「リソース盗難」ができるようになるということだ。何らかのリソース(例えばメモリ)を所有するオブジェクトが更新可能な右辺値から参照されているなら、そのリソースはどのみち消え去ってしまうのだから、コピーしなくても盗用することができる。構築や代入を、更新可能な右辺値が所有する何かを取ってきて行うということを、一般に「ムーブする」と言う。そしてムーブできるオブジェクトは "ムーブ・セマンティクス" を持っている。

これはいろんな場面、例えば vector のリアロケーションなどで、極めて有用だ。ある vector が(例えば push_back() の最中に)より多くのキャパシティを必要としていて裏でリアロケーションしようとする場合には、古いメモリブロックから新しいメモリブロックへと要素をコピーする必要がある。これらのコピーコンストラクタ呼び出しはとても高くつくことがある。(例えば vector<string> なら、文字列ごとにコピーする必要があるので、その都度、動的なメモリアロケーションが起こる。)でも待った!古いメモリブロックに入っている要素はこれから破棄されるものだ。なら、それらの要素をコピーせずに、ムーブすることができる。この場合、古いメモリブロックに入っている要素は存続する格納場所を占有しているので、それを参照しようとする式、例えば old_ptr[index] は左辺値になる。再割り当ての際には、古いメモリブロックの要素を更新可能な右辺値の式で参照したい。更新可能な右辺値のフリをすれば、要素をムーブできるようになるので、コピーコンストラクタ呼び出しを取り除くことができる。(「この左辺値に右辺値のフリをさせたい」というのは、次のように言うのと同じことだ。「ああ、これが左辺値なのは知ってるさ。存続するオブジェクトを参照してるんだろ。けど、どうせすぐに破棄するなり代入するなり何なりするつもりだから、このあとこの左辺値に何が起ころうが知ったこっちゃないね。こいつからリソースを持って行きたいなら、好きにすれば」)

C++0x の右辺値参照は、更新可能な右辺値を検出する能力とそこから奪い取る能力を与えることで、ムーブ・セマンティクス を可能にする。また、左辺値を更新可能な右辺値として取り扱った場合に ムーブ・セマンティクス を有効にできる。では、右辺値参照がどのように働くのか見てみよう!

右辺値参照:初期化

C++0x が導入した新しい種類の参照、右辺値参照は、Type&& または const Type&& という構文で書ける。現在の C++0x ワーキングドラフトである N2798 の 8.3.2/2 節に曰く:「ある参照が & を用いて宣言されたならそれは左辺値参照と呼ばれる。また && を用いて宣言されたならそれは右辺値参照と呼ばれる。左辺値参照と右辺値参照は別々の型である。明示的に書かれない限り、これらは意味的に等価であり、どちらも参照と呼ばれる。」これはつまり、C++98/03 の参照(今では左辺値参照として知られる)に関するあなたの洞察は、右辺値参照にも当てはまるというということだ。あなたが学ばなければならないのは、それらの違いについてだけだ。

(注意:私は Type& を「Typeレフ」、 Type&& を「Typeレフレフ」と発音している。ちゃんと呼べばそれぞれ「Typeへの左辺値参照」と「Typeへの右辺値参照」になる。「int への const なポインタ」を int * const と書いて「int 星 const」と呼ぶのと似たようなものだ。)

では違いは何だろう?左辺値参照と比較すると、右辺値参照は初期化とオーバーロード解決の際に異なった振る舞いを見せる。何を束縛しようとしているか(つまり初期化)の違いと、それらを何で束縛しようとしているか(つまりオーバーロード解決)の違いというわけだ。

ではまず初期化について見てみよう:

  • 更新可能な左辺値参照 Type& は、既に見たとおり、更新可能な左辺値を束縛でき、それ以外(const な左辺値、更新可能な右辺値、const な右辺値)は束縛できない。
  • const な左辺値参照 const Type& は、既に見たとおり、何でも束縛できる。
  • 更新可能な右辺値参照 Type&& は更新可能な左辺値や更新可能な右辺値を束縛する。しかし、const な左辺値や const な右辺値は(const の一貫性に違反するので)束縛しない。
  • const な右辺値参照 const Type&& は何でも束縛する。

これらの法則は謎めいて聞こえるが、簡単な二つのルールから導かれたものだ:

  • 更新可能な参照が const なものを束縛しないようにすることで、const の一貫性に従うようにすべし
  • 更新可能な左辺値参照が更新可能な右辺値を束縛しないようにすることで、誤ってテンポラリを更新してしまうことを回避すべし

日本語を読むよりコンパイラのエラーメッセージを読むほうがお好みなら、ためしにやってみせよう:

C:\Temp>type initialization.cpp
#include <string>
using namespace std;

string modifiable_rvalue() {
    return "かわいい";
}

const string const_rvalue() {
    return "ふわふわ";
}

int main() {
    string modifiable_lvalue("子猫ちゃん");
    const string const_lvalue("腹ペコペコのゾンビー");

    string& a = modifiable_lvalue;          // Line 16
    string& b = const_lvalue;               // Line 17 - ERROR
    string& c = modifiable_rvalue();        // Line 18 - ERROR
    string& d = const_rvalue();             // Line 19 - ERROR

    const string& e = modifiable_lvalue;    // Line 21
    const string& f = const_lvalue;         // Line 22
    const string& g = modifiable_rvalue();  // Line 23
    const string& h = const_rvalue();       // Line 24

    string&& i = modifiable_lvalue;         // Line 26
    string&& j = const_lvalue;              // Line 27 - ERROR
    string&& k = modifiable_rvalue();       // Line 28
    string&& l = const_rvalue();            // Line 29 - ERROR

    const string&& m = modifiable_lvalue;   // Line 31
    const string&& n = const_lvalue;        // Line 32
    const string&& o = modifiable_rvalue(); // Line 33
    const string&& p = const_rvalue();      // Line 34
}
C:\Temp>cl /EHsc /nologo /W4 /WX initialization.cpp
initialization.cpp
initialization.cpp(17) : error C2440: 'initializing' : cannot convert from 'const std::string' to 'std::string &'
        Conversion loses qualifiers
initialization.cpp(18) : warning C4239: nonstandard extension used : 'initializing' : conversion from 'std::string' to 'std::string &'
        A non-const reference may only be bound to an lvalue
initialization.cpp(19) : error C2440: 'initializing' : cannot convert from 'const std::string' to 'std::string &'
        Conversion loses qualifiers
initialization.cpp(27) : error C2440: 'initializing' : cannot convert from 'const std::string' to 'std::string &&'
        Conversion loses qualifiers
initialization.cpp(29) : error C2440: 'initializing' : cannot convert from 'const std::string' to 'std::string &&'
        Conversion loses qualifiers

更新可能な右辺値参照が更新可能な右辺値を束縛するのは問題ない。肝は、テンポラリを更新するのに使えるということだ。

左辺値参照と右辺値参照は初期化の際には同じように振舞う(違うのは上の18行目と28行目だけだ)。にも関わらず、オーバーロード解決の際には分岐することになる。

右辺値参照:オーバーロード解決

更新可能な左辺値参照のパラメータと const な左辺値参照のパラメータで関数をオーバーロードできることは良くご存知だろう。C++0x では、更新可能は右辺値参照のパラメータと const な右辺値参照のパラメータでもオーバーロードできる。四種類すべてのオーバーロードを持つ単項関数が与えられたとき、それぞれが各々に対応する参照を束縛できているのを見ても、別に驚かないだろう:

C:\Temp>type four_overloads.cpp
#include <iostream>
#include <ostream>
#include <string>
using namespace std;

void meow(string& s) {
    cout << "meow(string&): " << s << endl;
}

void meow(const string& s) {
    cout << "meow(const string&): " << s << endl;
}

void meow(string&& s) {
    cout << "meow(string&&): " << s << endl;
}

void meow(const string&& s) {
    cout << "meow(const string&&): " << s << endl;
}

string strange() {
    return "strange()";
}

const string charm() {
    return "charm()";
}

int main() {
    string up("up");
    const string down("down");

    meow(up);
    meow(down);
    meow(strange());
    meow(charm());
}
C:\Temp>cl /EHsc /nologo /W4 four_overloads.cpp
four_overloads.cpp

C:\Temp>four_overloads
meow(string&): up
meow(const string&): down
meow(string&&): strange()
meow(const string&&): charm()

実用上は、Type&、const Type&、Type&&、const Type&& をすべてオーバーロードしてもあまり役に立たない。もっとずっと興味深いオーバーロードの組は const Type& と Type&& だ:

C:\Temp>type two_overloads.cpp
#include <iostream>
#include <ostream>
#include <string>
using namespace std;

void purr(const string& s) {
    cout << "purr(const string&): " << s << endl;
}

void purr(string&& s) {
    cout << "purr(string&&): " << s << endl;
}

string strange() {
    return "strange()";
}

const string charm() {
    return "charm()";
}

int main() {
    string up("up");
    const string down("down");

    purr(up);
    purr(down);
    purr(strange());
    purr(charm());
}
C:\Temp>cl /EHsc /nologo /W4 two_overloads.cpp
two_overloads.cpp

C:\Temp>two_overloads
purr(const string&): up
purr(const string&): down
purr(string&&): strange()
purr(const string&): charm()

これはどうなっているんだろう?ルールはこうなっている:

  • 初期化のルールは拒否権を持っている
  • 左辺値は左辺値参照に束縛されることを強く望み、右辺値は右辺値参照に束縛されることを強く望む
  • 更新可能な式は更新可能な参照に束縛されることを弱く望む

(「拒否権」というのはつまり、禁止された式の参照への束縛を含む関数候補は直ちに「見込みなし」と看做され、それ以降の考慮からは外される、ということ。)これらの法則が適用される過程を追いかけてみよう。

  • purr(up) については、初期化ルールによって purr(const string&) と purr(string&&) 以外は拒否される。up は左辺値なので左辺値参照 purr(const string&) に束縛されることを強く望む。up は更新可能なので更新可能な参照 purr(string&&) に束縛されることを弱く望む。強く望まれた purr(const string&) が勝つ。
  • purr(down) については、const の一貫性から初期化ルールによって purr(string&&) が拒否される。従って purr(const string&) の不戦勝となる。
  • purr(strange()) については、初期化ルールによって purr(const string&) と purr(string&&) 以外は拒否される。strange() は右辺値なので右辺値参照 purr(string&&) に束縛されることを強く望む。strange() は更新可能なので更新可能な参照 purr(string&&) に束縛されることを弱く望む。二重に望まれた purr(string&&) が勝つ。
  • purr(charm()) については、const の一貫性から初期化ルールによって purr(string&&) が拒否される。従って purr(const string&) の不戦勝となる。

覚えておくべき重要な点は、const Type& と Type&& をオーバーロードした場合、更新可能な右辺値は Type&& に束縛され、他はすべて const Type& に束縛されるということだ。そんなわけで、これが ムーブ・セマンティクス のためのオーバーロードセットということになる。

重要な注意:値を返す関数は const Type を返す( charm() のように)よりも Type を返す( strange() のように)べきだ。前者から得られることは特にない(非 const メンバ関数呼び出しを禁止するくらいで)が、ムーブ・セマンティクス の最適化を妨げてしまう。

ムーブ・セマンティクス:そのパターン

シンプルクラス remote_integer を考えよう。これは動的にアロケートされた int へのポインタを保持するものだ。(これを「リモート・オーナーシップ」と言う)。このクラスのデフォルトコンストラクタ、単項コンストラクタ、コピーコンストラクタ、コピー代入演算子、それにデストラクタはどれもお馴染みのものだろう。ここにムーブコンストラクタとムーブ代入演算子を加えた。これらは #ifdef MOVABLE でガードされているので、それらがある場合とない場合で何が起こるのかをお見せできる。実際のコードでそうする必要はない。

C:\Temp>type remote.cpp
#include <stddef.h>
#include <iostream>
#include <ostream>
using namespace std;

class remote_integer {
public:
    remote_integer() {
        cout << "Default constructor." << endl;
        m_p = NULL;
    }

    explicit remote_integer(const int n) {
        cout << "Unary constructor." << endl;

        m_p = new int(n);
    }

    remote_integer(const remote_integer& other) {
        cout << "Copy constructor." << endl;

        if (other.m_p) {
            m_p = new int(*other.m_p);
        } else {
            m_p = NULL;
        }
    }

#ifdef MOVABLE
    remote_integer(remote_integer&& other) {
        cout << "MOVE CONSTRUCTOR." << endl;

        m_p = other.m_p;
        other.m_p = NULL;
    }
#endif // #ifdef MOVABLE

    remote_integer& operator=(const remote_integer& other) {
        cout << "Copy assignment operator." << endl;

        if (this != &other) {
            delete m_p;

            if (other.m_p) {
                m_p = new int(*other.m_p);
            } else {
                m_p = NULL;
            }
        }

        return *this;
    }

#ifdef MOVABLE
    remote_integer& operator=(remote_integer&& other) {
        cout << "MOVE ASSIGNMENT OPERATOR." << endl;

        if (this != &other) {
            delete m_p;

            m_p = other.m_p;
            other.m_p = NULL;
        }

        return *this;
    }
#endif // #ifdef MOVABLE

    ~remote_integer() {
        cout << "Destructor." << endl;

        delete m_p;
    }

    int get() const {
        return m_p ? *m_p : 0;
    }

private:
    int * m_p;
};

remote_integer square(const remote_integer& r) {
    const int i = r.get();

    return remote_integer(i * i);
}

int main() {
    remote_integer a(8);

    cout << a.get() << endl;

    remote_integer b(10);

    cout << b.get() << endl;

    b = square(a);
 
    cout << b.get() << endl;
}
C:\Temp>cl /EHsc /nologo /W4 remote.cpp
remote.cpp

C:\Temp>remote
Unary constructor.
8
Unary constructor.
10
Unary constructor.
Copy assignment operator.
Destructor.
64
Destructor.
Destructor.

C:\Temp>cl /EHsc /nologo /W4 /DMOVABLE remote.cpp
remote.cpp

C:\Temp>remote
Unary constructor.
8
Unary constructor.
10
Unary constructor.
MOVE ASSIGNMENT OPERATOR.
Destructor.
64
Destructor.
Destructor.

いくつか見るべきところがある。

  • コピーコンストラクタとムーブコンストラクタがオーバーロードされ、コピー代入演算子とムーブ代入演算子もオーバーロードされる。const Type& と Type&& で関数をオーバーロードすると何が起こるのかは既に見た。これは b = square(a); が自動的にムーブ代入演算子を可能な限り選ぶことの説明になる。
  • 動的にメモリをアロケートする代わりに、ムーブコンストラクタとムーブ代入演算子は単に other からそれらを盗用する。盗み取る際は、other のポインタをコピーしてナルを入れる。other が破棄される際にデストラクタは何もしない。
  • コピー代入演算子とムーブ代入演算子にはどちらも自己代入チェックが必要である。コピー代入演算子が自己代入チェックを必要とする理由は良く知られている。これは、int のようなPOD型はそれ自身に問題なく代入できる(例えば x = x;)ので、ユーザ定義型も同様に問題なく自己代入できるべきであるためだ。自己代入は、直書きされたコードではほとんど起こらないが、std::srot() などのアルゴリズムの中では簡単に起こり得る。C++Ox では std::sort() のようなアルゴリズムは要素をコピーするのではなくムーブすることがある。自己代入に対する潜在的な需要がここに存在する。

そうすると、自動的に生成される(標準用語で言う「暗黙的に宣言された」)コンストラクタや代入演算子ではどうなるのかが気になるところだ。

  • ムーブコンストラクタとムーブ代入演算子は暗黙的には宣言されない。
  • デフォルトコンストラクタの暗黙的な宣言は、ユーザがコピーコンストラクタやムーブコンストラクタを含む何らかのコンストラクタを宣言することによって抑制される。
  • コピーコンストラクタの暗黙的な宣言は、ユーザがコピーコンストラクタを宣言することで抑制される。しかしムーブコンストラクタを宣言しても抑制されない。
  • コピー代入演算子の暗黙的な宣言は、ユーザがコピー代入演算子を宣言することで抑制される。しかしムーブ代入演算子を宣言しても抑制されない。

基本的に、自動生成ルールはムーブセマンティクスの影響を受けない。例外はムーブコンストラクタだが、これはどのコンストラクタの宣言でも暗黙的に宣言されたデフォルトコンストラクタを抑制するのと同じことだ。

ムーブセマンティクス:左辺値からのムーブ

ところで、コピー代入演算子でコピーコンストラクタを書いたりする人なら、ムーブ代入演算子でムーブコンストラクタを書いてみたいところだろう。これは可能だが、注意深くやる必要がある。以下は間違った書き方だ:

C:\Temp>type unified_wrong.cpp
#include <stddef.h>
#include <iostream>
#include <ostream>
using namespace std;

class remote_integer {
public:
    remote_integer() {
        cout << "Default constructor." << endl;

        m_p = NULL;
    }

    explicit remote_integer(const int n) {
        cout << "Unary constructor." << endl;

        m_p = new int(n);
    }

    remote_integer(const remote_integer& other) {
        cout << "Copy constructor." << endl;

        m_p = NULL;
        *this = other;
    }

#ifdef MOVABLE
    remote_integer(remote_integer&& other) {
        cout << "MOVE CONSTRUCTOR." << endl;

        m_p = NULL;
        *this = other; // WRONG
    }
#endif // #ifdef MOVABLE

    remote_integer& operator=(const remote_integer& other) {
        cout << "Copy assignment operator." << endl;

        if (this != &other) {
            delete m_p;

            if (other.m_p) {
                m_p = new int(*other.m_p);
            } else {
                m_p = NULL;
            }
        }

        return *this;
    }

#ifdef MOVABLE
    remote_integer& operator=(remote_integer&& other) {
        cout << "MOVE ASSIGNMENT OPERATOR." << endl;

        if (this != &other) {
            delete m_p;

            m_p = other.m_p;
            other.m_p = NULL;
        }

        return *this;
    }
#endif // #ifdef MOVABLE

    ~remote_integer() {
        cout << "Destructor." << endl;

        delete m_p;
    }

    int get() const {
        return m_p ? *m_p : 0;
    }

private:
    int * m_p;
};

remote_integer frumple(const int n) {
    if (n == 1729) {
        return remote_integer(1729);
    }

    remote_integer ret(n * n);

    return ret;
}

int main() {
    remote_integer x = frumple(5);

    cout << x.get() << endl;

    remote_integer y = frumple(1729);

    cout << y.get() << endl;
}
C:\Temp>cl /EHsc /nologo /W4 /O2 unified_wrong.cpp
unified_wrong.cpp

C:\Temp>unified_wrong
Unary constructor.
Copy constructor.
Copy assignment operator.
Destructor.
25
Unary constructor.
1729
Destructor.
Destructor.

C:\Temp>cl /EHsc /nologo /W4 /O2 /DMOVABLE unified_wrong.cpp
unified_wrong.cpp

C:\Temp>unified_wrong
Unary constructor.
MOVE CONSTRUCTOR.
Copy assignment operator.
Destructor.
25
Unary constructor.
1729
Destructor.
Destructor.

ここでコンパイラはRVOは行っているが、NRVOは行っていない。前に述べたように、コピーコンストラクタ呼び出しはRVOやNRVOで取り除かれることもあるが、しかしいつもそれが適用されるわけではない。ムーブコンストラクタはその残りのケースを最適化する。)

ムーブコンストラクタの中で WRONG とマークされた行はコピー代入演算子を呼ぼうとしている!これはコンパイルできるし動作もするが、ムーブコンストラクタの趣旨は損なっている。

何が起こったのだろう?C++98/03を思い出して欲しい。名前のついた左辺値参照は左辺値だ(例えば int& r = *p なら r は左辺値)が、名前のない左辺値参照もまた左辺値になる( vector<int> v(10, 1729) に対して v[0] を呼べば int& が返るが、これは名前のない左辺値参照で、アドレスを取ることができる)。右辺値参照はこれとは異なる振る舞いをする:

  • 名前のついた右辺値参照は左辺値である
  • 名前のない右辺値参照は右辺値である

名前のついた右辺値参照は、複数の操作を施して、繰り返し使うことができるので、左辺値となる。仮にそうではなくて右辺値だとしたら、最初に施される操作がその内部リソースを盗み取り、後続する操作に影響を及ぼしてしまう。この盗用は外からは見えないだろうから、これは禁止されている。その一方、名前のない右辺値参照は繰り返して使われないので、右辺値のままで良い。

本当にムーブ代入演算子でムーブコンストラクタを実装したいのであれば、左辺値を右辺値として取り扱うことでそこからムーブする能力が必要になる。これは C++0x の <utility> にある std::move() によって可能になる。これは VC10 には入る予定だが(実は手元の開発用ビルドにはもう入っている)、VC10 CTP には含まれていない。そこでその書き方をイチからご説明しよう:

C:\Temp>type unified_right.cpp
#include <stddef.h>
#include <iostream>
#include <ostream>
using namespace std;

template <typename T> struct RemoveReference {
     typedef T type;
};

template <typename T> struct RemoveReference<T&> {
     typedef T type;
};

template <typename T> struct RemoveReference<T&&> {
     typedef T type;
};

template <typename T> typename RemoveReference<T>::type&& Move(T&& t) {
    return t;
}

class remote_integer {
public:
    remote_integer() {
        cout << "Default constructor." << endl;

        m_p = NULL;
    }

    explicit remote_integer(const int n) {
        cout << "Unary constructor." << endl;

        m_p = new int(n);
    }

    remote_integer(const remote_integer& other) {
        cout << "Copy constructor." << endl;

        m_p = NULL;
        *this = other;
    }

#ifdef MOVABLE
    remote_integer(remote_integer&& other) {
        cout << "MOVE CONSTRUCTOR." << endl;

        m_p = NULL;
        *this = Move(other); // RIGHT
    }
#endif // #ifdef MOVABLE

    remote_integer& operator=(const remote_integer& other) {
        cout << "Copy assignment operator." << endl;

        if (this != &other) {
            delete m_p;

            if (other.m_p) {
                m_p = new int(*other.m_p);
            } else {
                m_p = NULL;
            }
        }

        return *this;
    }

#ifdef MOVABLE
    remote_integer& operator=(remote_integer&& other) {
        cout << "MOVE ASSIGNMENT OPERATOR." << endl;

        if (this != &other) {
            delete m_p;

            m_p = other.m_p;
            other.m_p = NULL;
        }

        return *this;
    }
#endif // #ifdef MOVABLE

    ~remote_integer() {
        cout << "Destructor." << endl;

        delete m_p;
    }

    int get() const {
        return m_p ? *m_p : 0;
    }

private:
    int * m_p;
};

remote_integer frumple(const int n) {
    if (n == 1729) {
        return remote_integer(1729);
    }

    remote_integer ret(n * n);

    return ret;
}

int main() {
    remote_integer x = frumple(5);

    cout << x.get() << endl;

    remote_integer y = frumple(1729);

    cout << y.get() << endl;
}
C:\Temp>cl /EHsc /nologo /W4 /O2 /DMOVABLE unified_right.cpp
unified_right.cpp

C:\Temp>unified_right
Unary constructor.
MOVE CONSTRUCTOR.
MOVE ASSIGNMENT OPERATOR.
Destructor.
25
Unary constructor.
1729
Destructor.
Destructor.

(以降では std::move() と私の Move() を区別せずに使っているが、実装は同じものだ。)std::move はどのように動作するのだろう?差し当たっては「魔法が関係している」と言っておこう。(複雑なものではないが、完全転送を見ていく際に出会うことになるテンプレート引数の型推論や参照の折りたたみが関ってくるので、完全な説明は後で述べる。)具体的な例でその魔法を飛び越えてみよう。オーバーロード解決の例の up のような、string 型の左辺値が与えられているとき、std::move(up) は string&& std::move(string&) を呼び出す。これは名前のない右辺値参照、つまり右辺値を返す。上記の strange() のような string 型の右辺値が与えられれば、std::move(strange()) は string&& std::move(string&&) を呼び出す。こちらも、名前のない右辺値参照、すなわち右辺値を返す。

std::move() は、ムーブ代入演算子でムーブコンストラクタを実装する以外の場所でも有用だ。「ここに左辺値があるが、その値はどうなっても良い」(例えば、これから破棄されるとか代入されるとか言う理由で)と言える場合はどこでも、std::move(あなたの左辺値の式) と書くことでムーブセマンティクスをアクティブにすることができる。

ムーブセマンティクス:ムーブ可能なメンバ

C++0x の標準クラス(例:vector、string、regex)はムーブコンストラクタとムーブ代入演算子を備えている。そして、手作業でリソースを管理する独自のクラス(remote_integerなど)でこれらがどのように実装されているかを見てきた。ではムーブ可能なデータメンバを含むクラス(vector や string や regexや remote_integerなど)に関してはどうだろう?コンパイラはムーブコンストラクタとムーブ代入演算子を自動的に生成してくれないので、自分たちの手で書いてやる必要がある。幸い、std::move() を使えば、これはとても簡単だ:

C:\Temp>type point.cpp
#include <stddef.h>
#include <iostream>
#include <ostream>
using namespace std;

template <typename T> struct RemoveReference {
     typedef T type;
};

template <typename T> struct RemoveReference<T&> {
     typedef T type;
};

template <typename T> struct RemoveReference<T&&> {
     typedef T type;
};

template <typename T> typename RemoveReference<T>::type&& Move(T&& t) {
    return t;
}

class remote_integer {
public:
    remote_integer() {
        cout << "Default constructor." << endl;

        m_p = NULL;
    }

    explicit remote_integer(const int n) {
        cout << "Unary constructor." << endl;

        m_p = new int(n);
    }

    remote_integer(const remote_integer& other) {
        cout << "Copy constructor." << endl;

        if (other.m_p) {
            m_p = new int(*other.m_p);
        } else {
            m_p = NULL;
        }
    }

    remote_integer(remote_integer&& other) {
        cout << "MOVE CONSTRUCTOR." << endl;

        m_p = other.m_p;
        other.m_p = NULL;
    }

    remote_integer& operator=(const remote_integer& other) {
        cout << "Copy assignment operator." << endl;

        if (this != &other) {
            delete m_p;

            if (other.m_p) {
                m_p = new int(*other.m_p);
            } else {
                m_p = NULL;
            }
        }

        return *this;
    }

    remote_integer& operator=(remote_integer&& other) {
        cout << "MOVE ASSIGNMENT OPERATOR." << endl;

        if (this != &other) {
            delete m_p;

            m_p = other.m_p;
            other.m_p = NULL;
        }

        return *this;
    }

    ~remote_integer() {
        cout << "Destructor." << endl;

        delete m_p;
    }

    int get() const {
        return m_p ? *m_p : 0;
    }

private:
    int * m_p;
};

class remote_point {
public:
    remote_point(const int x_arg, const int y_arg)
        : m_x(x_arg), m_y(y_arg) { }

    remote_point(remote_point&& other)
        : m_x(Move(other.m_x)),
          m_y(Move(other.m_y)) { }

    remote_point& operator=(remote_point&& other) {
        m_x = Move(other.m_x);
        m_y = Move(other.m_y);
        return *this;
    }

    int x() const { return m_x.get(); }
    int y() const { return m_y.get(); }

private:
    remote_integer m_x;
    remote_integer m_y;
};

remote_point five_by_five() {
    return remote_point(5, 5);
}

remote_point taxicab(const int n) {
    if (n == 0) {
        return remote_point(1, 1728);
    }

    remote_point ret(729, 1000);

    return ret;
}

int main() {
    remote_point p = taxicab(43112609);

    cout << "(" << p.x() << ", " << p.y() << ")" << endl;

    p = five_by_five();

    cout << "(" << p.x() << ", " << p.y() << ")" << endl;
}
C:\Temp>cl /EHsc /nologo /W4 /O2 point.cpp
point.cpp

C:\Temp>point
Unary constructor.
Unary constructor.
MOVE CONSTRUCTOR.
MOVE CONSTRUCTOR.
Destructor.
Destructor.
(729, 1000)
Unary constructor.
Unary constructor.
MOVE ASSIGNMENT OPERATOR.
MOVE ASSIGNMENT OPERATOR.
Destructor.
Destructor.
(5, 5)
Destructor.
Destructor.

見て分かるように、メンバごとのムーブの書き方は明確だ。remote_point のムーブ代入演算子で自己参照チェックが不要な点に注意。これは、remote_integerで既に行われているためだ。また、remote_point の暗黙的に宣言されたコピーコンストラクタ、コピー代入演算子、およびデストラクタがどれも正しく動作している点にも注目。

そろそろムーブセマンティクスにも完璧に馴染んできた頃だろう。(でもまだこれで終わりじゃない!)あなたが新たに身に着けた信じがたい力を試すのにちょうど良いから、最初に挙げた operator+() のコピーにまつわる問題(string の代わりに remote_integer を適用した)は、読者への練習問題として残しておこう。

最後に念のため:コンパイラはムーブコンストラクタとムーブ代入演算子を自動的には生成してくれないので、コピー可能なクラスにはできる限りそれらを実装するようにしよう。そうしたクラスを普通に使っていてもムーブセマンティクスが自動的に拾ってもらえるようになるし、STL コンテナやアルゴリズムもムーブセマンティクスの恩恵を受けて、高くつくコピー動作を安価なムーブに置き換えてくれるようになる。

転送問題

C++98/03 の左辺値、右辺値、参照およびテンプレートに関するルールは完全に上手く働くように見えるが、それはプログラマが高度なジェネリックコードを書くまでの話だ。完璧にジェネリックな関数 outer() を書いているとしよう。この関数の存在理由は、任意の個数の任意の引数を受けて、これを任意の関数 inner() に引き渡す(転送する)ことだ。この良い例はいろいろある。ファクトリ関数 make_shared<T>(args) は args を T のコンストラクタに転送し、shared_ptr<T> を返す。(これは T オブジェクトとその参照カウンタの制御ブロックを単一の動的なメモリアロケーションで格納することができる。侵入的な参照カウント機構*3と同等に効率が良い。)function<Ret (Args)> などのラッパー関数は引数を格納された関数に転送する、などなど。この記事では、outer() の inner() への引数転送だけに注目する。outer() の戻り値の型を決める問題はここでは置いておく(常に shared_ptr<T> を返す make_shared<T>(args) のように簡単なケースもあるが、完全に一般的なケースを解決するには C++0x の decltype 機能が必要になる)。

引数がゼロの場合は自己解決できるが、引数が一つの場合はどうだろう?試しに outer() を書いてみよう:

template <typename T> void outer(T& t) {
    inner(t);
}

この outer() の問題は、更新可能な右辺値を引数にして呼び出せないことだ。inner() が const int& を取る場合、inner(5) はコンパイルできる。だが outer(5) はコンパイルできない。T は int に推論されるが、int& は 5 を束縛できないからだ。

宜しい、ではこれを試してみよう:

template <typename T> void outer(const T& t) {
    inner(t);
}

inner() が int& を取るなら、これは const の一貫性に違反するので、コンパイルできない。

ところで、outer() を実際に働く T& と const T& の両方でオーバーロードすることは可能だ。そうするとあたかも inner() であるかのように正しく outer() を呼ぶことができる。

残念ながら、これは引数が複数の場合に上手く拡張しづらい。つまり T1& と const T1&、T2& と const T2& 云々とすべての引数に対してオーバーロードしていくことなるので、オーバーロードの数が指数的になってしまうのだ。( VC9 SP1 の tr1::bind() では、これをやると、最初の五つの引数に対して六十三個のオーバーロードが必要になった。絶望するには十分だ。しかしそうしなければ、1729 のような右辺値に対する束縛関数をどうして呼べないのか、ユーザに説明しなければならなくなり、その場合はこの説明を一通りこなさなければならなくなる。このオーバーロードを根絶やしにするには、あなたがきっと知りたいとは思わないような不気味なプリプロセッサ機構を駆使する必要があった。)

転送問題は C++98/03 の深刻で根本的な未解決の問題だった(コンパイルを極端に遅くし、コードをほとんど解読不能にする不気味なプリプロセッサ機構に訴えなければ)。だが、右辺値参照はエレガントな方法で転送問題を解決する。

(ムーブセマンティクス・パターンでは例を示す前に初期化とオーバーロード解決のルールについて説明したが、今度はテンプレート引数推論や参照の折りたたみのルールを説明する前に完全転送パターンの例を示す。この方が理解しやすいだろう。)

完全転送:そのパターン

完全転送を用いると、N 個の任意の引数を受け、それを透過的に任意の関数に転送するような単一の関数を書くことができる。各引数の更新可能/不可能、左辺値/右辺値といった性質は保持され、outer() を innert() とまったく同じように使うことができ、おまけにムーブセマンティクスも働いてくれる。(「任意の個数の」の部分は C++0x の可変個数テンプレート*4が解決してくれる。ここではどんな N が与えられても良いとする。)これは簡単なものではあるが、最初は魔法のように見える:

C:\Temp>type perfect.cpp
#include <iostream>
#include <ostream>
using namespace std;

template <typename T> struct Identity {
    typedef T type;
};

template <typename T> T&& Forward(typename Identity<T>::type&& t) {
    return t;
}

void inner(int&, int&) {
    cout << "inner(int&, int&)" << endl;
}

void inner(int&, const int&) {
    cout << "inner(int&, const int&)" << endl;
}

void inner(const int&, int&) {
    cout << "inner(const int&, int&)" << endl;
}

void inner(const int&, const int&) {
    cout << "inner(const int&, const int&)" << endl;
}

template <typename T1, typename T2> void outer(T1&& t1, T2&& t2) {
    inner(Forward<T1>(t1), Forward<T2>(t2));
}

int main() {
    int a = 1;
    const int b = 2;

    cout << "Directly calling inner()." << endl;

    inner(a, a);
    inner(b, b);
    inner(3, 3);

    inner(a, b);
    inner(b, a);

    inner(a, 3);
    inner(3, a);

    inner(b, 3);
    inner(3, b);

    cout << endl << "Calling outer()." << endl;

    outer(a, a);
    outer(b, b);
    outer(3, 3);

    outer(a, b);
    outer(b, a);

    outer(a, 3);
    outer(3, a);

    outer(b, 3);
    outer(3, b);
}
C:\Temp>cl /EHsc /nologo /W4 perfect.cpp
perfect.cpp

C:\Temp>perfect
Directly calling inner().
inner(int&, int&)
inner(const int&, const int&)
inner(const int&, const int&)
inner(int&, const int&)
inner(const int&, int&)
inner(int&, const int&)
inner(const int&, int&)
inner(const int&, const int&)
inner(const int&, const int&)

Calling outer().
inner(int&, int&)
inner(const int&, const int&)
inner(const int&, const int&)
inner(int&, const int&)
inner(const int&, int&)
inner(int&, const int&)
inner(const int&, int&)
inner(const int&, const int&)
inner(const int&, const int&)

たったの二行!完全転送は二行だけだ!冴えてるねえ!

この例は outer() が t1 と t2 を inner() に透過的に転送する様子を示している。inner() はまるで直接呼ばれたかのように左辺値/右辺値の違いや const 性を観測している。

std::move() と同様、std::identity と std::forward() は C++0x の <utility> に定義されている( VC10 には入るが、CTP には無い)。

これらがどのように実装されているかを示そう。(ここでも std::identity を私の Identity と、std::forward() を Forward() とそれぞれ区別なく使っているが、どちらも実装はまったく同じだ。)

では、魔法の種明かしと行こう。これはテンプレート引数の推論と参照の折りたたみによって成される。

右辺値参照:テンプレート引数の推論と参照の折りたたみ

右辺値参照とテンプレートは特殊な方法で作用しあう。次の例は何が起こるかを示したものだ:

C:\Temp>type collapse.cpp
#include <iostream>
#include <ostream>
#include <string>
using namespace std;

template <typename T> struct Name;

template <> struct Name<string> {
    static const char * get() {
        return "string";
    }
};

template <> struct Name<const string> {
    static const char * get() {
        return "const string";
    }
};

template <> struct Name<string&> {
    static const char * get() {
        return "string&";
    }
};

template <> struct Name<const string&> {
    static const char * get() {
        return "const string&";
    }
};

template <> struct Name<string&&> {
    static const char * get() {
        return "string&&";
    }
};

template <> struct Name<const string&&> {
    static const char * get() {
        return "const string&&";
    }
};

template <typename T> void quark(T&& t) {
    cout << "t: " << t << endl;
    cout << "T: " << Name<T>::get() << endl;
    cout << "T&&: " << Name<T&&>::get() << endl;
    cout << endl;
}

string strange() {
    return "strange()";
}

const string charm() {
    return "charm()";
}

int main() {
    string up("up");
    const string down("down");

    quark(up);
    quark(down);
    quark(strange());
    quark(charm());
}
C:\Temp>cl /EHsc /nologo /W4 collapse.cpp
collapse.cpp

C:\Temp>collapse
t: up
T: string&
T&&: string&

t: down
T: const string&
T&&: const string&

t: strange()
T: string
T&&: string&&

t: charm()
T: const string
T&&: const string&&

Name の明示的な特殊化によって型を出力できる。

quark(up) を呼び出すと、テンプレート引数推論が実行される。quark() はテンプレート引数 T を持つ関数テンプレートだが、テンプレート引数は明示的に与えていない( quark<X>(up) のような奴のこと)。その代わり、関数パラメータの型 T&& と関数の引数の型( string 型の左辺値)を比較することでテンプレート引数が推論される。

C++0x は関数パラメータの型と関数引数の型をマッチさせる前にその両方を変形する。

まず関数引数の型を変形する。特別なルールが有効にされた( N2798 14.8.2.1 [temp.deduct.call]/3 ):テンプレートパラメータ T に対して関数パラメータの型が T&& という形であり、また関数引数が型 A の左辺値であるなら、テンプレート引数として型 A& が推論される。(この特殊なルールは T& や const T& の関数パラメータには適用されない。これらは C++98/03 と同様に振舞う。const T&& にも適用されない。)quark(up) の場合、string は string& に変形されるということになる。

その後、関数パラメータの型を変形する。C++98/03 でも C++0x でも参照は放棄される( C++0x は左辺値参照と右辺値参照の両方を放棄する)。四つのいずれの場合でも、これは T&& を T に変形するということを意味する。

その結果、変形後の関数引数の型として T が推論される。これが quark(up) が "T: string&" と出力し、quark(down) が "T: const string&" と出力する理由だ。up と down は左辺値なので、特殊ルールが有効になる。strange() と charm() は右辺値なので通常のルールが用いられる。それで quark(strange()) が "T: string" を、quark(charm()) が "T: const string" をそれぞれ出力する。

テンプレート引数の推論が行われた後、代入が行われる。テンプレートパラメータ T は現れるごとに推論されたテンプレート引数の型で置き換えられる。quark(strange()) では T は string であるので、T&& は string&& になる。同様に、quark(charm()) では T が const string なので、T&& は const string&& になる。ところが、quark(up) と quark(down) には別の特殊ルールが適用される。

quark(up) では T は string& なので、これを代入すると T&& は string& && を生成する。C++0x では参照の参照は折りたたまれる。参照折りたたみのルールは「左辺値参照は感染する」というものだ。X& &、X& && および X&& & はどれも X& に折りたたまれる。X&& && のみは X&& に折りたたまれる。そのため、string& && は string& に折りたたまれる。テンプレートの中では右辺値参照に見えるものは右辺値参照でなくても良い。quark(up) は quark<string&>() をインスタンス化する。このインスタンス化においては T&& は string& になる。

我々は Name<T&&>::get() でこれを観察した。同様に、quark(down) は quark<const string&>() をインスタンス化し、T&& は const string& である。C++98/O3 では const 性をテンプレートパラメータの内側に隠していた( T& を取る関数テンプレートは const Foo オブジェクトで呼ばれることがある。この場合、T& は const Foo& のように見える)。C++0x では左辺値性がテンプレートの内側に隠される。

それで、この二つの特殊なルールが何をもたらしてくれるのだろうか。quark() の中では、型 T&& は quark() の関数引数と同じ左辺値性/右辺値性および const 性を持つ。これこそが、右辺値参照が完全転送のために 左辺値性と右辺値性および const 性を保存するやり方である。

完全転送:std::forward() と std::identity はどのように動くのか

もう一度 outer() を見てみよう:

template <typename T1, typename T2> void outer(T1&& t1, T2&& t2) {
    inner(Forward<T1>(t1), Forward<T2>(t2));
}

いまなら何故 outer() が T1&& と T2&& を取るのか理解できる。これらの型は outer() の引数の情報を保存する。しかしなぜ Forward<T1>() と Forward<T2>() を呼ぶのだろうか?名前のついた左辺値参照と名前のついた右辺値参照はどちらも左辺値であることを思い出そう。outer() が inner(t1, t2) を呼べば、inner() は常に t1 と t2 を左辺値として認識することになり、完全転送が破綻してしまう。

幸い、名前のない左辺値参照は左辺値で、名前のない右辺値参照は右辺値だ。だから、t1 と t2 を inner() に転送するためには、補助関数にこれらを渡して、これらの型を保存したまま名前を取り去る必要がある。std::forward() がやっているのはこういうことだ:

template <typename T> struct Identity {
    typedef T type;
};

template <typename T> T&& Forward(typename Identity<T>::type&& t) {
    return t;
}

Forward<T1>(t1) を呼んでも、Identity は T1 を更新しない(それが何をやるのかはこの後すぐに分かる)。そのため T1&& を受け取った Forward<T1>() は T1&& を返す。これは t1 の型には手をつけないままその名前を取り去ることになる(型は何でも良い。string& でも const string& でも string&& でもはたまた const string&& でも良いという例は既に見た)。inner() は、 Forward<T1>(t1) が outer() の第一引数として渡されたものと同じ型、左辺値性/右辺値性、const 性を持っていると認識するだろう。こうして完全転送が動くわけだ!

間違って Forward<T1&&>(t1) と書いた場合にどうなるか疑問に思うかもしれない。( outer() は T1&& t1 を取るのだから、この誤りはとても犯しやすいものだ。)有難いことに、何も悪いことは起こらない。Forward<T1&&>() は T1&& && を受け取り、それを返す。これは T1&& に折りたたまれる。そんな訳で Forward<T1>(t1) と Forward<T1&&>(t1) は同じものであり、前者のほうが短く書けるから推奨されているに過ぎない。

Identity は何をしているのだろう?以下は動作しないが、何故だろうか:

template <typename T> T&& Forward(T&& t) { // BROKEN
    return t;
}

Forward() をこのように書けば、明示的なテンプレート引数を使わずに呼び出せるようになる。テンプレート引数の推論が起動されるわけだが、T&& がどうなるかは既に見てきた --- 関数引数が左辺値なら、それは左辺値参照になる。また、Forward() で解決しようとしていた元々の問題というのは、outer() の内部では、t1 と t2 と名づけられた二つのものは、それぞれの型が T1&& と T2&& という右辺値参照であったとしても左辺値になる、ということだった。

この壊れた実装でも Forward<T1>(t1) はまだ動くだろう。だがForward(t1) は(魅惑的なことに!)コンパイルされ、元の t1 と同じように働くという間違いを起こす。これは尽きることのない苦しみの源になってしまうので、Identity を用いてテンプレート引数の推論を無効にする。これは C++98/03 でも C++0x でも同じ働きをするので、経験を積んだテンプレートプログラマなら良くご存知のものだと思う。

typename Identity<T>::type の二重コロンは鉛の薄板で、テンプレート引数推論はその左側には波及しない。(これを説明するのはまた別のお話で。)

ムーブセマンティクス:std::move() はどのように動くのか

さて、テンプレート引数推論の特殊なルールと参照の折りたたみについて学んだところで、もう一度 std::move() を見てみよう:

template <typename T> struct RemoveReference {
     typedef T type;
};

template <typename T> struct RemoveReference<T&> {
     typedef T type;
};

template <typename T> struct RemoveReference<T&&> {
     typedef T type;
};

template <typename T> typename RemoveReference<T>::type&& Move(T&& t) {
    return t;
}

この RemoveReference の仕掛けは C++0x の <type_traits> にある std::remove_reference を正確に再現したものだ。例えば RemoveReference<string>::type、RemoveReference<string&>::type、RemoveReference<string&&>::type はどれも string になる。

同様に、Move() の仕掛けも C++0x の <utility> にある std::move() をそっくりそのまま写し取ったものだ。

  • string 型の左辺値で呼び出した場合、 T は string& に推論され、Move() は(折りたたまれた後に)string& を受け取って( RemoveReference の後に)string&& を返す。
  • const string 型の左辺値で呼び出した場合、T は const string& に推論され、Move() は(折りたたまれた後に)const string& を受け取って( RemoveReference の後に)const string&& を返す。
  • string 型の右辺値で呼び出した場合、T は string に推論され、Move() は string&& を受け取ってstring&& を返す。
  • const string 型の右辺値で呼び出した場合、T は const string に推論され、Move() は const string&& を受け取って const string&& を返す。

このようにして Move() は引数の型の const 性を維持したまま左辺値を右辺値に変換する。

過去

右辺値参照についてさらに学ぶには、標準委員会への提案論文を読むと良い。それらの提案が書かれた頃とは状況が違うことに注意して欲しい。右辺値参照は C++0x のワーキングペーパーに統合され、継続的に文面の見直しを受けている。提案書の中には誤っていたり、古くなっていたり、採用されなかった別の方法について論じていたりするものもある。それでも、とても参考になる。

N1377N1385N1690 が主要な提案論文になる。N2118 はワーキングペーパーに統合される前の最終版の表現を含んでいる。N1784N1821N2377 それに N2439 では「*this へのムーブセマンティクスの拡張」の発展をたどる事ができる。この機能は C++0x に統合されたが、VC10 ではまだ実装されていない。

未来

N2812「右辺値参照による安全性の問題(およびその対処)」は初期化ルールを変更し、右辺値参照が左辺値を束縛することを禁じるようにしよと提案している。これはムーブセマンティクスや完全転送パターンには影響しないので、たったいま学んだばかりの新しいテクニックを無効にするようなものではない(だが std::move() や std::forward() の実装の仕方は変えることになる)。

*1:perfect forwardingの日本語訳って何が一般的なんでしょう?

*2:"const correctness"ってどう訳すんでしょう?

*3:intrusive reference counting:要するに T の定義に参照カウンタそのものを含めること。もちろん non-intrusive な方がいろいろと都合が良い。

*4:variadic template:variable-length ではないので「可変長テンプレート」とは訳さない方が良いんでしょうね、たぶん。

kyabkyab 2009/02/13 22:32 原文長すぎて挫折したので、助かります。

ntnekntnek 2009/02/13 23:25 お役に立ってよかったです。

スパム対策のためのダミーです。もし見えても何も入力しないでください
ゲスト


画像認証

トラックバック - http://d.hatena.ne.jp/ntnek/20090210/p1