💭

C++のコピー・ムーブ・参照・右辺値参照を私なりにまとめてみました

2022/05/02に公開

※書きかけです、少しずつ追記・修正します。

C++のデータ型は値のセマンティクスらしいです。
つまり 変数 = 変数をすると、C++では中の値もすべてコピーされる...というのがデフォルトの動作でありC++の各種クラスやデータ型(st::stringやstd::vector、std::array)も標準でそうなるようになっているらしいです。このような動作や処理 のことを セマンティクス と言い、とりわけコピーされる動作を

コピーセマンティクス (値のセマンティクス)

と言います。
PCで言うと、ファイルやフォルダのコピーでしょうか?

一方、他の言語(例えばPythonなど)では、デフォルトでは値そのものはコピーされずに、値への参照のみコピーされる ことが多いです。
これを

参照のセマンティクス

と言います。
イメージ的にはファイルやフォルダへのショートカットやシンボリックリンクの作成ですね!
Pythonではコピーセマンティクスはできないの?というとそうではなく、Pythonの世界でもディープコピーと言いdeepcopy()といった関数を使うことで可能です。
C++でもint& a = b; のようにアンパサンドという特殊な書き方で参照のセマンティクスが可能です。

また、C++では、コピーセマンティクスや参照のセマンティクスのほかにも

ムーブセマンティクス

があります。
イメージ的には、ファイルやフォルダの切り取り→貼り付け(移動)です。[1]
C++ではstd::string a = std::move(b); という風に書くことでムーブします。
ただし、vectorやstringが内部のフィールドで保持しているポインターの参照先の値をすべて逐一別メモリ上に移動させるとコストがかかりそうですよね?
なので、実際は各クラスは、値を参照しているポインターのみ移動させる処理になっています。
むずかしい...。[2]

C++では他にも

ポインター

が使えたり、ムーブや参照の亜種(?)として

右辺値参照
const参照

などがあります。
(ポインターを参照のセマンティクスに含めるべきかどうかは微妙なところではあります...が本記事では深堀しません。英語ではポインター渡しも参照渡しもどちらもreferenceなんですよね...)

このようにC++は色々なセマンティクスを使い分けて細かく処理を記述することができるようになっています。
しかし、使い分けられるところがC++の良さである一方で、それがC++の難しさになっていて、どういう書き方(シンタックス)をすると、どういうセマンティクス(動作)になるのか対応がよくわからない!という人は多いんじゃないでしょうか。私がそうです。その動作の早見表を作ったのがこの記事の本趣旨です。

例えば、以下のようにした場合どうなるのでしょうか...?

C++
int b = 10;
int&& a = b; //無理 右辺値参照は右辺値しか受け取れない

この場合、aは右辺値参照型なので右辺値しか受け取れないのです。
エラーなのです。

早見表でいうと、 左辺 が「右辺値参照」、右辺 が「通常」の行を見るとエラーになっていることが分かります。


本記事での用語の確認

記事の都合上、特殊な用語ではないですが一応確認しておきます。

= の左側にある受け取る側の変数 → 左辺
= の右側にある渡す側の変数や値 → 右辺

と言うことにします。
C++には=を使わずに{}や()でも初期化できますが、基本ルールは同じです。
受け取る側を左辺、渡す側を右辺とします(int a{b} ならaは左辺bは右辺)
本記事ではわかりやすさ優先で=で統一します。

関数の引数でも大体同じです。

void f(int a);
f(b); // bはどこかで定義されている。

関数fの仮引数 int a は受け取る側なので 左辺
実引数である bは 渡す側なので 右辺
です。

また、本文中や本記事にある表では概ね
普通の変数(または通常)→ int a;
参照変数(または参照) → int& a;
右辺値参照       → int&& a;
const参照        → const int& a;
という意味で使っています。

普通の変数と右辺値参照の違い

普通の変数も右辺値参照もどちらも右辺値を受け取れるけど、何が違うのでしょう?
(私が初めて右辺値参照を勉強したときに感じた疑問です)

次の(A)と(B)は左辺の型が異なります。

C++
//aは普通の変数
int b = 10;
int a = std::move(b);  // ・・・(A)

//yは右辺値参照
int x = 10;
int&& y = std::move(x);// ・・・(B)

//↑は両方ともエラーにならない。

普通の変数(A)も右辺値で初期化できる(余談ですがこれを束縛と言う)
右辺値参照(B)もエラーになりません。

(A)と(B)は何が違うのでしょうか?

(A)はムーブ で、
(B)はムーブは起きていません

私も勘違いしていたのですが、左辺側が右辺値参照の場合はムーブが起きない んです。
あくまでも、右辺値の"参照" なのであって、値のムーブ(移動)はされていない と解釈すると良いと思います。

どちらもint型ですが、これがユーザー定義型であれば(A)はムーブコンストラクタが呼ばれます。
一方、(B)はムーブコンストラクタが呼ばれません。

これは初期化時だけではなく、代入でも同じです。

C++
//aは普通の変数
int b = 10;
int a;
a = std::move(b); //ムーブ代入が起きる

//右辺値参照はそもそも再代入できない。
//int x = 10;
//int&& y;   //エラー 右辺値参照(普通の左辺値参照も)は定義時に右辺値で初期化する必要がある
//y = std::move(x);

初期化時ではなく、一度定義したものに代入(再代入)する場合は各種コンストラクタ(通常・コピー・ムーブ)ではなく各種代入演算子(コピー・ムーブ)が呼ばれます。

右辺値参照や参照(左辺値参照)は再代入できません。

動作早見表

左辺 右辺 動作 初期化時に呼び出されるコンストラクタ(代入時に呼び出される演算子) 備考
通常 通常 コピー コピーコンストラクタ(コピー代入演算子)
通常 右辺値 ムーブ ムーブコンストラクタ(ムーブ代入演算子)
通常 参照 コピー コピーコンストラクタ(コピー代入演算子)
通常 右辺値参照 コピー コピーコンストラクタ(コピー代入演算子)
参照 通常 参照する 何も呼び出されない 参照の再代入不可(以下参照すべて不可)
参照 右辺値 エラー
参照 参照 参照する(代入時はコピー) 何も呼び出されない(コピー代入演算子) 参照変数の初期化時のみ参照。代入時は普通の変数同士同様にコピーになる。ややこしい
参照 右辺値参照 エラー
const参照 通常 参照する 何も呼び出されない 再代入不可(以下const参照すべて不可)
const参照 右辺値 [3]右辺値参照?参照? 何も呼び出されない 右辺値(一時オブジェクト)の寿命はconst参照変数分延長される
const参照 参照 参照する 何も呼び出されない
右辺値参照 通常 エラー 再代入不可(以下右辺値参照すべて不可)
右辺値参照 右辺値 右辺値参照 何も呼び出されない 右辺値(一時オブジェクト)の寿命は右辺値参照変数分延長される
右辺値参照 参照 エラー
右辺値参照 右辺値参照 エラー

関数の戻り値の場合はまた特殊なんです。。。

基本は↑の変数の初期化、代入(関数の引数でも同じ)のルールと同じなのですが...。
※書きかけです。書く予定のメモ的なものだけ置いておきます。

int func(int& v) {
    return v; //return時に関数内でもコピー、ムーブ、参照、右辺値参照の概念がある。
}

int receive = func(val);  //ここで何が起こってるのか...? →コピーコンストラクタは一回


int&& a = func(val); //これはどうなるか?→可能(つまり関数の戻り値は右辺値扱い※1)

int a = func(); //関数の戻り値は右辺値のはず。法則からしてムーブコンストラクタが呼ばれるはず...→実はムーブコンストラクタは呼び出されない。これは特殊なのか


//C++の場合、最適化やRVOやNRVOなどがあってまたややこしい。
//※1ただし、参照を返す関数の場合はprvalueにはならない...うーんうーん。。
脚注
  1. ムーブの説明はあくまでイメージ優先です。 ↩︎

  2. Rustはムーブがデフォルト動作になっています。unique_ptrのように所有権のムーブという動作になってるんですよね(たぶん)。 ↩︎

  3. 参照と書くべきだと思うのですが、const参照は参照と違って右辺値も参照できるので...迷ってます。 ↩︎

Discussion