シンプルな配列クラスを使って「右辺値参照」と「ムーブセマンティクス」を知る

More than 5 years have passed since last update.

C++11 の新しい機能に「右辺値参照」と「ムーブセマンティクス」があります。

ググればこれらに関して解説されてるサイトが結構ありますが、一般的には理解しづらいものとして扱われてるみたいです。

かくいう自分もよくわかりませんでした……。

ですので、今回は至ってシンプルな配列クラスを使って、これらが何を意味するのかを知ろうかと思います。


従来は「コピー」

まずは、「右辺値参照」と「ムーブセマンティクス」を使わないコードを見てみます。


test1.cpp

#include <iostream>


using namespace std;

struct Array
{
typedef int ValueType;

ValueType *p;
int length;

// コンストラクタ
Array(int _length)
: p(new ValueType[_length])
, length(_length)
{
memset(p, 0, sizeof(ValueType) * length);
p[0] = 123;
cout << "constructor(" << this << ")" << endl;
}

// コピーコンストラクタ
Array(const Array & o)
: p(new ValueType[o.length])
, length(o.length)
{
memcpy(p, o.p, sizeof(ValueType) * length);
cout << "copy constructor(" << this << ")" << " <- " << &o << endl;
}

// デストラクタ
~Array() {
if (p != nullptr) {
delete p, p = nullptr;
length = 0;
}
cout << "destructor(" << this << ")" << endl;
}

// 代入演算子(コピー)
Array & operator = (Array & o)
{
length = o.length;
memcpy(p, o.p, sizeof(ValueType) * length);
cout << "copy(" << this << ")" << " <- " << &o << endl;
return (*this);
}
};

// Array を受け取り、それをそのまま返すだけ
Array Func(Array array) {
return array;
}

int main()
{
Array array(32);
array = Func(array);

return 0;
}


見ての通り、Func に配列オブジェクトを渡して、それをそのまま受け取るだけの処理です。

処理の流れがわかるようにログを出力するようにしてあります。

これの実行結果は以下のようになります。


output

constructor(0015F9BC)

copy constructor(0015F8AC) <- 0015F9BC
copy constructor(0015F8E0) <- 0015F8AC
destructor(0015F8AC)
copy(0015F9BC) <- 0015F8E0
destructor(0015F8E0)
destructor(0015F9BC)

何やら色々実行されてますけど、一番最初の constructor と 一番最後の destructor 以外は

    array = Func(array);

この一行を実行するためだけに行われている処理となります。

わかりやすく日本語で書くと、以下のようになります。


  1. Func の引数に array をコピー。

  2. Func の戻り値に Func の引数をコピー。

  3. Func の引数を破棄。

  4. Func の戻り値を array にコピー。

  5. Func の戻り値を破棄。

見ての通り、コピー処理がふんだんに呼ばれてます。

でもこの処理、実はコピーする必要なんて全然無いですよね?

単に渡された配列をそのまま返してるだけですから、この程度だったらポインタでも十分です。

ポインタであればアドレスをコピーするだけなので、配列の中身をコピーする場合に比べると当然速くなります。


「コピー」ではなく「ムーブ」

では、これを「右辺値参照」と「ムーブセマンティクス」をつかったコードに置き換えてみます。


test2.cpp

#include <iostream>


using namespace std;

struct Array
{
typedef int ValueType;

ValueType *p;
int length;

// コンストラクタ
Array(int _length)
: p(new ValueType[_length])
, length(_length)
{
memset(p, 0, sizeof(ValueType) * length);
p[0] = 123;

cout << "constructor(" << this << ")" << endl;
}

// コピーコンストラクタ
Array(const Array & o)
: p(new ValueType[o.length])
, length(o.length)
{
memcpy(p, o.p, sizeof(ValueType) * length);
cout << "copy constructor(" << this << ")" << " <- " << &o << endl;
}

// ムーブコンストラクタ
Array(Array && o)
: p(o.p)
, length(o.length)
{
o.p = nullptr;
o.length = 0;

cout << "move constructor(" << this << ")" << " <- " << &o << endl;
}

// デストラクタ
~Array() {
if (p != nullptr) {
delete p, p = nullptr;
length = 0;
}
cout << "destructor(" << this << ")" << endl;
}

// 代入演算子(コピー)
Array & operator = (Array & o)
{
length = o.length;
memcpy(p, o.p, sizeof(ValueType) * length);
cout << "copy(" << this << ")" << " <- " << &o << endl;
return (*this);
}

// 代入演算子(ムーブ)
Array & operator = (Array && o)
{
if (this != &o) {
p = o.p;
length = o.length;

o.p = nullptr;
o.length = 0;

cout << "move(" << this << ")" << " <- " << &o << endl;
}
return (*this);
}
};

// Array を受け取り、それをそのまま返すだけ
Array Func(Array array) {
return array;
}

int main()
{
Array array(32);
array = Func(std::move(array));

return 0;
}


「ムーブコンストラクタ」や「代入演算子(ムーブ)」が増えていたりしますが、この辺りの説明は後ほど。

とりあえず、実行結果を見てみましょう。


output

constructor(004DFE48)

move constructor(004DFD38) <- 004DFE48
move constructor(004DFD6C) <- 004DFD38
destructor(004DFD38)
move(004DFE48) <- 004DFD6C
destructor(004DFD6C)
destructor(004DFE48)

ログを見る限りでは「copy」がすべて「move」に変わっていますね。

これが何を意味するのか?これからそれを説明します。


「右辺値参照」と「ムーブコンストラクタ」

「右辺値参照」と「ムーブセマンティクス」を使ったコードを見ると、

    array = Func(array);

    array = Func(std::move(array));

になっていることがわかります。

この std::move は、渡されたオブジェクトの「右辺値参照」を返します。

では「右辺値参照」とは一体何なのか?というお話は、今してもややこしくなるのでひとまず置いておくとして、

まずは Func の引数に「右辺値参照」が渡されると、どういう挙動になるのか?を見てみましょう。

先程と同じく、一番最初の constructor と一番最後の destructor 以外は

    array = Func(std::move(array));

を実行したときのログです。

このログの

move constructor(004DFD38) <- 004DFE48

これが Func の引数に「右辺値参照」を渡した時の処理となります。

このログから分かる通り、Func の引数に「右辺値参照」を渡すと、引数の「ムーブコンストラクタ」が呼び出されます。

「ムーブコンストラクタ」 とは、コード中の

    // ムーブコンストラクタ

Array(Array && o)
: p(o.p)
, length(o.length)
{
o.p = nullptr;
o.length = 0;

cout << "move constructor(" << this << ")" << " <- " << &o << endl;
}

これのことです。

引数に「&&」を取るような形で書かれていますが、この「&&」が「右辺値参照」であることを示しています。

では、この「ムーブコンストラクタ」とは何か?

それは、 渡されたオブジェクトの所有権を新しいオブジェクトに移動させるためのものです。

「コピーコンストラクタ」は渡されたオブジェクトのコピーを作成しますが、

「ムーブコンストラクタ」は上のコードのように新しいオブジェクトに情報を移して、古いオブジェクトはクリアします。

「ん?こんなことして大丈夫なの?」と思われるかもしれませんが、array 自体は Func の引数に所有権を移動した後、

戻り値で所有権を取り戻すまで参照されませんので、特に問題はありません。

むしろ「配列のコピー」が「ポインタのコピー」に置き換わるため、パフォーマンスは上がります。


「右辺値参照」と「代入演算子(ムーブ)」

さて、「ムーブコンストラクタ」のお話が終わった所で改めて

    array = Func(std::move(array));

のログを見てみましょう。

先ほどお話した「ムーブコンストラクタ」以外にも

move(004DFE48) <- 004DFD6C

こんなのがあります。

これも「ムーブコンストラクタ」と同じく、オブジェクトの所有権を移動する処理です。

これがどこで行われているのかというと、

    // 代入演算子(ムーブ)

Array & operator = (Array && o)
{
if (this != &o) {
p = o.p;
length = o.length;

o.p = nullptr;
o.length = 0;

cout << "move(" << this << ")" << " <- " << &o << endl;
}
return (*this);
}

ここです。

これは Func の戻り値を array に代入する際に実行されていますが、

「ムーブコンストラクタ」の時と違い std::move のように明示的に「右辺値参照」は指定していません。

では、何故コピーではなくこちらのムーブ呼ばれるのか?

関数の戻り値は右辺値に分類されるため、std::move を使わわなくても「右辺値参照」が引数になっている代入演算子を実行するコードを生成しているわけです。


「ムーブコンストラクタ」や「代入演算子(ムーブ)」は絶対に必要?

これらのお話から、「じゃあ、「ムーブコンストラクタ」や「代入演算子(ムーブ)」は絶対に必要なの?」

と思われるかもしれませんが、これらは絶対に必要というわけではありません。

「ムーブコンストラクタ」や「代入演算子(ムーブ)」がなかった時に「右辺値参照」を渡して呼び出されたとしても

それらは「コピーコンストラクタ」や「代入演算子(コピー)」で置き換えられるようになっています。

ですので、もしなければ従来通り、コピーとして処理されます。

もし気になるようであれば、上のコードの「ムーブコンストラクタ」や「代入演算子(ムーブ)」をコメントアウトして実行してみてください。


「右辺値参照」と「ムーブセマンティクス」って?

長くなりましたが、


  • ムーブコンストラクタ

  • 代入演算子(ムーブ)

これらに対応した結果、処理の内容は以下のようになりました。


  1. array の所有権を Func の引数に移動。

  2. Func の引数の所有権を Func の戻り値に移動。

  3. Func の引数を破棄。

  4. Func の戻り値の所有権を array に移動。

  5. Func の戻り値を破棄。

元は 「実はコピーする必要なんて全然無いけど、コピーだらけなコード」 が、

その呼び出し元のコードをほとんどいじること無く、見事に

「実はコピーする必要なんて全然無いし、実際にコピーしないコード」 となりました。

このように、

「オブジェクトの不要なコピーを抑えて、なるべく所有権(ポインタ)の移動だけで済ませよう」

という試みが 「ムーブセマンティクス」 です。

そして、その「ムーブセマンティクス」をコンパイラレベルでお手軽に実現するために必要な物こそが 「右辺値参照」 となります。

「ムーブセマンティクス」自体はポインタや参照でも実現できますが、ポインタはバグの温床になりやすいですし、

なによりも「見やすい」「使いやすい」を保ったまま実現できるところが大きいです。

ちなみに、C++11 の標準ライブラリのクラス群は、基本的には「右辺値参照」を使った「ムーブセマンティクス」に対応しているそうです。


最後に

今回はネットであれこれ調べながら検証したものです。

主に以下のサイトを参考にさせて頂きました。

また、間違いのご指摘や補足は大歓迎です!