はじめに
コピーなしで値を受け渡せる滅茶苦茶便利なmove semanticsですが、ちゃんと理解しておかないとハマります。今回は、中でもややこしい右辺値参照型変数について、std::move
の戻り値を受けた場合どうなるか実験してみました。
結論を先に言うと、右辺値参照変数はstd::move
の戻り値を延命しないのでmove元を解放してはならないです。
(2017/6/14追記)
コメントでご指摘いただきましたが、そもそも、std::move
の戻り値を右辺値参照変数に代入すると左辺値参照と変わらないので、使いどころもないですし、わかりにくいからやめた方が良い、というのが教訓として得られました。
サンプルコードの共通クラス
本題に入る前に、以下の実験では次のクラスがあるものとして記述しています。
単にコンストラクタとデストラクタでログを出すだけのクラスです。このクラスを使ってどのタイミングでどのコンストラクタが呼ばれるかをみていきます。
おまけとして、初期化されたら100、解放されたら0、moveされたら+1のカウンターがついています。何回moveされたかを見ることができます。
#include <iostream>
struct hoge {
hoge() : i(100) { std::cout << "default!" << std::endl; }
hoge(hoge&& f) {
i = f.i + 1;
std::cout << "move!" << std::endl;
}
~hoge() {
i = 0;
std::cout << "destructor!" << std::endl;
}
int i;
};
右辺値参照とは
基本的な事項はここをみてください。
右辺値参照は 右辺値を束縛 する参照ということですが、いまいちよくわかりません。
右辺値は一時オブジェクト的なもの、となんとなく理解できても、右辺値を束縛ってどう言う意味だ??と思います。この文脈で必ず出てくるのが次の例です。
int&& rvalue_ref = 100;
この例では整数リテラル100
が右辺値参照変数 rvalue_ref
に束縛されています。リテラルは明らかに右辺値で、本来ならこの式の評価が終わったら即座に解放されてしまうはずですが、右辺値参照変数rvalue_ref
によって 延命され、生存期間はrvalue_ref
と同じになります。
std::move
の戻り値を右辺値参照してみる
C++のmove semanticsでは所有権の移動がうまくいった場合、元のデータはmove先に移動し、move元は中身がはっきりしないオブジェクトと言う微妙な状態になります。(ただし、今回の場合hoge
クラスはmoveコンストラクタ内で値をコピーしているので元オブジェクトの中身は保持されます)
さて、右辺値参照変数にstd::move
の戻り値を代入した場合どうなるのでしょうか?
では、右辺値参照型変数にstd::move
の戻り値を代入しただけの場合、所有権の移動が行われていない状態ですが、その参照変数とmove元のオブジェクトの関係はどうなるのでしょうか?
- 仮説1:
std::move
の戻り値は一時オブジェクトとなり、右辺値参照変数により延命される。元のオブジェクトは中身不定の状態になる。 - 仮説2: 右辺値参照型変数はmove元を参照する
実験してみましょう。次のコードと結果をみてください。
int main() {
//このコードは解放済みの領域への参照をしているのでかなり危険です!コピペしないでね。
hoge *x_original = new hoge();
hoge&& x1 = std::move(*x_original);
std::cout << "now x1=" << x1.i << std::endl;
delete x_original;
std::cout << "now x1=" << x1.i << std::endl;
return 0;
}
default!
now x1=100
destructor!
now x1=0
wandboxで、GCC 5.4 で試してみています。最適化オプションなしです。
どうやら、仮説2が正解のようですね。
最初のx1
の値から、この時点ではx_original
の実体を参照しているのか、他に確保された一次オブジェクトを参照しているのかわかりません。しかし、その後deleteによってx_original
を解放すると、参照先のx1
もdeleteされてしまっていることがわかります。この挙動は左辺値参照の場合と変わりません。
上記のコードは明らかに危険です。安全にmoveするためにはどうしたら良いでしょうか?
上のhoge&&
をhoge
にしてみると
int main() {
hoge *x_original = new hoge();
hoge x1 = std::move(*x_original);
std::cout << "now x1=" << x1.i << std::endl;
delete x_original;
std::cout << "now x1=" << x1.i << std::endl;
return 0;
}
default!
move!
now x1=101
destructor!
now x1=101
destructor!
と、moveコンストラクタが働き実体化するので、x_original
がdeleteされてもmove先は影響を受けません(当たり前ですが)。これは正しいmoveで、安全なコードになります。
次に、右辺値参照変数に変更を行ってみましょう。
int main() {
hoge *x_original = new hoge();
hoge&& x1 = std::move(*x_original);
std::cout << "now x1=" << x1.i << std::endl;
x1.i = 200;
std::cout << "now x1=" << x1.i << std::endl;
std::cout << "now x_original=" << x_original->i << std::endl;
return 0;
}
default!
now x1=100
now x1=200
now x_original=200
これも左辺値参照と同じで、元のオブジェクトに対しても変更が反映されています。
このことからも仮説2が正しいようですね。
最後に、コンテナの要素に対して削除してみた場合も試してみます。
int main() {
//このコードは解放済みの領域への参照をしているのでかなり危険です!コピペしないでね。
hoge x_original = hoge();
std::queue<hoge> q;
q.push(std::move(x_original));
hoge&& x1 = std::move(q.front());
std::cout << "now x1=" << x1.i << std::endl;
q.pop();
std::cout << "now x1=" << x1.i << std::endl;
return 0;
}
default!
move!
now x1=101
destructor!
now x1=0
destructor!
上記と同じでpop()によって消去されてしまっており、解放済みのオブジェクトにアクセスしていることがわかります。一見するとコピー回数を減らした良さそうなコードに見えますし、コンテナに入っているとうっかりやらかしそうです。
まとめ
以上から、std::move
の戻り値を受けた右辺値参照変数は左辺値参照変数のように振る舞うということがわかりました。これは、仕様的には、std::move
の戻り値の代入は所有権の移動が完了していない状態で、所有権は依然move元のオブジェクト(左辺値)にあり、左辺値参照と同じ挙動になるということでしょうか。
std::move
を右辺値参照変数で受け取るのは危険性が高い割にメリットの薄いように思います。
関数の引数として右辺値参照変数をとる場合と戻り値に右辺値参照変数をとる場合のハマりどころについて、今後まとめたいです。