やさしいムーブセマンティクス入門
はじめに
どうも、C++何もわからんJinです。Qiitaに投稿するのは二回目なのですが、今日はムーブセマンティクスについて説明したいと思います。JavaScriptなどの高級な言語を使っていると意識する機会はないと思いますが、C++を使っているとときどきそれが必要な場面が出てくると思います。
値の種類
私たちがコード内で扱ういわゆる"値"には大きく 右辺値 と 左辺値 に分類することができます。
右辺値は主に参照ではない関数の戻り値や文字リテラルや数値リテラルなどです。
言葉だけで説明しても分かりづらいと思うので例を挙げると、int f()の戻り値や、"string"などの実際の文字列、100などの実際の数値です。
ちなみに、int& g()は値ではなく参照を返しているのでg()の戻り値は右辺値ではありません。
一方、左辺値は変数のことを主に指します。
左辺値参照と右辺値参照について
C++初学者はint&
を単に「参照」と習ったかもしれません。
しかし左辺値と右辺値を習った今、厳密に言うと、「左辺値参照」です。
対照に「右辺値参照」というものも存在します。
右辺値参照はint&&
と表されます。
値渡しと参照渡し
仮引数に引数を渡す場合、実は値渡しと参照渡しという種類があります。
たとえば、int f(int)
に引数を渡すとき、値渡しが行われます。
値渡しを別名コピーと読んだりします。
コピーという名前から想像できる様に値渡しが行われる際、値が複製されます。
今回の場合では、引数がintなのであまり気にしませんが、この引数の型のサイズが非常に大きくなるとき、コピーのときにかかる時間は馬鹿にできません。
この"コピーのときにかかる時間"を別名 コピーコスト と呼びます。
アルゴリズムの世界において頻出の単語なので覚えておいたほうが良いと思います。
この値の複製を避けるために、参照渡しという手段があります。
int f(int)
をint f(int&)
とすることで値渡しではなく参照渡しが行われます。
参照渡しの場合、実引数のオブジェクトそのものを扱います。
したがって、関数内で仮引数を変更すると実引数も変更されます。
コピーとムーブ
また、コピーと対になる概念として ムーブ というものがあります。
ムーブは「実引数を関数に渡したあと、その引数のオブジェクトを使わないことが保証できる」際に有用な手段です。
それだけを言われたところで分からないと思うので、ここでは江添本を継承して、動的に要素を確保するコンテナクラスについて考えたいと思います。
template<class T>
struct dynamic_array{
T* begin;
T* end;
dynamic_array(size_t N): begin(new T[N]), end(begin + N){}
~dynamic_array(){
delete[] begin;
}
T& operator[](size_t index){
return begin[index];
}
};
このようなクラスです。
では、次にコピーコンストラクタについて考えましょう。(以下被るコードは省略させてもらいます)
//Copy-constructor
dynamic_array(const dynamic_array& arg){
std::copy(arg.begin, arg.end, begin);
}
このように実装することができます。
しかし、この場合要素数がとてつもなく大きかったらコピーコストがボトルネックになってしまいます。
ここで登場するのがムーブコンストラクタです。
ムーブの考えとして、「所有権を移動(ムーブ)させる」というものがあります。
所有権についてはここでは深く紹介しませんが、気になる人はスマートポインタを解説した記事を別にお読みください。
ちなみにムーブする場合は仮引数をTからT&&にする必要があります。
この場合だと配列を参照しているポインタを引き継いじゃえばいい訳です。
それを愚直に実装すると以下のようになります。
dynamic_array(dynamic_array&& arg)
: begin(arg.begin)
, end(arg.end)
{}
しかし、ここで問題点が発生します。
このとき、実引数ももちろんデストラクタが実行されます。
なので同じアドレスを示すポインタに対してdeleteが二重に行われてしまいます。
二重deleteは未定義動作なので何が発生するかわかりません。セグフォ(Segmentation Fault)が起こるかもしれません。
そのためこうすべきです。
dynamic_array(dynamic_array&& arg)
: begin(arg.begin)
, end(arg.end)
{
arg.begin = nullptr;
arg.end = nullptr;
}
この様にすることで二重deleteを防ぐことができます。
すると、当然実引数は正しく動作しなくなります。
これが先程述べた「実引数を関数に渡したあと、その引数のオブジェクトを使わないことが保証できる」という意味の真実です。
なので「ムーブコンストラクタに渡した引数が何もしてないのに壊れた」などと文句を言うことは言えません。
「お前が悪い」となるだけです。
ちなみにC++の思想として「コピー構築、代入は引数に対して破壊的行為をしない。ムーブ構築、代入は引数に対して破壊的行為をするかもしれない」という考えがあります。
そして、ムーブ構築、代入に引数を渡すときはstd::move(arg)という風にします。
最終形態
今回のクラスを使用するとこうなります。
template<class T>
struct dynamic_array{
T* begin, end;
dynamic_array(size_t N): begin(new T[N]), end(begin + N){}// (1)
dynamic_array(dynamic_array<T>&& arg)// (2)
: begin(arg.begin)
, end(arg.end)
{
arg.begin = nullptr;
arg.end = nullptr;
}
~dynamic_array(){
delete[] begin;
}
T& operator[](size_t index){
return begin[index];
}
};
int main(){
dynamic_array<int32_t> a(10);//このときはコンストラクタ(1)が呼び出される
dynamic_array<int32_t> b = std::move(a);//このときはコンストラクタ(2)が呼び出される
a[2];//aが要素にアクセスしてもエラーが起きるだけ!!なぜなら所有権がbに移動したから!
}
ちなみにint, charなどのプリミティブ型はムーブ構築、代入しても破壊的行為は起こさないので、ムーブ後にそのオブジェクトを使ってもエラーは起きません。
しかし、やはりエラーを防ぐためにムーブ後のオブジェクトは使わないことをおすすめします。
最後に
いかがでしたでしょうか。深夜テンションで小一時間で作ったので質の低い記事になったと思います。もし、「これは明らかに違う」みたいなことがあったら早急に私のTwitterアカウント(@CPP_IS_GOD)にご報告ください。