C++ のスタイルを変えるかもしれない右辺値参照とムーブセマンティクス
C++11 で右辺値参照とムーブセマンティクスが追加されました。
これらを活用するとコピーのコストを削減できるようになり、
C++ では今まで出来なかったスタイルを広める可能性を秘めています。
そこで今回はその右辺値参照とムーブセマンティクスについて説明します。
これらは難しいと思っている人もいるかもしれませんが、ポイントを押さえればそんなに難しい話ではありません。
理解さえすれば、C++ コーディングにおける強力な武器になるはずです。
変数の不変性を重視したスタイル
C++ でいままでできなかったスタイルというのは変数の不変性を重視したスタイルです。ここでいう変数の不変性は一度作ったオブジェクトを変更しないことを言います。
例えば、文字列を大文字に変換する関数があったとします。
prologue.cpp:
string ToUpperString(const string &src) { string dest; for (auto ch : src) { dest.push_back(::toupper(ch)); } return dest; }
string src = "Hello"; auto newstr = ToUpperString(src);受け取った引数(src)を変更するのではなく、src はそのままで変換した新しい文字列を返します。
不変性を重視するスタイルでは、変数を変更したい場合にオブジェクトの中身を変更するのではなく、 こういった関数を使って新しい変数を作成していきます。
変数の不変性(参照透過性)
なぜ、変数の不変性を重視しないといけないのでしょうか?変数をなるべく変えないようにした方がコードの質、安全性が上がります。 オブジェクトの変更点をきっちり追わないといけないのは手間ですし、見過ごしによる危険を伴います。 これは const の重要性が分かっていれば、理解しやすいのではないでしょうか。 ただ、それらは感覚的な面も多いのですが、スレッドなどの並列処理では参照しているオブジェクトが変わらない(参照透過性)というのは、 安全面でかなり重要です。
実際、C#, Java, JavaScript などの言語では文字列オブジェクトは変更不可(immutable)となっています。 これをさらに、すべての変数に適用していたのが関数型プログラミングです。
C++ ではなぜできなかったのか
それほど有用なスタイルですが、C++ ではあまり採用されていません。(Qt などでは少し採用されています)というのもこのスタイルでは無駄が多いためです。先ほどの関数では戻り値を返すところでコピーが発生します。
他の言語などではポインターのように扱うものが多く、作成したオブジェクトを返してもポインター分のコピーにしかなりません。 一方、C++ ではオブジェクトのコピーではコストがかかりますし、ポインターを返すとガベッジコレクション(GC)がないため管理に問題が出てきます。
もともと変数の不変性を重視したスタイルは効率を犠牲にすることが多いのですが、 戻り値のコピーは完全に無駄で許容するには大きすぎます。
ムーブセマンティクスによる解決
実をいうとかしこいコンパイラーでは関数の戻り値でコピーが発生しない場合があります。 これは 戻り値の最適化(RVO) や 名前付き戻り値の最適化(NRVO) といった技術なのですが、 これは必ずできるわけではありません。ムーブセマンティクスが C++11 で導入された要因の一つはこういったコピーのコストを明示的に減らすことです。
string ToUpperStringEffective(const string &src) { string dest; for (auto ch : src) { dest.push_back(::toupper(ch)); } return std::move(dest); }std::move を使うとコピーではなく、移動になります。 次章でこの「移動」について説明します。
ムーブセマンティクスと右辺値
ムーブセマンティクスの「セマンティクス」は直訳すると「意味論」ですが、式や構文などよりもちょっと広い感じで、 「移動に関連すること」ぐらいに思ってください。ムーブセマンティクス
関数の戻り値だとちょっと説明しづらいので、次のような変数の swap(入れ替え)を行う場合を考えてみます。void Swap(string &a, string &b) { string tmp = b; b = a; a = tmp; }std::string の中身は実装依存ですが、 文字数やヒープ領域に確保した配列はメンバーに持っているはずです。 そういったアロケートしたメモリーを持つオブジェクトが対象だとします。
アロケートしたメモリーへのポインターがある場合には浅いコピーの問題が発生します。
単純な浅いコピーだと同じメモリーを二つのオブジェクトで持ってしまいます。
これを回避するためにはコピー先でもアロケートしたメモリーにコピーする必要があります。 こういった処理を深いコピーやクローンと言います。
このため、アロケートしたメンバーを持つ場合はコピーコンストラクターや代入演算子をオーバーロードします。
C++11 で新しくできるようになったのが移動です。 浅いコピーでは二つのオブジェクトが共有するのがダメなので、元の方から取ってしまいます。
ここで Swap の処理を見てみると tmp は関数をでると解放される変数ですが、代入する時に深いコピーが発生します。 この文字列がとても長い場合などかなりの無駄が生じます。
移動をすると当然、元の方は使えなくなってしまいます。 しかし、「使わなくなると分かっているのだったら移しちゃえ」ということで移動させるのがムーブセマンティクスの考え方です。 移動させることによりコピー時のコストが削減できます。
コピーではなく、移動させるためには std::move を使います。
void SwapEffective(string &a, string &b) { string tmp = b; b = a; a = std::move(tmp); }ちなみに tmp ← b, b ← a の時にも std::move を使ってもいいのですが、 分かりやすいように明らかにいらない tmp だけにしています。
なぜ、std::move を使うと移動になるのかというと、tmp が右辺値として扱われるためです。 次は「右辺値」について説明します。
右辺値
右辺値参照を理解する上で重要なポイントがあります。それは「代入式の右辺にある値が右辺値ではない」ということです。 右辺値参照を難しいと思っている人の多くはここを勘違いしているのではないでしょうか。 かく言う私も最初は間違っていました。
では、右辺値とは何かというと「使用する式を超えて保持されない一時的な値」のことです。
逆に左辺値というものもあり、変数であれば const であってもすべて左辺値です。
リテラルや計算結果で作成される値などは右辺値です。
int num = 5; // リテラル 5 int num2 = num * 2; // 計算結果 10 (num*2)代入式の右辺にあるからといって右辺値ではありませんが、 右辺値は左辺になれません。 ただ、const 定義された変数なども左辺になれないので、なれないからといって右辺値ではありません。
// 3 = num; // num * 2 = 7;右辺値というのは右辺にある必要もなく、次の式でも右辺値です。
cout << 3 << endl; cout << num * 2 << endl; cout << string("Hello") << endl; // string("Hello") はすぐに解放結局のところ、右辺値は「一時的な値」ということです。
右辺値はコンパイラーが必ず使わなくなると分かっています。 右辺値参照は「もう使わなくなるオブジェクトの参照」です。
前節の swap 時の tmp は使わなくなる変数でした。 これはコードの流れ上そうなっているだけで、 tmp は右辺値ではありません。 そのため、もう使わないということをコンパイラーに伝えるために std::move を使っています。
移動コンストラクター、移動代入演算子
std::move で std::string の移動を行いましたが、実際には std::move が移動させているわけではありません。 std::move は変数を右辺値にしているだけであり、右辺値に変換するためのキャストのようなものです。移動を行うのはオブジェクトの 移動コンストラクター や 移動代入演算子 です。これは右辺値を渡すことによって呼び出されます。
std::string などの標準ライブラリーにはそれらが予め実装されています。
ここからは移動コンストラクター、移動代入演算子を定義する方法について説明していきます。
ただし、それらを理解するためにはコピーコンストラクターと代入演算子についてしっかりと理解しておく必要があります。 その辺がまだあいまいな場合は先に以下の記事を読んでみてください。
自前で定義
サンプルとして次のようなクラスを考えてみます。これは文字列をアロケートを何度も書かなくていいように SetName() というメソッドを持っています。
movableclass.cpp:
class Person { char *m_name; ///< 名前 unsigned int m_age; ///< 年齢 public: /// コンストラクター Person(const char *name = nullptr, unsigned int age = 0) :m_name(0), m_age(age) { SetName(name); } /// デストラクター. virtual ~Person() { SetName(nullptr); } : Person &SetName(const char *name) { // 前の分は解放 if (m_name != nullptr) { delete m_name; m_name = nullptr; } // NULL でなければメモリーを確保 if (name != nullptr) { int leng = strlen(name); m_name = new char [leng + 1]; strcpy(m_name, name); } return *this; } : };アロケートしたメンバーを持っているので、コピーコンストラクターと代入演算子を用意します。
/// コピーコンストラクター Person(const Person &other) :m_age(other.m_age) { SetName(other.m_name); } /// 代入演算子 Person &operator=(const Person &other) { // 自身の代入チェック if (this != &other) { SetName(other.m_name); m_age = other.m_age; } return *this; }
まず、移動コンストラクターの定義から説明します。
&& が右辺値参照です。 右辺値がコンストラクターの引数として渡された場合に移動コンストラクターが呼ばれることになります。
右辺値ですので参照先のオブジェクトはもう使われません。そのため、渡されたオブジェクトは移動させてもよいということになります。
/// 移動コンストラクター Person(Person && other) :m_age(other.m_age) { std::cout << "Move Constructor from " << other << std::endl; m_name = other.m_name; other.m_name = nullptr; // 元のは NULL を指すように変更 }定義時のポイントは移動元のオブジェクトが指すポインターに nullptr を設定している点です。
移動元はもう使わないとは言っても好きに壊していいわけではありません。 移動元のオブジェクトでもデストラクターは呼ばれます。このクラスはデストラクターで nullptr かチェックして delete します。 nullptr に設定していないと浅いコピーのように多重解放となってしまいます。
なお、 ここでは nullptr かチェックしてますが、nullptr を delete をしてもよい というのは仕様で保証されています。
次に、移動代入演算子の定義です。
自身の代入のチェックをしている以外はコンストラクターと同じです。
/// 移動代入演算子 Person &operator=(Person && other) { std::cout << "Move = from " << other << std::endl; // 自身の代入チェック if (this != &other) { m_name = other.m_name; other.m_name = nullptr; // 元のは NULL を指すように変更 m_age = other.m_age; } return *this; }これらが定義されていることによって、右辺値を渡せば移動が行われるようになります。
Person peter(std::move(Person("Peter", 21))); Person michael; michael = Person("Michael", 16);実行結果:
$ ./a.exe Move Constructor from {"Peter"(21)} Move = from {"Michael"(16)}なお、コンストラクターの std::move はいらないのではないか と思った人用にちょっと補足です。
コンストラクターは結構柔軟に動作します。 どのコンパイラーでも同じかはわかりませんが、
Person peter(Person("Peter", 21))
だと Person peter("Peter", 21)
のように自動的に変換され、デフォルトコンストラクターが呼ばれてしまいます。
実行例では右辺値を渡しましたが、通常の変数など右辺値参照ではない値が渡された場合にはどうなるでしょうか?
その場合はオーバーロードで合う方、すなわち通常の参照(左辺値参照)を引数とする方が呼ばれます。 で、これがコピーコンストラクターや代入演算子です。
自動で定義される条件
コピーコンストラクターなどと同じように移動コンストラクター、移動代入演算子は自動で定義されることがあります。自動で定義される場合の条件は次の 3 つが定義されていない場合です。
- 移動コンストラクター、移動代入演算子
- コピーコンストラクター、代入演算子
- デストラクター
2 番目を定義するのは、わざわざコピーコンストラクターなどを定義しないといけないメンバーがあることを意味します。 そのため、移動コンストラクターも自動では定義されません。
気を付けなければいけないのは 3 番目の「デストラクターが定義されていない」という条件です。 移動した後のオブジェクトもデストラクターは呼ばれるわけで、 そこで独自の定義がされていると矛盾が生じる可能性があります。そのため、自動定義はされないようになっています。
今後は 空のデストラクターをとりあえず書いておく といったような不要なデストラクターは定義しないようにする必要があります。
ちなみに移動コンストラクター、移動代入演算子を定義せず、自動定義もされないとどうなるでしょうか?
これは簡単です。std::move などで右辺値を渡したとしても今まで通りコピーコンストラクター、代入演算子が呼ばれ、コピーされます。
自動定義の内容
自動で定義された移動コンストラクター、移動代入演算子で行う処理ですが、 これはコピーコンストラクターなどと一緒で、個々のメンバーに対して移動コンストラクター(移動代入演算子)を実行します。先ほどの Person クラスの名前のメンバー(m_name)が std::string であったとします。
class Person { std::string m_name; unsigned int m_age; : };int のような基本型は移動とコピーは同じです。
std::string にはすでに移動コンストラクター、移動代入演算子が実装されています。
よってこのクラスように すべてのメンバー変数が移動コンストラクター、移動代入演算子に対応している場合は自動定義を使う (明示的な定義をしない)方がいいでしょう。
移動を禁止する場合
アロケートしたメンバーを持つ場合、コピーコンストラクター等を定義するのではなく、コピーを禁止する場合があります。その場合、当然移動も禁止される必要があります。
で、どうするかというと、何もする必要はありません。 コピーを禁止すると移動も禁止されます。
protect.cpp:
/// コピー禁止クラス class NonCopy { int m_val; public: NonCopy(const NonCopy &) = delete; void operator=(const NonCopy &) = delete; NonCopy(int val=0) :m_val(val) {} int GetVal() const { return m_val; } };
NonCopy ncpy(1); // コンパイルエラー // NonCopy ncpy2(ncpy); // NonCopy ncpy2(NonCopy(2)); // NonCopy ncpy2(std::move(ncpy)); NonCopy ncpy3(3); // コンパイルエラー // ncpy3 = ncpy; // ncpy3 = NonCopy(4); // ncpy3 = std::move(ncpy);後述する所有権の移動などではコピーは禁止だけど移動は可能という場合があります。
この場合はコピーを禁止しておいて、移動コンストラクター、移動代入演算子を定義します。
/// 移動だけ OK クラス class MoveOnly { int m_val; public: MoveOnly(const MoveOnly &) = delete; void operator=(const MoveOnly &) = delete; MoveOnly(int val=0) :m_val(val) { std::cout << "Default Constructor val = " << val << std::endl; } MoveOnly(MoveOnly && other) :m_val(other.m_val) { std::cout << "Move Constructor from " << other << std::endl; } MoveOnly &operator=(MoveOnly && other) { std::cout << "Move = from " << other << std::endl; if (this != &other) { m_val = other.m_val; } return *this; } int GetVal() const { return m_val; } };
MoveOnly moly(1); // MoveOnly moly2(moly); // コンパイルエラー MoveOnly moly21(MoveOnly(21)); // デフォルトコンストラクター MoveOnly moly22(std::move(MoveOnly(22))); // 移動コンストラクター MoveOnly moly3(3); moly3 = MoveOnly(4); moly3 = std::move(moly);ちなみにコピーは可能だけど移動は禁止というのは今まで通りということです。 移動コンストラクター、移動代入演算子を定義せず、自動定義もなければ、右辺値を渡したとしてもコピーコンストラクターや代入演算子が呼ばれます。
デストラクターが定義されている場合の自動定義の利用
デストラクターは定義しているけど、移動は自動定義されたものでよいこともあると思います。そういった場合には関数の default の宣言によって自動定義と同じものを使うことができます。
defaultmove.cpp:
class Baz { Foo m_foo; ///< 移動対応済みメンバー public: Baz(int val=0) :m_foo(val) {} ~Baz() {} // ← これがあるので移動は自動定義されない // コピー定義 Baz(const Baz &src) = default; Baz &operator=(const Baz &src) = default; // 移動定義 Baz(Baz &&src) = default; Baz &operator=(Baz &&src) = default; };サンプルではコピーコンストラクターや代入演算子も default 宣言しています。 これは移動の方だけ default 宣言すると、明示的に禁止なくてもコピーは禁止、移動は OK となってしまうためです。
右辺値を受け取る関数
移動コンストラクターのように右辺値参照を使えば、 右辺値かどうかで処理を分ける関数をつくることができます。確認に使うクラスですが、先ほどの Person クラスはちょっと大きいので、 簡単なクラスを用意します。 移動を必要とするメンバーは持ってませんが、移動コンストラクターなどが呼ばれた時にメッセージを出すようにしています。
movetest.cpp:
class Foo { int m_val; public: Foo(int val=0) :m_val(val) {} Foo(const Foo &other) :m_val(other.m_val) { std::cout << "Copy Constructor from " << other << std::endl; } Foo(Foo && other) :m_val(other.m_val) { std::cout << "Move Constructor from " << other << std::endl; } Foo &operator=(const Foo &other) { std::cout << "Copy = from " << other << std::endl; if (this != &other) { m_val = other.m_val; } return *this; } : };
標準ライブラリーでの活用例
まず、右辺値で処理をわけるメリットを標準ライブラリーで見てみます。例えば、そこそこ大きなクラスを std::vector に格納する場合を考えてください。
vector への格納はコピーが発生し、それなりのコストになります。 すごく大きなクラスはポインターを格納するべきですが、管理が面倒になるので、そこそこぐらいではやりたくないです。
C++11 では vector などのコンテナークラスに格納する場合、右辺値であれば移動で格納されるようになっています。
vector<Foo> foos; foos.reserve(4); Foo left(1); foos.push_back(left); foos.push_back(Foo(2));コード中で vecotr::reserve() 予め配列を確保しているところがあります。
格納する時は移動なのですが、vector が配列を大きくした際にはコピーになってしまうようで、見やすいように予め確保しています。
実行結果:
Copy Constructor from Foo{1} Move Constructor from Foo{2}ちなみに std::vector を移動した場合はどうなるでしょうか?
この場合は移動元の vector 自体を使わないため、内部の配列を移動するだけで各メンバーは移動もコピーもしません。 vector のコピーやデフォルトの移動と混乱しないように注意してください。
vector<Foo> foos2 = std::move(foos);
文字列(string)の連結などにもムーブセマンティクスが活用されています。
string str = string("Hello") + " " + "world" + "!";上記の場合、 以前は
string("Hello") + " "
で "Hello " を新しくつくり、 + " world"
で "Hello world" を作り、
という感じで、一文字追加するにも全体を作成するので、記述は楽になったがコストはかかる という処理でした。こちらも C++11 以降では右辺値の場合は全体を作成するのではなく、必要な分を追加するということができるようになりました。
定義
右辺値かどうかで処理を分ける関数を実際に書いていきます。やり方はコンストラクターと同じです。 同名(オーバーロード)で次の 2 つを引数とする関数を定義します。
- 左辺値参照(従来の参照)
- 右辺値参照
class Bar { Foo m_foo; public: void Set(const Foo &obj) { std::cout << "Bar::Set(left )" << std::endl; m_foo = obj; } void Set(Foo &&obj) { std::cout << "Bar::Set(right)" << std::endl; m_foo = std::move(obj); } };これにより右辺値かどうかで処理を変えることができるようになります。
Foo obj(1); bar.Set(obj); bar.Set(Foo(5)); bar.Set(9); // 暗黙的な型変換が起こり Foo(9) が作成される実行結果:
Bar::Set(left ) Copy = from Foo{1} Bar::Set(right) Move = from Foo{5} Bar::Set(right) Move = from Foo{9}ちなみに、右辺値参照の引数によるオーバーロードだけでなく、 呼び出し時のオブジェクトが右辺値かどうかでメンバー関数をオーバーロードすることもできます。
こちらはオブジェクトが const かどうかでメンバー関数を二つ用意したときの拡張という感じです。
落ち穂拾い
右辺値参照とムーブセマンティクスで重要な点は説明し終わりましたが、 あまり使わないけど一応押さえておいた方がいいかなというのも説明しておきます。ユニバーサル参照と完全転送
前章の右辺値と左辺値用の関数ですが、 右辺値参照の型がテンプレートや auto(自動ジェネリック)である場合はユニーバーサル参照と呼ばれ、 どちらにもなれるようになっています。template <typename T> void SetBar(Bar &bar, T && val) { bar.Set(val); }このユニバーサル参照の関数を使ってみます。
SetBar(bar, obj); SetBar(bar, Foo(5)); SetBar(bar, 9);SetBar() に渡す値が右辺値だったとしても Bar::Set() に渡す時には関数引数の変数となるため、 左辺値になってしまいます。
(9 を渡した場合は SetBar 内で変換が起きるので、右辺値)
実行結果:
Bar::Set(left ) Copy = from Foo{1} Bar::Set(left ) Copy = from Foo{5} Bar::Set(right) Move = from Foo{9}これを右辺値のまま渡したいとなると、右辺値で処理を分けられるようにして std::move を使う必要があります。これは、ちょっとめんどくさいです。
しかし、完全転送(std::forward)を使うと簡単に書けるようになります。
template <typename T> void TransSetBar(Bar &bar, T && val) { bar.Set(std::forward<T>(val)); }完全転送では左辺値は左辺値、右辺値は右辺値のままになります。
TransSetBar(bar, obj); TransSetBar(bar, Foo(5)); TransSetBar(bar, 9);実行結果:
Bar::Set(left ) Copy = from Foo{1} Bar::Set(right) Move = from Foo{5} Bar::Set(right) Move = from Foo{9}
所有権の移動
C++11 ではスマート(かしこい)ポインターも追加されました。 これはポインターの寿命が無くなった時にポインターが指す先も解放されるというものです。 そのスマートポインターの一つとして unique_ptr というのがあり、これは auto_ptr の後継として用意されています。unique と付いているように対象をポインターとして指せるのは一つだけで、これをコピーすることはできません。
しかし、移動であれば複数から指されることにはならないため、新しい変数が作成可能となります。
std::unique_ptr<string> ptr(new string("Hello")); cout << *ptr << endl; auto newptr = std::move(ptr); cout << ((ptr == nullptr) ? "(null)" : *ptr) << endl; cout << *newptr << endl;実行結果:
Hello (null) Hellounique_ptr だけでなく、スレッドなど所有権があって複製するとまずいものはいくつかあります。 そういったコピーできないものを移動することを所有権の移動と呼びます。
なお、特に所有権の移動と呼ぶことはありませんが、通常の「移動」も 「アロケートしたオブジェクトの所有権を移動した」とも言えます。
まとめ
最後におさらいを兼ねて重要なポイントをまとめておきます。- 右辺値とは右辺の値ではなく、もう使わない値
- 左辺値参照(今までの参照)の他に右辺値参照を引数とする関数を定義することによって、右辺値を区別して処理できる
- 右辺値はもう使わない値なので、ヒープ領域のデータをコピーではなく移動ができる(効率的になる)
- 右辺値でなくても移動させたいものには std::move を使う
- 移動のためには移動コンストラクターと移動代入演算子を定義する
- すべてのメンバーが基本型か移動対応済みクラスであれば定義しない(default を使用)
- デストラクターは定義してはいけない
- アロケートしたオブジェクトをメンバーとして持つ場合は移動を定義するか、禁止する
- 移動を禁止する場合はコピー禁止だけで OK
- 関連記事
-
- C++14 Streams を使った関数型のデータ処理
- C++11 範囲に基づく for 文
- C++ での Mixin の活用 : Comparable を使って比較演算子を簡単実装
- C++ のスタイルを変えるかもしれない右辺値参照とムーブセマンティクス
- C++ typename の 2 つの使い方
Facebook コメント
コメント