右辺値参照とムーブコンストラクタの使い方 - C++ プログラミング

PROGRAM


右辺値参照とムーブコンストラクタの使い方

右辺値参照

右辺値参照というのは、C++11 で利用可能になった、右辺の値を書き換え可能にする仕組みです。

この仕組みによって、右辺が持っていたポインタを左辺にそのまま移転することができるようになります。

 

代入演算子の特徴

たとえば次のような式があったとします。

左辺 = 右辺;

C++ ではお馴染みの代入演算子ですけど、通常は、左辺の値を右辺の値で置き換えるという動作をします。

 

従来からの C++ のクラスの代入演算子では、左辺のクラスが右辺の値を受け取って処理をするような定義になっています。

左辺の型& operator=(const 右辺の型&);

右辺の値は書き換えられないので、たとえば右辺の値を左辺が引き継いで使用するとき、右辺の値がポインタだったりする場合には、大抵はどんなときでも、右辺の値を新しい値としてまるごと複製しなければいけなくなります。

 

このとき、もしもそのまま右辺のポインタを左辺に設定してしまうと、左辺と右辺の両方で同じポインタを使ってしまうため、たとえば左辺と右辺のどちらかが解放されるときに、もう片方でも使用中のポインタが示すメモリを開放してしまう恐れがあります。

そのため、ポインタが指す先のメモリを丸ごとコピーして新しい値として設定する必要があるのですが、それにかかるコストが大きい場合に問題になる場合があります。

 

二つの値を入れ替えるときの問題

問題となるのは、たとえば 2 つの値を入れ替えるようなときです。

2 つの値を入れ替えるとき、多くの場合は次のように、一時領域を作成しつつ代入演算子を使って値を交換することになります。

void swap(CMyClass& a, CMyClass& b)

{

CMyClass temp = a;

a = b;

b = temp;

}

このように a と b とを入れ替えるには、a の値を temp にとっておいてから、a の値を b で上書きして、b の値を temp で(取っておいた a で)上書きします。

ここで重要なのが、値を交換する中で使った temp は交換が終われば必要なくなるというところと、a と b は交換はされるもののどちらの値もそのまま使い続けられる、の 2 点です。

 

この、単に交換されて使い続けられればいいだけの値のために、代入演算子を使って 3 回も、値がまるごと新たに複製されています。

この、値の複製にかかるコストが大きければ大きいほど、大きな影響となって現れてくる危険性が出てきます。

 

C++11 で導入された右辺値参照は、この問題を解決する可能性を提供してくれます。

 

右辺値参照の定義と扱い

まず、あるデータ型の右辺値参照を受ける左辺のデータ型を定義するとき、データ型に続けて "&&" を指定します。

CMyClass&& value;

こうすることで、右辺値参照を受けることが明示された変数を用意することができます。

上記の例では CMyClass というクラス型ですが、int 型などでも同様に "int&&" とすることで右辺値参照として受けることを明示できます。

 

右辺値参照を受けることが明示された変数には、std::move 関数を使うことで、引数に渡した変数を右辺値として代入できます。

CMyClass a;

CMyClass&& value = std::move(a);

ただ、このような代入には意味がなくて、大きな意味を持ってくるのは、関数の引数や演算子の引数として右辺値参照の型を指定した時です。

 

通常参照と右辺値参照とは区別できる

CMyClass に次のような代入演算子が定義されているとします。

CMyClass& operator=(const CMyClass& value);

CMyClass& operator=(CMyClass& value);

どちらとも CMyClass のインスタンス参照を引数に取る代入演算子の定義ですけど、引数で通常の参照 (&) か右辺値参照 (&&) かを明示することで、引数の参照方法に応じて呼ばれる代入演算子を自動で切り替えることができます。

 

たとえば CMyClass 型の変数 a をそのまま代入演算子の右辺として指定した場合は、左辺 b の通常参照用の代入演算子 "CMyClass& operator=(const CMyClass& value)" が呼び出されます。

b = a;

そして、右辺値を std::move 関数を通して指定した場合は、左辺 b の右辺値参照用の代入演算子 "CMyClass& operator=(CMyClass& value)" が呼び出されることになります。

b = std::move(a);

ここまでで重要なポイントは、普通の参照と右辺値参照とは、どちらを扱うべきかを引数の型を使って区別できるというところにあります。

 

右辺値参照を扱うときの留意点

さて、そんな右辺値参照の使い方に入る前に、右辺値参照の扱いで見られる留意点を少し整理しておきたいと思います。

 

引数で受け取った右辺値参照の変数は、使用する際にはそのままだと通常の参照として扱われます。

そのため、引数で受け取った右辺値参照を、その中で右辺値参照として使いたい場合は、改めて std::move 関数を通す必要があります。

void method(CMyClass&& value)

{

// 同じ右辺値参照型へ代入する場合も、改めて std::move が必要です。

CMyClass&& temp = std::move(value);

 

// 右辺値参照型を引数に取る関数に渡す場合も、改めて std::move が必要です

this->method(std::move(value));

 

// 右辺値参照型を右辺に取る代入演算子を呼び出したい場合も、改めて std::move が必要です。

*this = std::move(value);

}

特に、右辺値参照型と普通の参照型のそれぞれを取る関数や演算子がオーバーロードで実装されていた場合、引数で右辺値参照で受けたからと安心してそのまま使ってしまうと、通常の参照型の方の関数や演算子が呼び出されるので注意が必要です。

 

右辺値参照が期待する実装

さて、ここまでの実装により、通常参照か右辺値参照かを区別して処理できるようになりました。

これを受けて、右辺値参照として値が渡されてきた場合には、それに期待されている実装をしてあげるようにします。

 

ここで、右辺値参照が期待する実装というのは、基本的には次のものになると思って良さそうです。

左辺値に右辺値を代入して、それで右辺値の役目は終わって不要になる。

つまり、右辺値として渡されてきた時点で、その値はその後どこかで使うつもりはないと明言しているということです。逆に言うと、右辺値として渡した時点で、その変数はもう処分するだけということになります。

ただし、右辺値に渡された変数は渡した側に処分される、ここだけは、渡す側も渡される側も忘れてはいけないポイントです。

 

こういうことが期待されているとなれば、右辺値参照を受け取った側は、最後に処分される時にさえ支障がないようにすれば、値を自由に壊してしまっていいということになります。

 

右辺値参照の約束があって初めて出来る権限委譲

こうした取り決めによって出来るようになるのが、オブジェクトの委譲(メモリ管理権限の委譲)です。

先ほど挙げた、2 つの値を交換するプログラムをもう一度見てみます。

void swap(CMyClass& a, CMyClass& b)

{

CMyClass temp = a;

a = b;

b = temp;

}

このとき、CMyClass が内部で扱うメモリが膨大だったとすると、値の交換だけなのに多大な処理コストがかかってしまいます。

ここでやりたいことは値の交換なので、なにもメモリを複製して受け取らなくても、a の値を b に委譲して b の値を a に委譲すればそれで実現できるはずです。

 

ここで代入文の値の動きに着目すると、

  1. 最初に temp に代入した a の値は、もう使わなくなります。(変数 a の器は使います)
  2. 次に、a に代入した b の値は、もう使わなくなります。(変数 b の器は使います)
  3. そして、先ほど a の値を保存しておいた temp の値は、b に代入したらもう使わなくなります。

つまり 3 箇所で、代入した後に代入元の値は要らなくなることになります。

このとき、代入した値がもう要らなくなるかはプログラマだけが知っていることで、コンパイラにはそれを勝手に判断することはできません。そこで、右辺値参照という存在が価値を持ってきます。

プログラマが右辺の値を右辺値参照と明示することで、それは左辺に代入されて役目を終えることが判り、そしてその時にはその時用の関数をプログラマが用意して、コンパイラにそれを実行してもらうことが可能になります。

 

それを踏まえて、まずは先ほどの値の交換を行うプログラムの中で、右辺値参照を明示してみます。

void swap(CMyClass& a, CMyClass& b)

{

CMyClass temp = std::move(a);

a = std::move(b);

b = std::move(temp);

}

関数が取る引数自体は、a と b とが交換された後の値を呼び出し元が使うことになるので、この関数に渡した後も使えることを明示するために通常の参照のままにしておきます。

 

そして、交換で使用する一時領域 temp を定義して、そこに右辺値参照を明示して a を代入しています。

一時領域を右辺値参照ではなく通常の値として宣言しているのは、値をそこに退避する入れ物として使いたいためです。これを右辺値参照や通常参照の型で定義してしまうと、単に参照ポインタを保持する変数として用意されてしまいます。

定義した一時領域に右辺値参照を使って代入することで、右辺の a の値は temp に移したらもう使わないことが分ります。

同様に、次は a に右辺値参照の b を代入しているので、右辺の b の値は a に移した後はもう使わないことが分ります。変数の器 a 自体は使いまわしていますが、その中の値は先ほど temp に移した後は使わないまま、ここで上書きされて終わっています。

最後の b も同様で、引数に渡された a の値を保存しておいた temp を右辺値参照にして代入することで、変数 b の器は使うものの、移した後の b の値を使うことなく上書きしています。

 

こうして、普通の参照のときとは違う意味合いを、右辺値参照によって含めることができました。

こうなれば、期待されていることが違うことが分るので、後はその期待通りの実装をしてあげるだけです。

 

通常の代入演算子と、右辺値参照を使った代入演算子の実装

今回の例では代入演算子なので、それの実装について見て行きます。

まず、普通の参照を受け取る代入演算子に期待されているのは、右辺の値を左辺に代入するということだけですから、例えば CMyClass が description という char* 型の説明文を持っている場合は、たとえば次のような複製が必要になってきます。

CMyClass& operator=(const CMyClass& value)

{

delete [] m_description;

 

m_description = new char[strlen(value->m_description) + 1];

strcpy(m_description, value->m_description);

 

return *this;

}

このように複製することで、この後、片方の値を変更するともう片方も変更されてしまうとか、片方を破棄するともう片方の値が使えなくなることがないようにしています。

 

右辺値参照を受け取る代入演算子の場合は、その後、右辺値参照として渡した方の値は使われないことが保証されるので、わざわざコピーしないで丸ごと譲り受けてしまうことができます。

CMyClass& operator=(CMyClass&& value)

{

delete [] m_description;

 

m_description = value->m_description;

value->m_description = nullptr;

 

return *this;

}

このように、右辺値参照として渡された値のメモリ領域をそっくりそのまま、自分の値として設定してしまうことができます。

 

ただし、右辺値参照として渡された値はその後使われなくても、後始末だけは行われるので、そのときに右辺値といっしょにメモリ領域が解放されてしまわないようにするために、受け取った右辺値参照のポインタに直接 nullptr を代入しておきます。

ちなみにこうやって自由に編集できるように、右辺値参照は const を指定しないで受け取るのが普通です。

 

このように、右辺値参照では渡された値はもう使わないという約束のおかげで、引数で受け取った参照の値を自分の都合に合わせて編集できるのが、右辺値参照の最大のメリットです。

こうしてメモリコピーを行うことなく、右辺値の文字列をそっくりそのまま左辺値として代入することができました。

 

クラスの定義と一緒に使われる右辺値参照の代入は、ムーブコンストラクタ

ところで、先ほどの値を交換する関数の冒頭で、次のようなクラス型の宣言と併せて値を代入する行がありました。

CMyClass temp = std::move(a);

この書き方は、右辺が通常の参照であれば、代入演算子ではなくてコピーコンストラクタが実行される場面です。

同じように、右辺が右辺値参照だった場合には、代入演算子ではなくムーブコンストラクタが実行されるようになっています。

 

ムーブコンストラクタ

コピーコンストラクタやムーブコンストラクタというのは、クラス型の定義で初期値が指定されたときに呼び出されるコンストラクタです。

コピーコンストラクタは普通の参照を受け取るのに対して、ムーブコンストラクタは右辺値参照を受け取ります。受け取った参照を使って、それと同じ値を自分自身に再現するという働きはどちらも同じです。

もちろん右辺値参照で受け取った方は、その値はもう後では使われないことが保証されているという原則は同じです。

 

ムーブコンストラクタは、自分自身と同じ型を const の付かない右辺値参照として受け取る形で定義します。

CMyClass(CMyClass&& value);

このようにムーブコンストラクタを定義することで、右辺値参照を初期値として構築された時に、自動的にこのコンストラクタが呼び出されるようになります。

 

この実装に記す内容は、先ほどの右辺値参照を取る代入演算子の時と原則的に同じです。

CMyClass(CMyClass&& value)

{

m_description = value->m_description;

value->m_description = nullptr;

}

違いとしては、今回はコンストラクタなので、既に設定されている値を破棄する最初のコードが要らないことと、コンストラクタに戻り値はないので return が要らないところでしょうか。

もっとも、やることは代入演算子と同じで、基本的にセットで実装することになるので、それと矛盾しないようにするために共通のメンバ関数を作って使用するか、次のように代入演算子を使いまわしてもいいかもしれません。

CMyClass(CMyClass&& value)

{

*this = std::move(value);

}

このようにすることで、渡された値を効率よく使ってインスタンスを生成できるようになりました。

 

右辺値参照とムーブコンストラクタの使いどころ

この、右辺値参照の代入演算子とムーブコンストラクタが生きるのが、たとえば STL の動的配列 std::vector<T> や std::list<T> でクラス型の値を扱っている場合です。

通常、この配列の要素を追加するときには、その都度インスタンスがコピーコンストラクタを使って生成されます。

このとき std::vector<T> の場合は、要素を追加する度にそれまでに追加されていた全ての要素が新しいメモリ領域に再代入されるのですけど、このときにもこれまではコピーコンストラクタが呼び出されていました。

 

それが、扱うクラスにムーブコンストラクタを実装しておくことで、この再代入の時に限ってはムーブコンストラクタが呼び出されるようになります。

要素の数が多くなると、そのクラスが内部で扱っているデータを複製するコストが気になってきますが、ムーブコンストラクタを実装することで、そのコストを最低限にまで引き下げることができるようになります。

 

std::sort 関数で std::vector<T> を並び替えるときにも、ムーブコンストラクタが呼び出されるので、並び替えにかかるコストも軽減されます。

 

関数の戻り値は右辺値参照として扱われる

クラス型を戻り値にする関数があったとします。

そういった関数が返した値を後で使おうと思ったときには、戻り値をそのまま変数に代入することになると思います。

CMyClass result = function();

このとき、戻り値が変数に代入して確保された後は、それ以上、使われることはありません。

 

戻り値はそんな性質ため、関数が返した戻り値については、戻り値の型が右辺値参照になっていなくても、コンパイラが自動的に右辺値参照として扱ってくれるようになっています。

これによって、クラス型の戻り値を変数に確保するときには、委譲という低コストな複製が採用されるようになりました。

 

同様に、関数が return で返す変数も、それが関数内で定義されたクラスだった場合にはそれ以降使われることはないので、std::move 関数を使わなくても、コンパイラが自動的に右辺値参照として扱ってくれます。

CMyClass function()

{

CMyClass result;

 

// 参照やポインタではないクラス型は、自動的に右辺値参照として扱われます。

return result;

}

右辺値参照が無かった頃は、戻り値を変数に受け取るときにはコピーコンストラクタが呼ばれてコピーが行われていたので、この右辺値参照の登場によって無駄なデータの複製が省け、既存のコードの軽量化も期待できます。

 

関数内で定義されたクラスであっても、それを参照型の変数に保存してある場合には、これを return の戻り値にしても、通常の参照として扱われ、戻り値はコピーコンストラクタで複製されます。

そのため、参照で指定されている戻り値のクラスが return の後で使われないことが確実な場合は、std::move 関数を明示することで、戻り値をムーブコンストラクタで生成することができます。

CMyClass function()

{

CMyClass value;

CMyClass& result = value;

 

// 参照型の変数は、自動では右辺値参照にならないため、明示的な std::move が必要です。

return std::move(result);

}

これは、関数が引数に取ったクラスを戻り値として return するときも同じです。

参照やポインタではない通常のクラスを引数で受け取った場合、受け取った時点で新しいインスタンスがコピーコンストラクタで生成されているので、関数内で新たにクラスを定義した時と同じように、それを右辺値参照で返しても問題ありません。

引数でクラスを参照指定で受け取った場合は、それをそのまま return する場合でも、通常の参照として扱われるため、戻り値用のインスタンスはコピーコンストラクタを使って生成され、もともと受け取った参照の方を壊すことはありません。

 

ただし、このような右辺値参照による代入の恩恵を得るためには、そのクラスにムーブコンストラクタや右辺値参照を引数に取る代入演算子が実装されている必要があります。

逆にいうと、ただそれだけでこの恩恵を得ることができるので、クラスにはそれらを積極的に実装するのが良さそうです。


[ もどる ]