ムーブセマンティクス
ムーブとは
C++では、値の代入や関数の引数、戻り値などの値のやり取りはコピーが基本です。
class C
{
int* ptr;
public:
C(int* p) : ptr(p) {}
};
int main()
{
int n = 1;
C a(&n);
C b = a; //メンバを全てコピー
}
上記のコードでは、C
型のクラス変数(インスタンス)a
のメンバを全てコピーしたものが変数b
に格納されます。
(デフォルトコピーコンストラクタがあるため)
メンバにポインタ変数がある場合はアドレス情報がコピーされますが、アドレスが指し示す先の値はコピーされないので、これは意図しない動作が起こる可能性のあるコードです。
しかし、仮に変数a
をこれ以上使用することがないのならば問題は起こらないでしょう。
さらに言えば、もう使用しないのならばその時点で変数a
が内部に持っているポインタの所有権は放棄しても良いはずです。
あるオブジェクトの所有権を移動(放棄)する処理をムーブといいます。
ムーブ処理に対する言語サポートをムーブセマンティクスといいます。
セマンティクス
セマンティクスとは「意味論」という意味で、「このコードはどのような動作をするか」という概念です。
似た言葉にシンタックス(構文論)があります。
これは簡単に言えば「文法」で、「ある動作を実現するためにどのようなコードを書くか」という概念です。
int a = 1;
=
演算子の左辺に変数、右辺に値を置くこのコードは「変数に値を代入するための文法(シンタックス)」です。
同時に「変数への値の代入の意味(セマンティクス)」があります。
もう一つ例を見てみます。
int a[5] = { 1, 2, 3, 4, 5 };
int b = *(a + 3);
int c = a[3];
どちらも同じ「意味」のコードですが、二種類の「文法」が存在します。
前者(関節演算子*()
の方)は「配列先頭のアドレスに3を足し、そのアドレス先の値を取り出して変数に代入」という処理です。
後者(添え字演算子[]
の方)も全く同じ処理ですが、「配列の三番目の要素の取り出し」という意味がプログラマにとってより明確です。
同じ動作を実現する場合に「コードの意味がわかりやすいこと」はプログラミング言語の重要な要素のひとつです。
後者の文法は糖衣構文(シンタックスシュガー)という、同等の処理を別の文法で記述できるようにC言語(C++言語)がサポートしているから書けるコードです。
(コンパイルにより生成されるデータは全く同一になります)
ムーブセマンティクスはムーブ処理という「意味」を明確にするための言語によるサポートです。
C++03でムーブしてみる
ムーブセマンティクスはC++11(2011年改定のバージョン)からサポートされています。
しかしムーブ自体はプログラマが実装することでそれ以前から可能です。
例としてポインタをメンバに持つクラスのムーブ処理をC++03(2003年改定のバージョン)で書いてみます。
#include <iostream>
class C
{
int* ptr;
public:
C(int startValue = 0)
{
//int型100個分を確保
ptr = new int[100];
for (size_t n = 0; n < 100; n++) {
ptr[n] = n + startValue;
}
}
//コピーコンストラクタ
C(C& r)
{
//コピー元が管理しているポインタを受け取る
ptr = r.ptr;
//コピー元のポインタをNULLにする
r.ptr = 0;
//「nullptr」はC++11から使用可能なので
//ここでは0を使う
}
//代入演算子のオーバーロード
C& operator =(C& r)
{
if(this == &r)
return *this;
//管理しているデータを破棄
delete[] ptr;
ptr = r.ptr;
r.ptr = 0;
return *this;
}
~C()
{
delete[] ptr;
//NULLに対するdeleteは安全
}
//index番目の要素の値を表示
//(エラーチェックは省略)
void print(size_t index)
{
std::cout << ptr[index] << std::endl;
}
};
int main()
{
C a(10);
C b = a; //ムーブ
b.print(0);
b.print(99);
//これはNG
//a.print(0);
}
10 109
このクラスC
のコンストラクタは100個の要素をポインタに確保し、先頭から順に数値をカウントアップして格納します。
ムーブの結果を分かりやすくするために、カウントの最初の値を指定可能にしています。
コピーコンストラクタは、コピー元が管理するポインタをコピー先にコピーします。
ここまでは最初のコードと変わりませんが、その後すぐにコピー元のポインタをNULL(0)にします。
これにより複数の変数が同じポインタを持つことを防いでいます。
つまり、コピー元が持っていたポインタのすげ替えを行うことでデータの「所有権」を移動させています。
コピー先の変数b
は変数a
が「管理していた」ポインタをそのまま使用することができます。
変数a
は、クラス変数自体は破棄されていませんが、そのメンバが「管理していた」ポインタはすでに持っていないので使用することはできません。
ムーブとauto_ptr
このクラスには欠点があります。
それは「=
演算子で値を代入しているのにコピーではなくムーブが行われる」ことです。
=
演算子でムーブが行われるのはこのクラスを設計したプログラマが勝手に作った決まりで、それを知らない他人が使用するとまず間違いなく意図しない動作を引き起こします。
同様の問題はC++11よりも以前から存在していたauto_ptr
クラスにも存在します。
auto_ptr
もやってることは上記のクラスと同じで、=
演算子はコピーではなくムーブを行います。
動作を十分に理解して使用すれば問題はありませんが、やはり意味的におかしいので勘違いしやすく、問題のあるコードを書いてしまう可能性を上げてしまいます。
(auto_ptr
はその他の問題もあり、C++11から非推奨にされています)
C++標準クラスとムーブセマンティクス
自作クラスをムーブセマンティクス対応させる前に、ムーブセマンティクスを実際に使用してみます。
C++11以降の標準ライブラリで提供されるクラスは、一部を除きムーブセマンティクスに対応しています。
以下はvectorクラスをムーブする例です。
#include <iostream>
#include <vector>
int main()
{
std::vector<int> vec1{ 1, 2, 3 };
//単純なコピー
std::vector<int> vec2 = vec1;
//std::vector<int> vec2(vec1); //これでもOK
std::cout << vec1[0] << std::endl; //1
std::cout << vec2[0] << std::endl; //1
//ムーブ
std::vector<int> vec3 = std::move(vec1);
//std::vector<int> vec3(std::move(vec1)); //これでもOK
//vec1はもはや使用すべきでない
//std::cout << vec1[0] << std::endl;
std::cout << vec3[0] << std::endl; //1
}
vectorクラスのオブジェクト(インスタンス)を=
演算子で代入(初期化)すると、全ての要素がコピーされます。
要素数が増えるほどコストが大きくなります。
これに対し、=
演算子の右辺のオブジェクトにstd::move
を適用するとムーブが行われます。
ムーブはポインタのすげ替えですからコストはほとんどなく、要素数がどれだけ多くても処理時間は変わりません。
ムーブ後のオブジェクトvec1
はそれまで管理していたデータの所有権を持ちません。
なのでvec1
はそれ以降使用すべきではありません。
std::moveの動作について
std::move
はムーブの際に使用する関数(関数テンプレート)ですが、これ自体はムーブ処理を行いません。
std::move
は指定のオブジェクトの所有権を放棄しても良いという宣言に過ぎず、そのオブジェクトに対して実際にどのような操作が行われるかはオブジェクトを渡すクラス次第です。
vectorクラスの場合はムーブ後のオブジェクトは空になりますが、他のクラスだと何らかのデータが残っているかもしれません。
処理系によってはまた異なる動作になるかもしれません。
これについて規格には特に取り決めはないようなので、ムーブ後のオブジェクトは使用しないのが無難です。
詳しくは改めて説明しますが、std::move
は左辺値を右辺値参照にキャストします。
キャストするだけなので、単純にstd::move
を適用しただけではデータが書き換えられることはありません。
std::vector<int> vec{ 1, 2, 3 };
std::move(vec);
//vecに何も変化は起こっていない
なお、std::move
は<utility>
ヘッダファイルに定義されています。
<vector>
などからもインクルードされていますが、単独で使用する場合は<utility>
ヘッダファイルをインクルードします。
右辺値と右辺値参照
ここからは少し話が変わり、ムーブを理解するために必要となる、値の分類(値カテゴリ)について説明します。
左辺値と右辺値
左辺値は(何らかの演算子の)左側にある値、右辺値は右側にある値という意味ですが、C++11以降の特にムーブセマンティクスの文脈においては意味が異なります。
C++11では「lvalue(左辺値)」「prvalue(純粋右辺値、pure rvalue)」「xvalue(期限切れ間近の値)」という三つの値カテゴリが定義され、すべての値(式)はこのいずれかに属します。
ごく簡単に言えば、lvalueは名前のあるオブジェクト、prvalueは名前のない(付けられない)オブジェクトを言います。
lvalueはアドレスを持ち、prvalueはアドレスを持ちません。
xvalueはムーブセマンティクスの肝となるもので、後述します。
このほかに「glvalue(一般化された左辺値、generalized lvalue)」「rvalue(右辺値)」というカテゴリもあります。
これらは混合カテゴリといい、glvalueはlvalueとxvalueの総称、rvalueはprvalueとxvalueの総称です。
xvalueに当たらないglvalueがlvalue、xvalueに当たらないrvalueがprvalueです。
単に「右辺値」と呼ぶ場合、prvalueを指すことが多いですが文脈によってはrvalueを指す場合もあるので注意が必要です。
簡単な例を見てみます。
int n;
n = 10;
上記コードでは、変数n
がlvalue、数値リテラルの10
がprvalueです。
変数n
は=
演算子の左側にあるから左辺値という意味ではありません。
int n, m;
n = 10;
m = n;
上記コードで右辺値(prvalue)に当たるのは数値リテラルの10
だけです。
変数n
とm
は右辺に置いても左辺に置いても左辺値です。
変数は名前のある(アドレスの取れる)オブジェクトだからです。
数値リテラルなどのリテラルはアドレスが取れないのでprvalueですが、文字列リテラルは例外としてアドレスがあるのでlvalueになります。
左辺値参照と右辺値参照
左辺値(lvalue)に対する参照は左辺値参照といいます。
これは今まで使用してきた通常の参照です。
オブジェクトを参照で関連付けることを束縛するといいます。
prvalueは通常の参照で束縛することは出来ません。
(const
による例外あり。後述)
prvalueはC++11から右辺値参照という新しい参照方法で束縛できます。
int a = 1;
//左辺値参照
int& refl = a;
//右辺値参照
int&& refr = 2;
std::cout << refl << std::endl;
std::cout << refr << std::endl;
//1
//2
//prvalueは左辺値参照で束縛できない
//int& refl2 = 1;
//lvalueは右辺値参照で束縛できない
//int&& refr2 = a;
右辺値参照型はデータ型名&&
という形式で、&を二つ連続で記述します。
なお、右辺値参照型は右辺値を束縛する型というだけで、その変数自体は左辺値です。
(普通の変数と同じく名前があり、アドレスを持ちます)
int&& refr = 10;
int* p = &refr;
//refrとpは左辺値
prvalueを左辺値参照で束縛することは出来ませんが、例外的にconst参照で束縛することができます。
(これはC++11よりも前から可能です)
//参照で束縛できない
//int& a = 1;
//const参照は可能
const int& b = 1;
左辺値を右辺値参照として扱う
すでに説明したstd::move
は、左辺値を右辺値参照型として扱う(キャストする)ものです。
std::vector<int> vec1{ 1, 2, 3 };
//左辺値vec1を右辺値参照型にキャスト
//vectorクラスの処理によりムーブが行われる
std::vector<int> vec2 = std::move(vec1);
//以下でも同じ
//std::vector<int> vec2(std::move(vec1));
vectorクラスのコンストラクタに左辺値を渡すとコピーコンストラクタが呼ばれます。
右辺値参照型を渡すとムーブコンストラクタが呼ばれ、ムーブが行われます。
(ムーブコンストラクタは引数がひとつだけのコンストラクタ、つまり変換コンストラクタなので、=
演算子で呼び出せます)
std::move
を適用した値は、その所有権を放棄しても良いという宣言であるとすでに説明しました。
このような値はxvalueという値カテゴリになります。
これは「期限切れ間近の値(eXpiring value)」という意味で、オブジェクトが管理するデータを他のオブジェクトで再利用するための値カテゴリです。
ムーブコンストラクタ
デフォルトムーブコンストラクタ
自作クラスをムーブセマンティクスに対応させるには、ムーブコンストラクタを定義します。
実はクラスを定義するとデフォルトのムーブコンストラクタが自動的に生成されます。
ただしコピーコンストラクタ、コピー代入演算子、ムーブ代入演算子、デストラクタのいずれかを定義するとムーブコンストラクタは自動生成されなくなります。
(コンストラクタ自動生成のルール参照)
まずはデフォルトムーブコンストラクタを使用してみます。
#include <iostream>
#include <string>
class C
{
int num;
int* ptr;
std::string str;
public:
C(int n, std::string s)
: num(n), ptr(&num), str(s) {}
//メンバ変数を出力する
void print(std::string caption)
{
std::cout << caption << std::endl;
std::cout << "num : " << num << std::endl;
std::cout << "ptr : " << ptr << std::endl;
std::cout << "str : " << str << std::endl;
std::cout << std::endl;
}
};
int main()
{
C a(10, "abc");
C b = std::move(a); //デフォルトムーブコンストラクタを使用
a.print("a");
b.print("b");
std::cin.get();
}
a num : 10 ptr : 0133FE24 str : b num : 10 ptr : 0133FE24 str : abc
※アドレスは実行毎に変わります。
int型とポインタはムーブ機能がないので単純なコピーが行われます。
C++標準クラスであるstringクラスはムーブに対応しているので、値が移動していることがわかります。
基本型やムーブに対応したクラスオブジェクトのみをメンバに持つ場合はデフォルトのムーブコンストラクタで十分です。
今回のようにメンバにポインタがある場合は不都合なので、ムーブコンストラクタを自分で定義する必要があります。
(デフォルトムーブコンストラクタが自動生成されない場合も自分で定義する必要があります)
ムーブコンストラクタの定義
ムーブコンストラクタは自分のクラスの右辺値参照型(T&&
)を引数に取るコンストラクタです。
#include <iostream>
#include <string>
class C
{
int num;
int* ptr;
std::string str;
public:
//通常のコンストラクタ
C(int n, std::string s)
: num(n), ptr(&num), str(s) {}
//コピーコンストラクタ
C(const C& r)
: num(r.num), ptr(nullptr), str(r.str) {}
//コピー代入演算子
C& operator=(const C& r)
{
if (this == &r)
return *this;
num = r.num;
ptr = nullptr;
str = r.str;
return *this;
}
//ムーブコンストラクタ
C(C&& r) noexcept
: num(r.num), ptr(r.ptr), str(std::move(r.str))
{
//コピー元のポインタをNULLにしておく
r.ptr = nullptr;
}
//ムーブ代入演算子
C& operator=(C&& r) noexcept
{
if (this == &r)
return *this;
delete ptr;
num = r.num;
ptr = r.ptr;
str = std::move(r.str);
//コピー元のポインタをNULLにしておく
r.ptr = nullptr;
return *this;
}
//メンバ変数を出力する
void print(std::string caption)
{
std::cout << caption << std::endl;
std::cout << "num : " << num << std::endl;
std::cout << "ptr : " << ptr << std::endl;
std::cout << "str : " << str << std::endl;
std::cout << std::endl;
}
};
int main()
{
C a(10, "abc");
C b = std::move(a); //ムーブ
a.print("a");
b.print("b");
std::cin.get();
}
a num : 10 ptr : 00000000 str : b num : 10 ptr : 004FF740 str : abc
コピーコンストラクタ(と代入演算子)とムーブコンストラクタ(と代入演算子)の定義を抜粋します。
class ClassName
{
//コピーコンストラクタ
ClassName(const ClassName& r) {}
//コピー代入演算子
ClassName& operator=(const ClassName& r) {}
//ムーブコンストラクタ
ClassName(ClassName&& r) noexcept {}
//ムーブ代入演算子
ClassName& operator=(ClassName&& r) noexcept {}
}
コピーコンストラクタはデータ型に&
をひとつ、ムーブコンストラクタは&
をふたつ記述します。
ムーブコンストラクタは実引数を書き換えるため、引数にconst
を付けることはできません。
ムーブコンストラクタ内の処理はC++03で実装したときと基本的に同じで、int型などの基本型は単なるコピー、ポインタは値のコピー後にコピー元をnullptr
に、その他のクラスはstd::move
でムーブします。
ムーブに対応していないクラスにstd::move
を適用した場合はコピーコンストラクタが呼ばれます。
int型のように、ムーブできないことが分かっている型ならわざわざstd::move
を適用する必要はありません。
上記のムーブコンストラクタ、ムーブ代入演算子にはnoexcept
というキーワードが付けています。
これはこの関数からは例外を送出しないことを保証するという意味です。
noexcept
が無くても動作はしますが、いくつかのC++標準クラス(vectorクラスなど)でnoexcept
のない自作クラスを使用すると、ムーブされずにコピー処理が呼ばれてしまう場合があります。
noexcept
については例外処理2#noexceptを参照してください。
完全転送
ある関数が引数に受け取った値を、そのまま別の関数に渡すことはよくあります。
引数の参照情報(左辺値参照/右辺値参照)も含めてそのまま別関数に渡すことを完全転送といいます。
ユニバーサル参照
以下のクラスC
のメンバ関数add
は、引数で受け取った値をそのままvectorクラスに格納しています。
#include <iostream>
#include <vector>
#include <string>
class C
{
std::vector<std::string> vec1;
std::vector<std::string> vec2;
public:
void add(const std::string& s1, const std::string& s2)
{
std::cout << "copy : " << s1 << s2 << std::endl;
//コピーして追加
vec1.push_back(s1);
vec2.push_back(s2);
}
void add(std::string&& s1, std::string&& s2)
{
std::cout << "move : " << s1 << s2 << std::endl;
//ムーブして追加
vec1.push_back(std::move(s1));
vec2.push_back(std::move(s2));
}
};
int main()
{
std::string s1 = "ab";
std::string s2 = "cd";
std::string s3 = "ef";
std::string s4 = "gh";
C a;
a.add(s1, s2);
a.add(std::move(s3), std::move(s4));
std::cin.get();
}
copy : abcd move : efgh
vectorクラスのpush_back
メンバ関数は、左辺値と右辺値参照の両方を引数に取れるようにオーバーロードされています。
左辺値の場合はコピーして追加、右辺値参照の場合はムーブして追加されます。
可能ならムーブしたほうが高速なので、メンバ関数add
も両方を付け取れるように左辺値と右辺値参照の二種類をオーバーロードしています。
すでに説明した通り、右辺値を束縛する右辺値参照型の変数それ自体は左辺値です。
右辺値参照型の引数も同様に左辺値ですから、これをそのままpush_back
関数に渡すとムーブではなくコピーになります。
なのでムーブするにはstd::move
を適用して右辺値参照にキャストする必要があります。
さて、これで一応目的通りの動作はしますが、同じような処理をふたつも書くのは無駄と言えます。
また、第一引数はコピーで第二引数はムーブしたい、などにも対応しようとするとオーバーロードの定義が煩雑になります。
引数の数が増えるほど手間が増え、プログラミングの本質ではない部分に労力が割かれることになります。
class C
{
public:
//左辺値参照、左辺値参照
void add(const std::string& s1, const std::string& s2) {}
//右辺値参照、右辺値参照
void add(std::string&& s1, std::string&& s2) {}
//左辺値参照、右辺値参照
void add(const std::string& s1, std::string&& s2) {}
//右辺値参照、左辺値参照
void add(std::string&& s1, const std::string& s2) {}
};
このような場合は関数テンプレートを使用して、以下のように一つにまとめることができます。
class C
{
std::vector<std::string> vec1;
std::vector<std::string> vec2;
public:
template<typename T, typename U>
void add(T&& a, U&& b)
{
vec1.push_back(std::forward<T>(a));
vec2.push_back(std::forward<T>(b));
}
};
引数の型のT&&
は型引数の右辺値参照に見えますが、型テンプレート引数に対して&&
を指定した場合はユニバーサル参照(転送参照(forwarding reference))という特殊な参照になります。
ユニバーサル参照に対して左辺値が与えられたときは左辺値参照(&
)に、右辺値が与えられたときは右辺値参照(&&
)になります。
これひとつで左辺値も右辺値も束縛できるので、面倒なオーバーロードは必要なくなります。
std::forward<T>
オブジェクトをムーブするために今まではstd::move
を使用してきましたが、これは左辺値を右辺値参照にキャストするためのものです。
これをユニバーサル参照の引数に適用すると、元の参照情報に関わらずすべて右辺値参照型にキャストされます。
つまり左辺値を左辺値参照として別関数に渡すことができません。
std::vector<std::string> vec;
template<typename T>
void add(T&& a)
{
//左辺値である引数aを右辺値参照にキャストする
//結果として常に右辺値参照が渡される
vec.push_back(std::move(a));
}
int main()
{
std::string s = "abc";
add(s);
//左辺値を渡したのに関数内で右辺値参照に変換されるので
//意図せずムーブされてしまう
}
ユニバーサル参照を使用する場合は、std::move
ではなくstd::forward<T>
を使用します。
(<utility>
ヘッダファイル)
これはT&&
が左辺値参照の場合は値を左辺値参照型に、右辺値参照の場合は値を右辺値参照型にキャストする関数テンプレートです。
std::forward
はstd::move
と同じくキャストするだけの関数で、それ単体を呼び出しただけでは何もしません。
オブジェクトを渡した先でムーブされる可能性があるので、それ以降はそのオブジェクトを使用すべきではありません。
std::vector<std::string> vec;
template<typename T>
void func1(T&& a)
{
vec.push_back(std::forward<T>(a));
//引数aはすでにムーブされている可能性があるので
//使用すべきでない
//vec.push_back(std::forward<T>(a));
}
template<typename T>
void func2(T&& a)
{
//std::forwardを使用しなければ左辺値なので
//ムーブされない(コピー)
vec.push_back(a);
//安全
vec.push_back(std::forward<T>(a));
}
ユニバーサル参照は「forwarding reference(転送参照)」が正式名称ですが、この名称がつけられる以前から「ユニバーサル参照」の呼称が広まっていたためこちらで呼ばれることが多いです。
ユニバーサル参照は関数テンプレートのほか、autoによる型推論でも使用できます。
int n = 1;
auto&& lref = n; //int&
auto&& rref = 2; //int&&