C++のムーブと完全転送を知る

社内勉強会、今期(と言ってももうあと1ヶ月もないですが)は、数理最適化勉強会と、Effective Modern C++輪読会をしています。この記事は、後者のEffective Modern C++輪読会で、『Effective Modern C++』5章の一部を輪読した時の資料を流用したものです。

C++11と言えば、昔のC++(03)から色々あって多くの機能が追加されとても便利になったバージョンです。さらに、C++14は11では間に合わなかった・忘れていた色々な便利なものを補填したもので、Effective Modern C++輪読会は、『Effective Modern C++』を教科書にしながらこのC++11/14について学ぶ会になっています。

時は既に2016年、gccもclangもMSVC++も概ねC++14が使えるようになっており、もはやC++14が使えないコンパイラにはC++コンパイラを名乗る資格はないと言っても過言ではない時代です(※個人的な意見であり弊社エンジニアの総意ではありません)。C++14が現代機械だとすると、C++11は戦後直後ぐらいの道具、C++03あたりは江戸時代ぐらいの遺物という感覚でしょうか(※あくまで個人的な感覚です)。

そんな状況で、Effective Modern C++輪読会と言うとちょっと今更感はあるのですが、こういうものはなんとなくの理解で進んでしまうことが多いので、この勉強会では学ぶことはけっこうありました。特に、今回のstd::movestd::forwardは、C++11で導入された機能の中で便利だと聞くけどいまいちよく分からないものNo1(※個人比)です。既に世の中にはもっと優れた記事がたくさんあるのは確かですが、色々な観点からの説明があるに越したことはないと思っているので、せっかくなので記事にして残しておきたいと思います。

一方で、C++といえばマサカリを投げ合う文化で有名ですが(?)、この記事も、大きな間違いはないとは思ってますが、マサカリを投げる隙がまったくない自信はありません・・・(先述の通り難しいところだと思いますし)。もし何か誤りなど見つけられた方は、記事にコメントいただくか、Twitter:@LWisteriaまで直接ご連絡ください。

用語の復習

ムーブと転送の説明に入る前に、用語の軽い復習をしておきます。

右辺値と左辺値

式には右辺値(rvalue)と左辺値(lvalue)があります。厳密な定義は複雑で難しいですが、概ね「アドレスを取得できる式=左辺値、できない=右辺値」と覚えておけば良いです。

ここで、

  • x, y, z:左辺値
  • g(y), f(x, g(y)):右辺値

です。

あるいは、これも厳密ではないですが、左辺値は名前のついた変数、右辺値は一時オブジェクトのように覚えてもいいです。

左辺値・右辺値それぞれの参照は左辺値参照(lvalue reference)と右辺値参照(rvalue reference)で、*通常は*、前者がT&で後者がT&&と書かれます。

ここであえて*通常は*と言ったのは、T&&で宣言しても左辺値になることがあるからです。

このようになる詳しい仕組みは後述しますが、この先、説明中にT&&が出てきても右辺値とは限らないことがあることは念頭においておいてください(逆に、T&&が毎回出てきた時に、毎回、それが右辺値か左辺値かを考えていると、理解が進むかもしれません)。

ムーブとコピー

ムーブ(move)は、コピー(copy)の対になる概念で、コピーが複製なのに対して、ムーブは置換(複製&破棄)を意味します。
RPGツクールで物体の移動をやりたい時に「移動」というコマンドがなくて「複製してから消せばいい」と書いてあったあれがムーブです。

このムーブを実現するコンストラクタおよび代入演算子をムーブコンストラクタムーブ代入演算子と呼びます。

↑の時、代入演算=のうち、y=xの=はコピー代入である必要があります。一方、z=x, ret=0, ret=y+zの=は、(コピー代入でもいいですが)xy+zの結果を捨ててもいい(後で使わない)のでムーブ代入にしてもよいはずです。

このように、同じ「代入」という演算は、コピーの意味とムーブの意味があります。「ムーブの意味にすること」をムーブセマンティクス(で解釈する|をとる)、と言います(※実はあんまり厳密な説明はできてないので、yohさんの『本当は怖くないムーブセマンティクス』(C++アドベントカレンダー2012 15日目)あたりを参考にしてください)。

(完全)転送

転送(forward)とは、受け取った引数をそのまま別の関数に受け渡すことです。

そして完全転送(perferct forward)とは、仮引数の値だけでなくその他の情報(型)も含めて完全に実引数に渡すことです。↑の例だと、f(z);と呼び出した時に、zの情報をそのままg()に伝え、g(z);と呼び出したことと等価になるような転送の場合を、完全転送と呼びます。

ですので、この例では完全転送を実現できていません。もしint& z;と宣言されていたら、f(z)g((int)z)と勝手に参照&を外されていることになるからです。

ムーブ

std::moveとは

C++でムーブを実現するための機能がstd::moveです。しかし、その名前に反してstd::moveはムーブしません
std::move(x)はxをムーブできるようにするだけです。

この「ムーブできる」とはどういう状態かというと、先に上げた例だと

となっており、ここで右辺に着目すると、左辺値xはムーブできない場合もあるが、右辺値0y+zは必ずムーブできています。
それもそうで、ムーブできるのは、右辺を廃棄して良い時なので、右辺値(≒一時オブジェクト)なら必ずムーブ可能ということです。

つまり、std::move右辺値にキャストすれば実現できます。実際に使ってみるとこんな感じです。

ここで、std::moveは実際にはムーブせずにムーブできるようにする(キャストする)だけと言っているのは、つまりstd::moveしてもムーブにならないことがあるからです。

どういう時かと言うと、const値をstd::moveした時です。

なぜこんなことが起きるかと言うと、ほぼ全てのムーブコンストラクタ・代入演算子は、非const参照を引数とするからです。
もう少し詳しく言うとムーブとは「代入元を破棄する」処理のことなので、引数(代入元)を操作(破棄)するため、非const参照を必要とします。

なので、私のような手が勝手にconstをつけるような人は特にconst値はムーブできないよと手に教えこむ必要がありますので、注意しましょう。

なお、「ほぼ」と書いたのは、規格上は別にconst右辺値参照を引数にすることもできるからですが、それはもはやただのコピーなので意味がないです。
(※復習:左辺値参照をとる関数に、右辺値を渡しても問題ない。逆に、右辺値参照をとる関数に、左辺値は渡せない。なので、大体の場合、const右辺値参照を取る関数は不便なだけで、const左辺値参照を取る関数があれば済む)

std::moveの正体

先述の通り、std::moveの正体はただ右辺値にキャストするだけの関数です。
ではどうやって右辺値にキャストしているかというと、こんな感じになります。

やっていることは

  1. 関数の戻り値は右辺値になるので、同じ値を返すだけの関数にしたい
  2. 戻り値を参照にしないとコピー(あるいはムーブ)が走るので、autoではなくdecltype(auto)
  3. 同様に、引数も参照にしたいが、T&だと左辺値しか受け取れないので、T&&にする(※T&&は後述の通り右辺値参照とは限らない)。
  4. 最終的に、右辺値参照を返したいが、もしTが左辺値参照だとdecltype(param)(つまりT&&)も左辺値参照になってしまうので、確実に右辺値参照(&&)にキャスト

という流れです。

と、こんな感じで「ムーブできるようにする」意味からstd::moveという名前になりましたが、やってることは「右辺値にキャストする」なのでstd::rvalue_castみたいな名前も提案されていたようです。
個人的にはstd::movableとかが良かったんじゃないかなぁと思ってます。

ムーブできないこともある

このように、C++11以降はムーブが使えるようになったので、ムーブを使えば速くなる!と言われますが、そうじゃない場合もあるので過度に期待し過ぎないほうがよいと言われています。そうじゃない場合というのは、ムーブできない場合もあるからです。

まずそもそも、ムーブ演算(コンストラクタ、代入)は、明示的に実装してあるか、そうでないなら

  • コピー演算が明示的に実装されていない
  • デストラクタが明示的に実装されていない
  • 全てのフィールドはムーブ演算可能

の全ての条件が満たされた時にしか暗黙的に生成されません。
つまり、ムーブしたくなるような複雑で大きいものでは、だいたいどれかの条件にひっかかるので、明示的に実装していないとムーブ演算なんて存在しないことがほとんどです。

また、もしムーブ演算があっても、ムーブがコピーより軽いかどうかは実装依存です。例えば、std::vectorは確かにムーブはポインタの付け替えで済むので高速ですが、std::arrayは中身を複製しないといけないのでムーブもコピーも同じぐらいです。

遅くなることはまぁまずないですが、実装依存なのでそういうムーブ演算を存在させることは可能ですね。
なので、ムーブを使えばコピーより必ず速くなるとは思わない方が良いです。

さらに、ムーブ演算が存在し、確実にムーブがコピーより軽い実装でも、例外安全性のstrong保証(途中で例外が発生しても必ず操作前に巻き戻って返せる性質)を必要とするアルゴリズムでは、例外を投げない(noexceptがついた)ムーブ演算でなければ、ムーブ演算を使えません。

というように、ムーブ演算が効果を発揮しない場合を列挙するとキリがありませんので、あんまり期待しても仕方ないよね、という話でした。

とは言うものの、とあるコンパイラではC++11にしただけで1.x倍になったという事例もあるらしいので、やっぱりムーブのおかげで速くなることも結構あります。
どっちやねん!って感じになりますが、「期末試験よくできたかもと思ったけど点数低かったら傷つくから、点数は低いものだと思っておこう」ぐらいの感じでいればよいのかなと思います。

完全転送

std::forwardとは

std::forwardは「完全転送した呼び出しにする」関数です。
完全転送とは、最初に述べた通り、仮引数の元の実引数の完全な型情報も他の関数の実引数に渡すことです。

ここで、fが多重定義されていて、そのfを呼び、かつそれ以外の共通の処理をまとめたテンプレート関数Fを考えましょう。

C++03以前の場合、まず、

と書いてしまうと思いますが、これは完全転送できていません

なぜなら、F(x)と呼んだ時にxvにコピーされるため、fに渡る引数は別の実体を指します(中身はコピーされているので同じに見えますが)。
それにそもそも、コピーコストが無視できないオブジェクトだと、コピーが走るのは避けたいです。

では、コピーされるのが問題だから参照にすればいいでしょうか?

いいえ、これはそもそも失敗です。なぜなら、右辺値を渡せないからです。

さて、そこで、C++11からは右辺値も参照が使えます!

これで右辺値が渡せます。ただし、まだ不完全です。

なぜなら、復習のところでも書きましたが、仮引数は必ず左辺値なので、Fを経由すると右辺値参照を取るfを呼べないのです。よし分かったじゃあstd::moveすればいいだろう、と思いつくかもしれませんが、それはそれで今度は左辺値参照を取るfを呼べません。

つまり、完全転送を実現するには、Fの実引数が右辺値ならfの右辺値も右辺値に、左辺値なら左辺値にと適切にキャストする必要があります。
それを実現するのがstd::forwardです。

これで完全転送できました。めでたしめでたし。

つまるところ、std::forward<T>(v)とは、vの

  • 参照元が右辺値なら、引数を右辺値に
  • 参照元が左辺値なら、引数を左辺値に

キャストする関数ということです。std::moveと同じく、実はstd::forwardもただのキャストであり、std::foward自体が転送するわけではないです。

std::forwardの仕組みと参照の圧縮

std::forwardを使った完全転送

で、std::forwardTを受け取って、参照元が右辺値か左辺値かを判断していると書きました。つまり、「Tに参照元が右辺値か左辺値かの情報が入っている」ということです。

ただ、別に新しい型みたいなのが追加されるわけではないです。実は、T&&の型推論をする時には、左辺値を代入されたらTは参照、右辺値を代入された非参照にするような仕様になっています。
例挙げると、

といった感じです。

ただ、実際にTがどっちなのかを直接観測するのは難しいのですが、TypeDisplayerのテクニックを使うと一応知ることができます。

なので、std::forward<T>

  • Tが参照の時、vの参照元は左辺値だったと判断して、左辺値参照を返す
  • Tが非参照の時、vの参照元は右辺値だったと判断して、右辺値参照を返す

という挙動をすれば良いわけです。

ではそんな挙動をどうやって実現しているのでしょうか。そこで参照の圧縮(reference collapsing)の出番です。

参照の圧縮ってなんのこっちゃと思うかもしれませんが、簡単に言うと、「実はコンパイル時には参照の参照があって、参照の参照を普通の参照に変換しているんだ!(なんだってー(AA略」みたいな話です。
参照の参照というのは、普通はコンパイルできない

みたいなやつのことです。こんなの出てこないだろうと思うかもしれませんが、例えばテンプレートとtypedef/エイリアスを使って

みたいなののTに参照を入れると、コンパイル時には登場します。例えば、Tに右辺値参照(int&&)を入れると、Typeは右辺値参照の左辺値参照(int&& &)になりますよね。

このようなコンパイル時に発生する二重の参照を単独の参照に変換することが参照の圧縮です。
参照には右辺値と左辺値があるので、パターンは4通りありますが、以下のような規則で変換されます。

便宜的に、二重の参照をT<1> <2>と書きます(例えばint& &&は<1>=&, <2>=&&)。この時の規則は以下の通り。
<1>/<2> 左辺値参照& 右辺値参照&&
左辺値参照& 左辺値参照 左辺値参照
右辺値参照&& 左辺値参照 右辺値参照

つまり例で言うと、int&& &&=int&&であり、その他は全てint&になるということです。
実際に確かめてみると、確かにそうなってるのがわかります。

こうしておくと、std::forward

  • T&&Tに非参照(int)や右辺値参照(int&&)を入れれば、右辺値参照が作れる
  • 左辺値参照(int&)を入れれば、左辺値参照を作れる

ようになります。

ということで、std::forwatd

と実装できます。

どういう動きをするかというと、int x;の場合、

forward(x)と左辺値を渡した場合
  1. Tは参照(int&)になる
  2. remove_referenceにより参照が外されて、非参照(int)になって、paramの型は参照(int&)に戻る。
  3. T&&int& &&と左辺値参照の右辺値参照になり、圧縮されて左辺値参照int&になる
  4. なので、全体としては、左辺値を受け取った時はその左辺値参照を返す
forward(1)あるいはstd::forward(std::move(x))と右辺値を渡した場合
  1. Tは非参照(int)になる
  2. 既に非参照なのでremove_referenceは何もせず非参照(int)のままで、paramの型は参照(int&)になる。
  3. T&&int&&と評価され右辺値参照になる
  4. なので、全体としては、右辺値を受け取った時はその右辺値参照を返す

という挙動を実現しています。

なお、参照の圧縮(つまり参照の参照)が起きる場合は実は4パターンしかありません。そのうち

  • typdefやエイリアス
  • テンプレートでのT&&での型推論

は既に説明しました。

3番目は、実はテンプレートではなくauto&&の型推論でも発生するということです。

これも直接観測は難しいですが、TypeDisplayerで確かめると確かにそうなっています。

このように、テンプレートやauto型推論する場合には、参照の圧縮によって、&&と書いても右辺値ではなく左辺値参照になることがあります。
最初に「&&は右辺値参照とは限らない」と書いたとおりです。このような型推論を伴う&&で、実際には左辺値参照になるかもしれない参照は、ユニバーサル参照などと呼ばれています。

あと最後の4番目は、decltypeでも起きます。
decltypeは引数に変数名を与えると変数の型になり、式を与えるとその左辺値参照になります。なので、int x;の時

  • decltype(x)は、int
  • decltype((x))は、int&

になります。ということは、

ということが起きます

完全転送できない場合とその対策

さて、先ほど以下の様な完全転送を実装しました。

でもこれは実はちょっと不完全です。なぜなら、引数の数が違う多重定義に対応していないからです。

引数の数も違う場合に対応するために、可変長にしましょう

これでどんな場合でも完全転送できるようになりましたね!!めでたしめでたし!

と言いたいところですが、実はこれでも、f(???)fwd(???)と???に同じものを書いた時に挙動が異なってしまうような完全転送できない場合が5パターン知られています

  • 参照がとれない場合
    • ビットフィールド
    • 定義のない静的(コンパイル時)定数フィールド
  • 型推論に失敗する・型推論結果が期待と異なる場合
    • NULL
    • 多重定義された関数・テンプレート関数
    • 波括弧の初期化子

でも大丈夫です。一応ちゃんと対策もあります。1つずつ見てみましょう。

まず最初。ここまで見てきたように、完全転送には元の引数に対する参照をとる必要があります(参照がないと、コピーしなきゃいけなくなるので)。
右辺値参照によって、ほぼすべての参照がとれるようになりましたが、実は参照がとれないパターンが2つあります。

参照が取れないパターン2つのうち、簡単な方は、ビットフィールドです。

これは、多くの場合、参照が取れません。

なぜか?参照はハード的にはポインタと同じなのでアドレスが必要ですが、例えば「4bit後ろのアドレス」って取れないからです。とれるのは、アドレスがとれるのはビットフィールドを汎整数型に変換した先だけです。

なので、逆に言えば、汎整数型に変換した先のアドレスで事足りるような場合、つまり

  • 非const参照
  • 元の構造体が右辺値

である時は値を書き換えないので、参照をとっても問題ないとみなされます。

どちらも、変換された汎整数型の実体(アドレス)を指す参照になります。

ということで、もう少し厳密に言うと、元の構造体が左辺値の場合にビットフィールドに対する非const参照の場合に参照が取れないので、この場合は完全転送もできないです。

これを回避するには、明示的にコピーを作ればよいだけです

参照が取れないパターンのもう片方は、定義のない静的(コンパイル時)定数フィールドです。

のように、汎整数型の場合、定義がなくてもFoo::Nを使うことができます。なぜなら、定数伝搬(const propagation)するので、定義(実体)がなくても、コンパイラがFoo::Nを10に置き換えなければならないからです。
なのでこの状態で参照を取ろうとすると、実体がない=メモリが割り当てられていないのでアドレスがとれず、つまり参照がとれない(コンパイルはできるけどリンクエラー)になります。

これ割とよくあって、私がよくやるのが、コンパイル時定数(constexpr)を完全転送しようとしてリンクエラーです。「コンパイル時定数なのに定義が要るとかなんでやねん!」ってなるので気をつけましょう(※一応、言われるがまま定義をすれば動きます)。

ただし、コンパイラによっては定義がなくても動いてくれるらしくて、それも規格上は許容されているらしいです(でも少なくともgcc/clangでは動かないです)。

以上のような参照が取れない場合以外、つまり参照がとれたとしても完全転送できない場合があります。
今までずーっと見てきたように完全転送はうまく型推論を駆使することで実現していますが、逆に言うと、型推論ができなかったり、推論結果が違うと、完全転送できません。そんなパターンが3つ知られています。

一番簡単なのはNULLを使った場合です。
NULLは実体は(だいたいは)0なので、可能な限りポインタではなく汎整数型(通常はint)に推論されます。

なので、ポインタの代わりにNULLを完全転送しようとすると、コンパイルエラーになります

解決策はnullptr使えです。

「今時まだNULLを使ってるなんておっさんみたい!」と現代っ子に言われないよう、NULLはもうやめましょう。

さて、次のパターンは、多重定義された関数・テンプレート関数です。
関数ポインタ型変数に関数を代入すると、関数への関数ポインタが取れるのは

と、太古の昔から変わらない現象ですが、多重定義された関数を関数ポインタとして取る場合、受け取る側の型が合う方を代入してくれるのもご存知ですか?

なんかいつもの型推論みたいに、右辺から左辺を推論するのではなく、左辺から右辺を推論しているので若干気持ち悪いんですが、まぁこれのおかげで多重定義されていても、型さえしっかりしてあれば関数ポインタを区別して取れます。

これは関数の引数にした時も同じです

が、これを完全転送しようとする失敗します

なぜなら、fwdはどんな型でも受け取る関数なので、いつものように代入先からgを推論することはできないからです。

回避策は、キャストするなどして代入元で型情報をつけてやればOKです。

なお、この問題はテンプレート関数でも同じです。

最後は波括弧初期化子です。これも、先の関数ポインタのように、代入先によって型を決定します

ということで、完全転送しようとすると、関数ポインタと同じように、どの型になるべきか推論できないという問題が発生します。

そして、この回避策も関数ポインタと同じで、ちゃんと明示的に型を指定すれば良いだけです。

まとめ

ということで、C++のムーブおよび転送についてざっと解説してみました。

実際使ってみないとややこしいだけに思えますが、特に高速かつ便利なライブラリを設計しようとしていると、この辺りを理解することはとても大事になります。
この辺りが一通り説明できたら、C++erレベル1は脱したと言ってもいいんじゃないかなと思います。

フィックスターズ広告

私は、実を言うと、ずいぶん前にstd::forwardを使えなくてあきらめたことがあったのですが、この発表の後、見返してみたら簡単に使えるようになってました。

フィックスターズは本物のC++プログラマーを募集しています
・・・というわけではないですが、高速化のお仕事が強いというだけあって、業務でもC++やC言語を使うことが多いですので、C++に強い関心がある方にもオススメです。

Work at Fixstars -フィックスターズで働いてみませんか?:

コメントを残す

メールアドレスが公開されることはありません。 * が付いている欄は必須項目です