最近は自作ツール(lfl)の制作にハマっているのですが、そのおかげでかなり色々な知識が付いてきて、さらに楽しくなっております。その一環でstd::forwardなる関数があると知りました。
std::forward関数について調べてみたはいいものの、よくは分かっていませんでした。ざっくりと「ムーブに関係した関数らしい」といった程度の認識でしかありませんでした。
とは言え、std::forwardを使えば、より安全なコードを書けたり、コードを書くのが楽になったりするかもしれないので、もう少し詳しく理解したいところです。何より、知っているようで知っていないというのは気持ち悪いですしね笑。
現状では自作ツールの制作もとりあえず一段落している(これからまだまだ使いやすくしていく予定ですが)ので、自作ツールの制作は一旦脇に置いて、std::forwardの動作を軽く調べてみました。
左辺値とか、右辺値、左辺値参照、右辺値参照といった辺りも(ざっくりとですが)解説するので、「左辺値とか右辺値、右辺値参照なんかが気になって公式リファレンスを読んでみたけど、よく分からなかった」という方も多少は参考になるのではないでしょうか。
※std::forwardがコンパイルエラーとなるパターンも下の方に紹介していますから、エラーの原因を知りたい方はそちらもご覧くださいませ~(あくまでもstd::forwardが何をする関数かを理解することがこの記事のテーマなので、エラーの具体的な修正方法までは説明していませんが)。
テンプレート引数
まずはC++er御用達のstd::forwardの公式リファレンスを見てみますと、(C++14の部分だけを抜粋すると、)その宣言は次のようになっています。
template <class T>
constexpr T&& forward(typename remove_reference<T>::type& t) noexcept;
template <class T>
constexpr T&& forward(typename remove_reference<T>::type&& t) noexcept;
ある型Tをテンプレート引数として、T&&型を返すという関数になっていることが分かります。そして同リファレンスを見ると、その戻り値はstatic_cast<T&&>(t)
となっています。
つまり、引数としてTに&か&&が付けられた値を受け取って、その値をテンプレート引数で与えられた型に&&を付加した型として返すと。
これだけではまだ分からない人も多いでしょうから、もう少し詳しく解説していきます。
関数の大枠
この関数にはtemplateが使われていますので、まずはテンプレート引数から見ていくことにしましょう。このテンプレート引数は、戻り値と引数の2か所に使われています。
ですから例えば、テンプレート引数Tとしてint型が指定された場合(つまり、この関数がstd::forward<int>(変数かマジックナンバー)
という形で使われた場合)、これらの関数宣言は次のように置き換えられます。
constexpr int&& forward(typename remove_reference<int>::type& t) noexcept;
constexpr int&& forward(typename remove_reference<int>::type&& t) noexcept;
ここで使われているremove_referenceは、「テンプレート引数に指定された型が参照型(&もしくは&&が付いた型)だったら参照を除去した型を、参照型でなかったら指定された型をtypeとして定義する」というテンプレートメタ関数です。
ここでremove_referenceのテンプレート引数に指定されているのは(参照型ではない)int型ですから、remove_reference<int>::typeはint型になります。
ですから最終的には、上記の宣言は次のように置き換えられることになります。
constexpr int&& forward(int& t) noexcept;
constexpr int&& forward(int&& t) noexcept;
これはforwardのテンプレート引数が参照型でない場合でしたから、参照型だったらどうなるかも見てみます。
std::forwardのテンプレート引数が参照型だった場合
※ここからは「右辺値参照」や「左辺値参照」という単語が出てきます。何のことか分からない方もいらっしゃるでしょうが、後で解説しますからご安心ください。とりあえずここでは、「&が付けられた型は”左辺値参照”で、&&が付けられた型は”右辺値参照”なのだ」と覚えておいてください。
テンプレート引数にint&(左辺値参照型)が指定された場合(std::forward<int&>(変数)
として使われた場合)は、テンプレート引数Tは次のように置き換えられることになります(しかし後述しますが、実際にこのような置き換えが発生するわけではないかもしれません。なので、(以降も)あくまでも便宜的に思考のプロセスを書いているのだとお考え下さい)。
constexpr int& && forward(typename remove_reference<int&>::type& t) noexcept;
constexpr int& && forward(typename remove_reference<int&>::type&& t) noexcept;
先ほどと同じく、remove_referenceの働きによって、これらはさらに次のように置き換えられます。
constexpr int& && forward(int& t) noexcept;
constexpr int& && forward(int&& t) noexcept;
引数は先ほどと同じですが、戻り値の型が先ほどとは違って奇妙な型になっています。これは、規格書N4860の9.3.3.2で定められている言語仕様によって、(少なくともC++20では)int&型として扱われます。
ここでC++20の規格書を軽く読んでみます。参照に関する事項は9.3.3.2に書かれていますので、そこを読んでみましょう。
特に注目していただきたいのは項目6です。
そこでは「型推論中に参照への参照が出てきたらこう解釈する」という型推論のルールが書かれています。つまり参照の計算規則みたいなものですね。
その規則は「reference collapsing」と呼ばれているそうです。日本語訳するなら、「参照崩し」「参照落とし」「参照畳み込み」「参照縮減」「参照縮約」「参照壊食」「参照壊釈」辺りになるのでしょうか。
個人的には「参照落とし」が意味的には最も近い(「参照壊釈」も気に入っています)と思うので、この記事に限っては、このルールを「参照落とし」と呼ばせていただきます。この日本語訳は、間違った日本語訳かもしれませんので、調べてみることをお勧めします(妙なところで手抜き)。
参照落としを引用すると、次のようになっています。
If a typedef-name ([dcl.typedef], [temp.param]) or a decltype-specifier ([dcl.type.simple]) denotes a type TR that is a reference to a type T, an attempt to create the type “lvalue reference to cv TR” creates the type “lvalue reference to T”, while an attempt to create the type “rvalue reference to cv TR” creates the type TR.
n4861 9.3.3.2 dcl.ref#6
この中から、今回関係のある部分を和訳(意訳)すると、
型TRが型Tへの参照だったとき、(中略)、型”cv TRへの右辺値参照”を作ろうとする試みは、型TRを生成する。
といった辺りになりそうです(ただし、後で注意する通り、ここでのTとソースコード中のTが別物であることに注意してください。尚、僕はtypedef-nameなど、型システムについてそこまで詳しいわけではないので、間違っている可能性も十二分にあります)。
これを分かりやすくするために、もう少しだけ具体化して考えてみます。
型を計算しているとき、参照型への右辺値参照が出てきたとします。例えば、ソースコード上ではT&&と書かれていたものが、Tはconst int&であると推論され(あるいは、明示的に与えられ)て、全体としてはconst int& &&と置き換えられた(※)場合などです。std::forwardの戻り値に出てきているパターンですね。
※ 規格書上ではan attempt to create the type “rvalue reference to cv TR”(cv TRへの右辺値参照が生成されようという試み)となっているので、T&&のTにconst int&が入ると検出したら、const int& &&への置き換えを介さずに、T&&がいきなりint&に置き換えられるのかもしれません。
※続. ですが、僕はコンパイラには詳しくないので、具体的にどのような処理が実行されているのかは分かりません。ですから、先ほども注意しました通り、この「置き換え」というのは、あくまでも思考を補助するための比喩表現とお考えください。
ここで、ソースコード中に記述されたTと、規格書中に出てきているTとは違うものだという点に注意してください。つまり、(ソースコード上の)T&&はconst int& &&と置き換えられるけど、(規格書で言うところの)Tに当たるものはintで、TRに当たるものはint&であるということです(規格書のTRの前についているcvはconstやvolatile修飾のことを表しています)。
このことに注意していただければ、規格書で言うところのTRへの右辺値参照がint& &&に当たることも分かると思います。そして、最終的には「型”cv TRへの右辺値参照”を作ろうとする試みは、型TRを生成する」のですから、(ソースコード上の)T&&はint&となることが分かります。
この参照落としから、(ソースコード上の)T&&がどのように計算されるかをまとめると、次のようになります。
- (ソースコード上の)Tがint&やstd::string&など(左辺値参照)だった場合、それぞれint&やstd::string&など(左辺値参照)として扱う
- (ソースコード上の)Tがint&&やstd::string&&など(右辺値参照)だった場合、それぞれint&&やstd::string&&など(右辺値参照)として扱う
ということになります。要するに参照型TRへの右辺値参照の場合は、その参照型そのものになるということです(参照型TRの左辺値参照の場合は、また挙動が違っているのでご注意ください)。
これまでの話を念頭に置いてもらった上で、9.3.3.2の項目6に用意されているコード例も読んでみましょう。ここで読みたいのは、(std::forwardの戻り値に関係する)r3を定義している行だけです。その行を抜粋すると、次のようになっています。
const LRI&& r3 = i; // r3 has the type int&
このLRIというのは、同コード例中、r3の定義よりも上の行で、typedefによりint&
として定義されています。つまり、r3はconst int& &&
という型の変数として定義されているということになります。
r3の型を、参照落としに当てはめてみると、const TR &&ということになります。参照への右辺値参照のパターンですね。このパターンの場合はTR部分だけが残るのですから、これは(コード例のコメントにも記載がある通り、)int&型に読み替えられます。
そのため、template引数にint&が与えられた場合、std::forward関数は次のように扱われます。
constexpr int& forward(int& t) noexcept;
constexpr int& forward(int&& t) noexcept;
このコードを見ると、int&を与えられたときのforward関数は、「引数に左辺値参照にマッチする値が渡されたときも右辺値参照にマッチする値が渡されたときも、左辺値参照の値を返す関数だ」と言えそうです(戻り値については後で見るので、とりあえずは宣言部分だけを気にしてください)。
さて、forward関数のテンプレート引数に与えられる型が、int(参照型でない)とint&(左辺値参照)の場合を見てきましたから、最後に右辺値参照int&&の場合(std::forward<int&&>(変数かマジックナンバー)
と使われた場合)を見てみます。
まず、テンプレート引数Tがint&&に書き換えられるので、forward関数は次のように置き換えられます。
constexpr int&& && forward(typename remove_reference<int&&>::type& t) noexcept;
constexpr int&& && forward(typename remove_reference<int&&>::type&& t) noexcept;
そしてremove_referenceによって、次のように置き換えられます。
constexpr int&& && forward(int& t) noexcept;
constexpr int&& && forward(int&& t) noexcept;
この戻り値の型がint&& &&になっていますが、先ほど解説した参照落としから、これはint&&として扱われます。参照落としのコード例で言えば、r5を定義している行と同じパターンです。
なので、これらの関数は最終的には次のように置き換えられることになります。
constexpr int&& forward(int& t) noexcept;
constexpr int&& forward(int&& t) noexcept;
これを見ると、テンプレート引数としてint&&が与えられた場合、「forward関数は引数に与えられた値が、左辺値参照にマッチするような値だろうと、右辺値参照にマッチするような値だろうと、右辺値参照を返すような関数」ということになります。
これまで見てきた型の対応を表にすると、次の表1のようになります。この表は例としてint型の場合を表したものですが、他の型でも同様です。今までほとんど言及してきませんでしたが、一応戻り値も載せておきました。
テンプレート引数に指定する型 | 戻り値の型 | 引数tの型 | 戻り値 |
int | int&& | int& or int&& | static_cast<int&&>(t) |
int& | int& | int& or int&& | static_cast<int&>(t) |
int&& | int&& | int& or int&& | static_cast<int&&>(t) |
この表を見ていただくと、テンプレート引数がどれであっても、引数の型はすべて同じくint&とint&&の2種類になっていることが分かると思います。ですが、戻り値の型は違っています(static_castのテンプレート引数も)。intとint&&のときと、int&のときという2パターンに分かれています。
以上のことをまとめると、「std::forward関数は、テンプレート引数に与えられた型が、左辺値参照か否かで挙動(戻り値)が変わるような関数である」と言えそうです。
より具体的には、「テンプレート引数に左辺値参照以外(intなど、参照型でない場合も含むことに注意してください)が指定されたときは、右辺値参照を戻り値の型として、テンプレート引数に左辺値参照が指定されたときは、左辺値参照を戻り値の型とする」ような関数と言えそうです。
これによって何が起こるのかを考えていきます。
関数の具体的な処理
ここまでは、”型”という関数の大枠しか見ていませんでしたから、次は引数や戻り値の具体的な”値”の方を見ていきます。
先ほどの対応表1を見ると、std::forwardは関数オーバーロードにより、引数tの型が左辺値参照である場合と右辺値参照である場合も定義されているということが分かります。
つまり、std::forward<int>(左辺値参照にマッチするような値)
と呼び出された場合は、std::forwardの左辺値参照版が呼び出されて、std::forward<int>(右辺値参照にマッチするような値)
と呼び出された場合は、右辺値参照版が呼び出されるということになります。
これによって、例えば「左辺値参照にマッチするような値にしか使えない」みたいな事態を防いでいるわけですね。
そして、この「左辺値参照にマッチするような値」が「左辺値」と呼ばれる値で、「右辺値参照にマッチするような値」が「右辺値」と呼ばれる値になります。
ここで、「左辺値」「右辺値」という言葉が出てきました。ということで、ここから「左辺値」と「右辺値」について説明します。
左辺値と右辺値
とはいっても、そんなに難しいことは解説しません(というか、できません)。
ひとまず基本的には、左辺値は名前が付けられている値一般のことで、右辺値は名前が付けられていない値一般のことだと考えてください。変数名を介して値を読み書きできたら左辺値で、そうでなければ右辺値だと考えれば分かりやすいかと思います。
そして右辺値とは、変数名を介して値を読み書きできないような値ですから、アドレスを取ることができないような値とも言えるかもしれません。
例えば、次のコードを見てください。
int main() {
2; // 右辺値
return( 0 ); // 0は右辺値
}
2も0も、変数名を介しては操作できない値です。文が実行されたら、すぐさま破棄されます。「右辺値はアドレスを取ることができない」とも説明しましたから、それをコード例で確認してみましょう。例えば、次のコードはエラーとなります。
int main() {
int * i_p = &2; // エラー(2のアドレスを取ることはできない)
}
破棄されたアドレス(プログラムの管理下にないメモリ)を操作することは、危険な操作です。
次に、右辺値以外も記述されたコードも見てみます。次のコードを見てください。
int main() {
int i = 2; // iは左辺値、2は右辺値
return( 0 ); // 0は右辺値
}
このとき、return文が実行されるときにiは破棄されているでしょうか。つまり、returnの引数としてiを使えるでしょうか?あるいは、iのアドレスを利用することはできるでしょうか?
聞くまでもなく、普通に使えますよね。
今の話を寿命という観点で整理し直してみると、次のようなことになります。
「2という値(というか、その実体)の寿命はint i = 2;
という一文が実行されるまでだが、iの寿命はint i = 2;
という一文が完了するまでではなく、そのブロックの終了までだ」
※読まなくていい補足:実際の動作がどうなっているか、僕は知りませんので、ここでの説明は厳密には間違っている可能性があります。可能性の話をすれば、2という値が入れられているメモリにiという名前を付けて、寿命を延ばしている可能性もありますし、iという名前でメモリを確保しておいて、そこに2という値をコピーして、元々の2を破棄するという操作をしている可能性もあります。それら以外の操作をしている可能性もあります。
このように、寿命という観点で見れば、左辺値と右辺値に違いがあることが分かると思います。要するに、一文を実行したら即座に破棄されるような値は右辺値で、式や関数を実行し終わっても破棄されずに保持されているような値は左辺値だと(実際には延命することもできるので、寿命だけで完璧に判定できるわけでは無さそうですが)。
左辺値と右辺値をもう少し人間味を持たせて解釈するなら、「右辺値は、その文が実行された後の使用を想定”しない”と明示した値のこと」で、「左辺値は、その文が実行された後の使用を想定”する”と明示した値のこと」といった辺りになるでしょうか。
これが基本的な話です。名前を介して利用できるなら左辺値で、そうでないなら右辺値だという原則を、ひとまず飲み込んでから次からの話を読んでください。
次にもう少し込み入った話をします。次のコードを見てください。
int func( int i ) { return i; } // iは左辺値?右辺値?
int j = 0; // jは左辺値、0は右辺値
int f = func( 2 ); // fは左辺値。では、2とfuncの戻り値は左辺値?右辺値?
今までの話から、とりあえずjとfが左辺値であることと、0が右辺値であることは理解できると思います。
では、2やiはどうでしょうか。
結論から言えば、2は右辺値で、iは左辺値になります。そして、func( 2 )を実行して得られる2という値は右辺値です。
funcを呼び出している側からすれば、int f = func( 2 );
という一文が完了した時点で、関数に与えられている2は破棄されます。なので、関数に与えられている2は右辺値です。
ではiはどうでしょうか。関数に値が渡されるときは、そのコピーが渡されます(※)。つまり、funcの呼び出し元でもfuncの内部でも、同じ2という値を利用してはいますが、利用している物自体はまったくの別物という状況が起こっているわけです。
※有限ではあるけど、めちゃくちゃ長いメモリ(箱の列)を想像してみてください。そのメモリ上のどこかに、funcに渡すための2が記入されています。そして、funcが呼び出されると、その2が保持されている場所とはまた違った場所に2を記入して、iと名付けます。そして、funcからはそのiを利用することになります。
そのため、呼び出し元の2は右辺値、func内部の(iと名付けられた)2は左辺値ということになります。
まとめると、func関数には右辺値が渡されて、func関数は同じ値を左辺値として受け取って、右辺値を返しているということになります。
※このfunc関数では、iをreturnの引数として使っているだけですが、func内部に、他のiを利用した処理を書けます。その意味では、iは引数に渡された後も使われると想定していると言えるでしょう。
以上で、とりあえず左辺値と右辺値のイメージは分かっていただけたと思います。
それでも分かりづらければ、とりあえずの取っ掛かりとして、(正確ではないものの)「マジックナンバーと関数の戻り値は右辺値だ。それ以外は左辺値だ」と、ざっくりと覚えておけば、多少は役に立つと思います。
※後はプログラミング経験を積んでいくと、次第に左辺値と右辺値の違いも分かってきて、それらのより詳細な分類分けも理解できるようになると思います(「std::moveの戻り値はxvalueだ」みたいに、左辺値と右辺値よりも、さらに細かな分類もあるのです)。
さて、ここまで左辺値と右辺値を解説してきたのは、forward関数の動作を理解するためでした。ということで、節を変えてforward関数を解釈し直してみます。
右辺値参照も左辺値
2つ上の節で、「forward関数はテンプレート引数に左辺値参照が与えられた場合は、引数tを左辺値参照に変換して(引数に何の変更も加えないで、そのまま)返して、それ以外は引数tを右辺値参照に変換して返す関数だ」と説明しました。
この説明を上の節で出てきた考え方を使って表現し直すなら、「forward関数はテンプレート引数に左辺値参照が与えられた場合は、引数tを”それ以降も利用される可能性がある値”として返して、テンプレート引数が左辺値参照以外なら、引数tを”それ以降は利用されない(すぐさま破棄される)値”として返すような関数だ」ということになります。
こう考えると、少しは分かりやすくなったのではないでしょうか。
つまり、左辺値(それ以降も利用される可能性がある値)に変換するのか、右辺値(それ以降は利用されない値)に変換するのかを、テンプレート引数で静的に制御しているのだと。
ここまでで、関数の型と戻り値を見てきました。あとは引数を理解できたら、std::forward関数を理解できたことになるでしょう。ということで、ここからは「何を右辺値や左辺値に変換するのか」を見ていきます。
その前に、上の節で関数の取る引数について、「関数は与えられた右辺値を、左辺値として受け取っていることになる」というような説明をしていた部分を思い出してください(文言としては「func関数には右辺値が渡されて、func関数は同じ値を左辺値として受け取って、右辺値を返している」といった辺りです)。
それは奇妙な話に感じます。というのも、それは「左辺値と右辺値という区別が存在しているのにも関わらず、その区別を無視している」ということになるからです。
これは喩えるなら、0.0という小数値と0という整数値を区別せず、同様に扱うようなものです。これは、場合によっては問題が起こることが分かると思います(例えば、2つの数を引数にとって、その商を返す関数を定義しようとしたときなど)。
もしも左辺値も右辺値のどちらにもマッチする関数しか書けなかったとすると、「引数が左辺値か右辺値かで、適用する処理を分けたい」と思っても、そのような細かな制御を実装する方法が与えられていないということになります。
それでは不便だ(※)ということで用意されているのが、今までは&や&&を型名に付けたものとしか説明してこなかった左辺値参照や右辺値参照になります。
※ この記事は、あくまでもstd::forwardの解説であって、左辺値や右辺値、左辺値参照、右辺値参照とstd::forward関数の関連を解説する記事です。そのため、右辺値参照があると何が嬉しいかという話は、僕には少し違った話題であるように感じられるからです。
※続. もしその辺りの話題に興味がある方は、「ムーブ」や「ムーブセマンティクス」といったキーワードで調べてみてください。ムーブセマンティクスは、「ポインタの挿げ替えを利用して、コピーコストを削減したオブジェクト作成」といった感じの概念になります。
といっても特に難しいことはなく、単に右辺値参照は右辺値にマッチする(※)ような型で、左辺値参照は左辺値にマッチするような型というだけです。要するに、一種の型なのです。
※ ややこしいのですが、const左辺値参照(int const &など)は、右辺値にも左辺値にもマッチします。
この「右辺値参照は右辺値にマッチして、左辺値参照は左辺値にマッチする」という性質があることによって、左辺値が与えられたときと右辺値が与えられたときとで、処理を書き分けられるようになっています。
このことを、コードと併せて確認していきます。次のコードを見てください。
int func() { return 2; }
int i1 = func();
int& i2= 2; // エラー(左辺値参照に右辺値を渡しているので)
int& i3 = func(); // エラー(左辺値参照に右辺値を渡しているので)
int& i4 = i1;
int&& i5 = 2;
int&& i6 = func();
int&& i7 = i1; // エラー(右辺値参照に左辺値を渡しているので)
int&& i8 = i5; // エラー(右辺値参照に左辺値を渡しているので)
上記コードには変数i1からi8までが宣言されていますが、その内のi2とi3、i7、i8はコンパイルエラーとなります。
これらのコンパイルエラーは、どれも左辺値参照か右辺値参照という型と、左辺値か右辺値かという値(実体)との食い違いによって起こっています。
それを念頭に置いて、それぞれのコンパイルエラーを確認します。まずはi2とi3です。これらはどちらも同じ理由でコンパイルエラーとなっているので、ここでは代表してi3だけを説明します。
i3の場合は、int型の左辺値参照であるint&型として宣言されています。関数の戻り値は基本的に、それ以降は使われる予定がない右辺値として返されます(ただし、左辺値参照int&などを返す関数として宣言されていない限りです。)。
ここも例外ではなく、i3に渡されているのは右辺値となっています。左辺値参照であるi3に右辺値を渡しているので、コンパイルエラーとなっているわけです。
次に、i7のコンパイルエラーを見てみます。
i7とi8では、i3(とi2)とは逆のパターンでエラーとなっています。i7もi8もどちらも右辺値参照に左辺値を渡しているからエラーとなっているのですが、i8は分かりづらいと思うので、それらを分けて説明します。
i7の場合は、int型の右辺値参照int&&として宣言されているi7に値を渡すとき、変数名を利用しています。
変数名を利用している時点で、「宣言した後も使い続けるぞ(だからすぐには破棄しないでおくれよ)」と宣言しているようなものですから、i7で渡されているのは左辺値になります(左辺値参照の場合はポインタみたいな使い方になるので、意味は値渡しと同じではありませんが、型だけを考えるならの話です)。
※この「すぐさま破棄しないように指示する」ということを「束縛」と呼ぶのかなぁと思ってますが、言葉の意味が正確には分かっていないので、この記事では「束縛」という単語は使っていません。僕の勉強不足ですね。精進します。「束縛」の正しい意味をご存じの方がいらっしゃったら、僕のTwitter(DM)かお問い合わせの方から、参考文献などを教えていただけるとありがたいです。
結果として、右辺値参照という型を持った変数に左辺値を渡すという処理になってしまって、コンパイルエラーになっています。
初めて右辺値参照や右辺値という概念に触れた方は、このi8のパターンがエラーとなることに違和感を覚えるかもしれませんが、右辺値参照というのは、あくまでも右辺値を受け取ることだけを意図した型なのであって、”右辺値参照”という型を持った左辺値であるという点に注意してください。
i8に渡されているi5というのは、右辺値参照という型で宣言されてはいますが、あくまでもアドレスを取ることのできる変数であり、その一文が終わっても破棄されないような値であり、左辺値なのです。
要するにi5は「宣言した後も使い続けるぞ」な値なのです。そのため、”右辺値参照”という型を持った(右辺値しか受け取らないと宣言されている)変数i8に左辺値を渡そうとしているので、エラーとなっています。これはi7のエラーと同じですね。
以上の左辺値と右辺値、そして左辺値参照と右辺値参照の話を理解していただいた上で、再度std::forwardの確認に戻りましょう。
std::forwardのエラーとstatic_cast
ここまで左辺値と右辺値、左辺値参照と右辺値参照という概念を解説してきたのは、偏<ひとえ>にstd::forwardの戻り値を考えるためでした。std::forwardのテンプレート引数に指定する型と、戻り値の型などの対応表を再掲します。
テンプレート引数に指定する型 | 戻り値の型 | 引数tの型 | 戻り値 |
int | int&& | int& or int&& | static_cast<int&&>(t) |
int& | int& | int& or int&& | static_cast<int&>(t) |
int&& | int&& | int& or int&& | static_cast<int&&>(t) |
この対応表を、これまでに説明してきた言葉を使って、もう一度解釈し直してみます。
すると、「std::forwardとは、テンプレート引数に左辺値参照以外が与えられたときは、引数を右辺値に変換して、テンプレート引数に左辺値参照が与えられたときは、引数を左辺値に変換する関数だ」と言えそうです。
これをもう少し砕けた解釈にするなら、「std::forwardとは、テンプレート引数に与えられた型が、”これ以降も使うぞ”と明示した型でなければ、引数に左辺値が与えられても右辺値が与えられても”これ以降は使わないぞ”な値として返して、テンプレート引数に与えられた型が、”これ以降も使うぞ”と明示した型だったら、引数を”これ以降も使うぞ”な値として返すような関数だ」ということになるでしょうか。
…逆に分かりにくくなってしまった気がしますが、まぁ、そういうことです。
std::forwardの具体的な使用方法としては、対応表から、以下のように6つのパターンがあることが分かると思います。
int fi1 = 2;
int fi2 = 2;
int fi3 = 2;
auto f1 = std::forward<int>( fi1 ); // テンプレート引数が参照型でなく、引数が左辺値
auto f2 = std::forward<int>( 2 ); // テンプレート引数が参照型でなく、引数が右辺値
auto f3 = std::forward<int&>( fi2 ); // テンプレート引数が左辺値参照で、引数が左辺値
auto f4 = std::forward<int&>( 2 ); // テンプレート引数が左辺値参照で、引数が右辺値
auto f5 = std::forward<int&&>( fi3 ); // テンプレート引数が右辺値参照で、引数が左辺値
auto f6 = std::forward<int&&>( 2 ); // テンプレート引数が右辺値参照で、引数が右辺値
しかし、この内f4だけはエラーとなります。f4は、表で言う所のint&の行に相当します。そのときの戻り値はstatic_cast<T&>(t)
となっていて、引数tは右辺値として与えられています。
つまり、static_castは右辺値を左辺値にキャストしようとしているということになります。
ここで、右辺値についての説明を思い出してください。先ほど、右辺値のことを「それ以降は使う予定がないぞ(=その一文が完了したら、もう実体は破棄してしまってもいいぞ)」な値のことだといったような説明をしました。
そのため、auto f4 = std::forward<int&>( 2 );
という一文が完了したら、その時点で2という実体はなくなります。そして、2という数値を(一時的に)格納するために使われていたメモリは、他のプログラムが利用できる状態になります。
つまり、文全体を解釈するなら「f4という変数には、他のプログラムが使っている(かもしれない)メモリを参照させるぞ」という意味になってしまいます。
それは危険な動作です。(おそらくそのような危険な動作を予防するためだと思いますが、)このパターンの場合はコンパイルエラーとなる場合もあります。例えば「static assertion failed due to requirement ‘!std::is_lvalue_reference::value’: std::forward must not be used to convert an rvalue to an lvalue」というエラーが出ます。
このエラーメッセージは、「右辺値を左辺値には絶対に変換するな」と言っています。
ですが、すべての標準ライブラリでコンパイルエラーになるのかどうかは分かりません。少なくとも、gccの標準c++ライブラリには、このようなエラーを出力するためのstatic_assert文が記述されていました。
まとめ
この記事を書くまで、std::forwardの使い所が分からなくて悩んでいましたが、(動作だけなら)よく分かるようになりました。次は使い所を色々と模索していこうかと思います。
一言でこの記事をまとめるなら、「std::forwardは、引数のキャスト先を左辺値参照にするか、右辺値参照にするかが、テンプレート引数によって変更できるようになっている関数だ」といった辺りになりますかね。
そして、その仕組みを成り立たせているのが右辺値は右辺値参照とマッチして、左辺値は左辺値参照とマッチするようになっているという規則や、参照落とし(まだ日本語訳が無いなら、参照壊釈よ広まれということで、「参照解釈」という名前を再び出しておきます)という規則なのだと。
しかし、それらの規則を純粋に適用すると、右辺値を左辺値にキャストするという危険な操作が可能となってしまうとも説明しました(なので、そのときはコンパイルエラーが発生することもある)。
その規則や、右辺値から左辺値にキャストすることの危険性を理解するのには、左辺値と右辺値を理解している必要があるだろうということで、左辺値と右辺値も併せて解説しました。
今回の記事は、後ろの話が前の話に依存しているだけでなく、前の話が後ろの話に依存してもいるような構造になってしまっているので、何度か読み返すことをお勧めします。
本当は上から下まで読めば理解できるような記事を書きたかったのですが、力及ばず、そうは書けませんでした。しかも長い。
C++言語を書くのも難しいけど、日本語(自然言語)を書くのも難しい。
P.S. std::forwardや右辺値、右辺値参照のことをもっと知りたいという方は、以下が参考になります。
- 右辺値参照・ムーブセマンティクス – cpprefjp C++日本語リファレンス
- 本の虫: rvalue reference 完全解説
- 7分でわかる右辺値参照 – Qiita
- 右辺値、左辺値などの細かい定義 – Qiita
- The Forwarding Problem: Arguments (Document number: N1385=02-0043)
ムーブについては、特に以下の記事が分かりやすかったです。
P.P.S. サムネイルの花はムラサキツユクサです。では、白いムラサキツユクサの日本での花言葉は?