こんにちは。田原です。
前回、6つの特殊メンバ関数の内の4つを解説しました。今回は残りの2つのうちの一つムーブ代入演算子とそれにまつわるムーブ・セマンティクス、そして、C/C++歴史上4番目のポインタ(的なもの)「右辺値参照」について解説します。ちなみに、1つ目はポインタ、2つ目は参照、3つ目はconst参照、そして4つ目が右辺値参照です。
1.ムーブ代入演算子
5つ目の特殊メンバ関数です。多くの場合、コンパイラが自動的に生成します。
前回の解説で使ったRAIIパターン・クラスであるBarの場合、次の29-33行目に書いたようなムーブ代入演算子が自動的に生成されます。
class Foo { int mData; public: Foo(int iData) : mData(iData) { std::cout << "Foo::Foo() : mData=" << mData << "\n"; } ~Foo() { std::cout << "Foo::~Foo() : mData=" << mData << "\n"; } int get() { return mData; } }; class Bar { Foo* mFoo; public: Bar() : mFoo(nullptr) { } Bar(Foo* iFoo) : mFoo(iFoo) { } ~Bar() { delete mFoo; } int get() { return (mFoo)?mFoo->get():-1; } #if 0 Bar& operator=(Bar&& iRhs) { mFoo = std::move(iRhs.mFoo); // ① return *this; } #endif };
見慣れない記述が2つありますね。
Bar&&
と&
を2つ連ねて書いてます。
Bar&
はBarへの参照でした。Bar&&
はBarへの右辺値参照です。従来の参照は&
1つで、右辺値参照は&
2つということです。(ついでにconst参照はBar const&
ですね。)-
std::move()
これは、ムーブしていいよという許可を与えるための記述です。暗黙的にムーブできない時に明示的にムーブを許可するために使います。
①の行では明示的にムーブが許可されているので、mFooの型(Fooへのポインタ型)に対するムーブ代入演算子が呼び出されて、そのムーブ代入演算子が、元のmFooをdeleteし、iRhs.mFooの値をmFooへコピー、そしてiRhs.mFooをクリアしてくれると万々歳です。
しかし、残念なことにポインタ型のムーブ代入演算子は定義されていませんし、定義できません。ムーブ代入演算子がない時はコピー代入演算子が呼ばれます。
従って、このケースではコンピュータに任せられないため、自分でムーブ代入演算子を定義する必要があります。
Bar& operator=(Bar&& iRhs) { if (this != &iRhs) { delete mFoo; mFoo = iRhs.mFoo; iRhs.mFoo = nullptr; } return *this; }
(自分自身が渡された時は、何もするべきでないので不一致の時のみ処理しています。)
とはいえ、int型等の「ムーブ」操作が本質的に存在しないような型はコピーで良い場合が多いですし、ムーブ代入演算子を持っている時はそちらが呼ばれますので、コンピュータが定義するムーブ代入演算子で適切に動作する場合も少なくありません。
後、特殊メンバ関数の最後の1つムーブ・コンストラクタが残っているのですが、解説の都合上次回説明します。
2.ムーブ・セマンティクス
「右辺値参照」というキーワードで検索すると、一緒に「ムーブ・セマンティクス」という言葉が概ねセットで出てきます。
私にとってはどうもこの言葉は理解し辛いです。ムーブは移動、セマンティクスは意味論なので、直訳すると「移動意味論」です。何かすごく難しいことを意味しているような気がしてしまいます。情けないですが「◯◯論」などと書いてあるとついつい避けてしまいたくなります。でも、実は言っていることは簡単なことでした。
前回、operator=を例に取り「字面から受け取る印象と全く異なるような定義をしないよう強くお勧めします」という話をしました。「左辺を右辺に設定する」なんて使い方は絶対やめましょうとサンプル・ソースを使って説明しました。
これを「セマンティクス」という言葉を使って表現すると「代入セマンティクス」です。a=b;
は通常bをaへ代入します。これを逆方向に代入するような意味的に間違った使い方はやめましょうということです。
つまり、ムーブ・セマンティクスとは、文法的にはできたとしても意味的に「ムーブ」ではないような定義はやめましょうというお約束です。(必須ではないですが破る時にはそれなりに強い理由付けと周知徹底をするべきです。)
2-1.不憫なstd::auto_ptr<>のお話再び
前回、不憫なstd::auto_ptrの話をしました。
std::auto_ptrはoperator=
を「代入」とはそれなりにズレた定義をしていました。a=b;
とした時bが保持していたデータが空(nullptr)になってしまうのです。(絶対ダメという程ではないですが、ある程度C++プログラマの規範になる標準ライブラリではできるだけ避けたい使い方かもしれません。)
この問題を回避するには、要するに代入を使わなければ良いです。std::auto_ptrのようにオブジェクトの所有権を管理する場合、所有者の数を管理する負荷を惜しむ時はコピーできませんから、代入を止めてしまうのは一つでしょう。そして、代わりにムーブ関数を実装する方法もあります。
次のようにdelete指定すれば自動生成を禁止できます。そして、move関数を元々のoperator=と同じ内容で定義すれば良いです。
Bar& operator=(Bar const& iRhs) = delete; // コピー代入演算子の自動生成禁止 Bar& operator=(Bar&& iRhs) = delete; // ムーブ代入演算子の自動生成禁止 Bar& move(Bar& iRhs) { if (this != &iRhs) { delete mFoo; mFoo = iRhs.mFoo; iRhs.mFoo = nullptr; } return *this; }
これにより次のように記述できます。
int main() { Bar bar0(new Foo(123)); Bar bar1; std::cout << "bar0=" << bar0.get() << "\n"; bar1.move(bar0); std::cout << "bar0=" << bar0.get() << "\n"; }
move関数のパラメータはBarへの参照ですので変更することが読み取れます。関数名もmoveですし、この構造でムーブして何ら問題ないです。前回はa=b;
でbが空になるのが嫌だったのですがa.move(b);
でbが空になっても驚く人はあまりいないでしょう。
これならば、ただでさえ理解するのが難しい参照に右辺値参照という新しいまた別の「参照」を導入しなくて済みます。
なぜ、そうしなかったのでしょう? あれこれ考えてみました。
2-2.従来の参照では右辺値を受け取れない
第6回目で軽く触れているように、式を計算する際の途中の値(右辺値)を記録するために一時オブジェクトが確保されます。この一時オブジェクトは記憶領域なのでC++の「参照」で参照できてもよいように思います。この右辺値が記録されている一時オブジェクトを従来の参照で参照できれば右辺値参照という新し参照を導入する必要がありません。
しかし、従来の参照では右辺値を参照できないと決められています。
2-2-1.gccの場合
#include <iostream> struct Foo { int mInt; Foo(int iInt) : mInt(iInt) { } }; Foo Bar(int x) { return Foo(x*2); } void Baz(Foo& iFoo) { std::cout << "Baz() : iFoo.mInt=" << iFoo.mInt << "\n"; } int main() { Foo foo=Bar(123); Baz(foo); // Baz(Bar(456)); // NG }
gccの場合、 NG行のコメントアウトを外してコンパイルすると次のようなエラーが表示されます。
error: invalid initialization of non-const reference of type 'Foo&' from an rvalue of type 'Foo'
最初のBaz呼び出しと2つ目のBaz呼び出しはほとんど同じことをしていますが、C++にとっては大きな相違があります。
前者はfoo変数を渡しています。変数は「容器」であり左辺値なので渡しているものは左辺値です。
後者はBar()関数の戻り値であるFooを渡しています。これはBar()関数から戻された領域を一時的に保持しておき、この式の処理が終わったら解放されますので、一時オブジェクトです。つまり右辺値です。
右辺値のことを標準規格ではrvalue
と定義しています。(かなり厳密に定義されていますが、結構難しいですし、そこまできっちり理解する必要はないので厳密な定義の説明は割愛します。)
このエラー・メッセージは「非const参照をrvalue(右辺値)では初期化できない」と言っています。つまり、通常の参照では右辺値を受け取れないと言っているのです。
2-2-2.VC++の場合
しかし、VC++では実は受け取れる場合があります。
いつものようにVC++で警告レベルを上げてビルドすると次の警告がでますが、ビルドできます。
warning C4239: 非標準の拡張機能が使用されています: '引数': 'Foo' から 'Foo &' への変換です。
つまり、標準規格にはないがVC++の拡張機能で受け取れるようにしているということですね。
しかし、通常はFoo型変数をFoo&へ変換できますので、このメッセージは意味不明ですね。初めて出会った時は本当に頭痛かったです。gccで同じものをビルドして上記のエラー・メッセージを見て初めて理解できました。
次のコードはBar()関数の戻り値がFooからintへ変わっただけですが、これをVC++でビルドすると
error C2664: 'void Baz(int &)': 引数 1 を 'int' から 'int &' へ変換できません。
というエラーがでてコンパイルできません。頭が痛くなってきます。
#include <iostream> int Bar(int x) { return x*2; } void Baz(int& iInt) { std::cout << "Baz() : iInt=" << iInt << "\n"; } int main() { int foo=Bar(123); Baz(foo); Baz(Bar(456)); // NG }
どちらともインスタンスを返却しています。これらは他の寿命の長い変数への参照ではなく、Bar()関数から戻る際に確保される一時領域に記録される一時オブジェクトです。つまり右辺値です。
正直頭の痛い拡張仕様です。素直に標準規格通り、参照は右辺値を受け取れないとすればよいのにと思ってしまいます。
実はこんな文書をマイクロソフトは公開しています。
一時オブジェクトは非 const 参照にバインドできない
この記述をみて2015か2017ではバインドされなくなったのかな? それにしては資料が古いが。と思ってたのですが、上記のように未だにバインドされてしまいます。恐らくこの変更を採用すると多くのソースをビルドできなくなるのではないかと思います。そこで互換性のためにエラーとするのを断念して警告とし、「これは拡張機能である」としたのかも知れません。
2-3.従来の参照では右辺値を受け取れないということは
右辺値はその式が終わると破棄されます。つまりムーブしても問題ないです。どうせ使わないのですから、式が終わった後なら「空」になろうが問題は出ません。プログラマはビックリしません。
なのに、右辺値を受け取れる参照はconst参照だけです。constなので変更できませんから、当然ムーブできません。
無条件でムーブしてよい右辺値なのにムーブする術がないです。これは勿体無いです。 ムーブならポインタの先のオブジェクト(でかいこともある)をコピーせずにポインタの挿げ替えだけで済むのに、その術がないのです。
可能であれば、右辺値を受け取れる方法が欲しいです。
つまり、「従来の参照で右辺値を受け取れないのなら、右辺値を受け取れる新しい参照を作ればいいじゃないか」ってことで作られたのが「右辺値参照」ではないかと思います。良くそんなことを思いつきますよね。頭のいい人って本当に凄いです。
3.std::move
右辺値(式が終わると消える値)を受け取れる右辺値参照は、更に好都合なオプションを追加されてます。
左辺値をムーブしたい時もあります。所有権を他のクラスへ引き渡したいような時です。std::auto_ptrの代入演算子のような働きをさせたい時です。
std::auto_ptrは普通に代入しただけでムーブされるのでビックリしました。
では、明示的にムーブして良いと許可を与えた時だけは左辺値でもムーブできるようにすれば、プログラマがビックリすることはないです。
そこで、明示的に左辺値(変数)を右辺値(偽装一時オブジェクト)へ変換するのがstd::moveです。
左辺値の寿命はその変数が有効な間ですから、式が終わったら消える右辺値の寿命より長いです。std::moveは、「本当は寿命は長いけど右辺値のように寿命が短いものとして扱っても良い」(つまりムーブして良い)と偽装するものです。
具体的には右辺値参照へのキャストです。でも右辺値参照へのキャストとして覚えるとちょっと混乱しやすいです。(次回触れる予定です。)std::moveは左辺値(変数)を右辺値へ偽装する機能と覚えておくとすっきりすると思います。
2-1.のサンプルでは、右辺値参照を使わず、moveメンバ関数を定義しました。
Bar& move(Bar& iRhs) { if (this != &iRhs) { delete mFoo; mFoo = iRhs.mFoo; iRhs.mFoo = nullptr; } return *this; }
次のように呼び出しました。
bar1.move(bar0);
そして、Bar(new Foo(789))
は右辺値(この式が終わると消える)なので参照で受け取ることができなため、次のように使うことはできませんでした。
bar1.move(Bar(new Foo(789)));
これが次のように変わります。
Bar& operator=(Bar&& iRhs) { if (this != &iRhs) { delete mFoo; mFoo = iRhs.mFoo; iRhs.mFoo = nullptr; } return *this; }
次のように呼び出します。
bar1 = std::move(bar0);
そして、Bar(new Foo(789))
は右辺値なので下記も可能です。(しかも、std::moveは省略可)
bar1 = Bar(new Foo(789));
3.まとめ
やっぱりムーブはハードですね。今回はいつになく苦労しました。
途中でムーブ代入演算子で自分チェックとdelete mFoo;
するのを忘れている事に気がついて大慌てで修正しました。(今から前回の該当箇所を修正します。)
代入演算子には自分が渡されることもあるので自分チェックしないとやばいケースがあります。実ムーブ処理を書くムーブ代入演算子はまさにそのケースですね。(他のムーブを呼び出すだけなら、なくても良い場合も多いでしょう。)
今回、ムーブ・セマンティクスと右辺値参照の有用性/必要性について自分なりの理解を書けたと思います。なぜにムーブを実現するためだけに「右辺値参照」などという大掛かりなものを導入したのか、結構長い間不思議に感じていたのですが、今回当記事を書くに当って各種調べた結果、納得できる結論を導けたと思います。
さて、次回は残っているムーブ・コンストラクタとムーブ、および、特殊メンバ関数周辺の話題を解説します。お楽しみに。