概要
どのサイトを見ても、C++の「右辺値参照」がわからないので、
何番煎じかわかりませんが、自分で書くことにします。
※筆者も初学者のため、正確性に欠けるところがあると思います。
修正いたしますので、ぜひお教えください。皆様のご指摘に感謝します。
要するに
無駄なコピーを行わなくすることで、パフォーマンスを上げるために存在する機能。
この記事で解説すること
- 右辺値、左辺値ってそもそも何なの
- 右辺値参照(&&)っていつ使うの
- std::move()って要するに何
先人の記事
一番わかりやすかった記事はこちら↓です。
上記サイトより引用します。
右辺値は名前が悪い。 一時オブジェクト あるいはアドレスを持たないものと考えると良い
std::moveは名前が悪い。移動ではなく右辺値へのキャスト演算子である
関数の仮引数で右辺値参照(hoge&&) で受けても左辺値(hoge&)であるので右辺値にするには std::move()でキャストする
ほとんどこれに集約されます。
では、具体例を交えて解説していきます。
解説
右辺値、左辺値ってそもそも何なの
この記事にたどり着く前に何度もお調べかと思いますが、こちらでも解説します。
まず大前提として、「右辺値」「左辺値」は名前が悪いです。
もう少しいい名前を付けてあげればよかったのに。
「右辺値」「左辺値」などと称されるものは、「値」ではなく、
より正確に言えば表現とするのが正しいです。
さらに正確に言うと、この「表現(expression)」は、
"lvalue"と"prvalue"、そしてその中間の"xvalue"に分かれていて、
"lvalue"と"xvalue"をまとめて"glvalue"と呼び、
"prvalue"と"xvalue"を包含した概念が"rvalue"で……
……となりますが、ここを深追いすると初学者には厳しくなってきますので、
一度この記事では差し置きます。
「右辺値参照」を理解するためには、
いったん「左辺値(lvalue)」と「右辺値(rvalue)」が存在する、ということのみ
把握しておけば大丈夫です。
そして、何度も言いますが、これらは名前が悪いです。
というわけで、本記事では右辺値を「右辺値(保存されない値)」と表記します。
また左辺値を「左辺値(保存された値)」と表記します。
細かくは後でわかってくると思いますが、まずこの前提で進めていきますのでご了承ください。
右辺値、左辺値とは
例えば、簡単な以下のプログラムを考えましょう。
int x = (3 + 2) * 10; // ...(1)
std::cout << x << std::endl; // ...(2)
(3 + 2) * 10を計算して表示するプログラムです。
実行すれば、50と表示されますね。
ここで、(1)の行について考えましょう。
(1)では、xという変数に、計算結果の値(ここでは50)が代入されることになります。
この値は、メモリー上に保存されているので、(スコープ内であれば)以降もxという名前で使えます。
実際に、(2)の行で参照されて、50と表示されますね。
つまり、xはint型の「左辺値(保存された値)」です。
一方で、(1)の行の2や10はプログラム上で二度と参照されることはありません。
例えば、(3 + 2)の答えである5は、メモリーに保存されないわけです。
つまり、これらは「右辺値(保存されない値)」です。
別の例を考えましょう。
int add(int a, int b){
return a + b;
}
int mul(int a, int b){
return a * b;
}
int main(){
int x = 3;
int y = 4;
std::cout << add(x, y) << std::endl;
std::cout << mul(x, y) << std::endl;
}
先ほどと同様に、xとyは左辺値(保存された値)ですが、
一方でadd(x, y)(値としては7)はどうでしょうか。
この7という値は、以降の行でもう一度使いたいとなったとしても、
(変数に代入されていないので)使うことができません。
もう一度計算するしかないですね。
つまり、add(x, y)は右辺値(保存されない値)です。
大まかにわかってきたでしょうか。では次へ行きます。
右辺値は、通常の変数に代入することで左辺値にできる
以下の例を考えましょう。
int add(int a, int b){
return a + b;
}
int main(){
std::cout << add(3, 4) << std::endl; // ...(1)
int z = add(5, 6); // ...(2)
std::cout << z << std::endl; // ...(3)
}
(1)は右辺値(保存されない値)であるadd(3, 4)をそのまま表示します。(値は7)
この値は二度と使用されないので、すぐ消えてしまいます。
一方で、(2)の行では、右辺値(保存されない値)であるadd(5, 6)を変数zに代入しています。
そのおかげで、左辺値(保存された値)となり、(3)の行で再度使用できています。
右辺値(保存されない値)は、(通常の)変数に代入することで左辺値(保存された値)にできるわけですね。
当たり前のことを言っているだけでした。しかし、重要なことです。
右辺値は、参照型の変数には代入できない
以下の例を考えましょう。
int add(int a, int b){
return a + b;
}
int main(){
int x = add(2, 3); // ...(1)
int& y = x; // ...(2)
std::cout << y << std::endl; // ...(3)
}
(2)では、参照型の変数yに、左辺値(保存された値)のxを代入しています。
当然コンパイルは通り、値5が表示されます。
一方で、以下の例はどうでしょう。
int add(int a, int b){
return a + b;
}
int main(){
int& y = add(2, 3); // ...(1)
std::cout << y << std::endl; // ...(2)
}
この例は、コンパイルエラーになります。
アドレスが割り当てられていない右辺値(保存されない値)を、
参照型の変数yに代入しようとしたために、エラーになるわけです。
つまり、右辺値(保存されない値)は参照型の変数に代入できないのです。
ここまでわかれば、右辺値と左辺値は理解できたと言えるでしょう。
右辺値参照(&&)っていつ使うの
右辺値を変数に代入する際、コピーが発生する
以下の例を考えます。
void triple(std::vector<int>& vec){
for(int& elem : vec){
elem *= 3;
}
}
std::vector<int> same_vec(std::vector<int>& vec){ // ...(1)
return vec;
}
int main(){
std::vector<int> x = {1, 2, 3, 4, 5};
std::vector<int> y = same_vec(x); // ...(2)
triple(x);
for(int elem : y) {
std::cout << elem << ", ";
}
std::cout << std::endl;
}
何と表示されるでしょうか。
答えは、1, 2, 3, 4, 5, です。
あれ、(1)で参照を渡しているはずでは……と思った方は、勘が鋭いですね。
実は、(2)の時点で、変数のコピーが発生しています。
そのため、xとyの指すものは異なってくるわけです。
右辺値参照で無駄なコピーを防ぐ
以下の例を考えます。
void triple(std::vector<int>& vec){
for(int& elem : vec){
elem *= 3;
}
}
std::vector<int> get_vec(int n){
std::vector<int> vec = {n, n, n, n, n};
triple(vec);
return vec;
}
int main(){
std::vector<int> z = get_vec(2); // ...(1)
for(int elem : z) {
std::cout << elem << ", ";
}
std::cout << std::endl;
}
6, 6, 6, 6, 6が表示されます。
先ほどの例を考えると、(1)で、不必要なコピーが発生していることがお判りでしょうか。
この程度の大きさであれば問題ないですが、
これがもし巨大な構造体だった場合、コピーコストがかかります。リソースが無駄ですね。
そこで、右辺値参照(&&)が役に立ちます。
(1)の行を、以下のように書きかえましょう。
void triple(std::vector<int>& vec){
for(int& elem : vec){
elem *= 3;
}
}
std::vector<int> get_vec(int n){
std::vector<int> vec = {n, n, n, n, n};
triple(vec);
return vec;
}
int main(){
std::vector<int>&& z = get_vec(2); // ...(1)
for(int elem : z) {
std::cout << elem << ", ";
}
std::cout << std::endl;
}
結果は全く同じです。しかし、(1)で右辺値参照(&&)を行っています。
これで、get_vec(2)で得た右辺値(保存されない値)を、
コピーせず、受け取ることができるのです。
これで、リソースが軽くなるというわけです!
std::move()って要するに何
次は、ムーブ(std::move)について解説します。
ムーブとは、右辺値(保存されない値)へのキャストと考えてよいです。
例を見ましょう。
ムーブの例
std::vector<int> x = {1, 2, 3};
std::vector<int> y = std::move(x); // ...(1)
(1)で、ムーブが行われています。
これは、「xを右辺値(保存されない値)にキャスト」、
つまり xはもう使わないことを表します。
この行以降のxの挙動は不定です。xは使わないようにしましょう。
しかし、なぜこのような機能があるのでしょうか。
ムーブの役割
ムーブは直訳すると「移動」ですが、これも若干名前が悪いですね。
その実態は、変数への代入後、コピー元を右辺値(保存されない値)にするという動作です。
代入 & コピー元の右辺値(保存されない値)へのキャストですね。
ムーブが威力を発揮するのは、以下のような場合です。
std::vector<int> append(std::vector<int>& vec){ // ...(A)
vec.push_back(10);
vec.push_back(20);
vec.push_back(30);
std::cout << "copy" << std::endl;
return vec;
}
std::vector<int> append(std::vector<int>&& vec){ // ...(B)
vec.push_back(10);
vec.push_back(20);
vec.push_back(30);
std::cout << "move" << std::endl;
return std::move(vec); // ...(★)
}
int main(){
std::vector<int> tmp = std::vector<int>{1, 2, 3};
std::vector<int> x = append(tmp); // ...(1)
for(int elem: x){
std::cout << elem << ", ";
}
std::cout << std::endl;
std::vector<int> y = append(std::vector<int>{1, 2, 3}); // ...(2)
for(int elem: y){
std::cout << elem << ", ";
}
std::cout << std::endl;
}
実行結果は以下のようになります。
copy
1, 2, 3, 10, 20, 30,
move
1, 2, 3, 10, 20, 30,
関数append()が2つありますが、引数の型が、ただの参照と右辺値参照で違います。
つまり、(A)と(B)の2つが両方存在している、オーバーロードの状態です。
(1)は、左辺値(保存された値)tmpでappend()を呼び出しているので、(A)のほうが呼ばれます。そのため、"copy"と表示されます。
(逆に、(B)のみしか定義されていなかったなら、エラーになります。左辺値は右辺値参照型に代入できないからです。)
(2)は、右辺値(保存されない値)std::vector<int>{1, 2, 3}でappend()を呼び出しているので、(B)のほうが呼ばれます。そのため、"move"と表示されます。
(逆に、(A)のみしか定義されていなかったなら、エラーになります。上記にあるように、参照型に右辺値は代入できません。)
ここで大事なのは(★)の行です。
std::move()を利用することで、
関数(B)内のvecが右辺値(保存されない値)として返されます。
すなわち、(2)の行で無駄なコピーが発生しなくなるのです。
ムーブの利用価値がわかってきたでしょうか。
上記の例で、右辺値参照を強制させたい場合、
すなわち(A)の関数を消去する場合は、以下のようになります。
std::vector<int> append(std::vector<int>&& vec){ // ...(B)
vec.push_back(10);
vec.push_back(20);
vec.push_back(30);
return std::move(vec);
}
int main(){
std::vector<int> tmp = std::vector<int>{1, 2, 3};
std::vector<int> x = append(std::move(tmp)); // ...(1)
for(int elem: x){
std::cout << elem << ", ";
}
std::cout << std::endl;
}
(1)は、append(tmp)とはできません。右辺値参照の関数(B)しか定義されていないので、
左辺値(保存された値)であるtmpはそのまま引数として渡せないからですね。
そこで、ムーブを使って、
tmpを右辺値(保存されない値)に、いわば「キャスト」して、
引数として渡すわけです。
(1)の行以降は、tmpは使用できません。(動作未定義です。)
tmpは右辺値(保存されない値)になってしまったから(ムーブしたから)ですね。
もしプログラム上でtmpを別で再度使用する機会があるようでしたら、
std::copy()でディープコピーし、別の変数に代入しておくなどするとよいでしょう。
まとめ
- 右辺値とは
- 保存されない値。メモリ上どこにも保持されない値。
- 左辺値はその逆。保存された値。メモリ上に保持されている値。
- 右辺値参照(&&)とは
- 右辺値を、右辺値として受け取れる型。
- リソース的に無駄なコピーを防ぐことができる。 - ムーブ(std::move())とは
- 変数へ値を代入したのち、代入した左辺値を右辺値に変更することができる。
- ムーブされた変数は、以降は使用できない。
最後に
この記事が、誰かの役に立つことを願います。
C++の勉強、頑張ってください。
また、皆様、誤りのご指摘ありがとうございます。私も勉強になります。
誤りや改善点等がありましたら、ぜひコメント等でお教えください。随時修正いたします。