何についての記事か
自作クラスのメンバ変数にポインタが存在する時のMove/Copyコンストラクタ、代入演算子の実装について
Moveの実装
デフォルトのMoveコンストラクタ/代入演算子を使用すると何がまずいか
以下のようなコンストラクタでnewを行い、デストラクタで解放する処理があった時に、Moveと代入演算子をデフォルト実装していると問題が発生します。
std::moveの処理を行った後にotherがpAにアクセスしたタイミングでnullptrアクセスが発生します。
これはmoveしたのにも関わらずpAが管理しているリソースの所有権がotherに移行していないことが原因です。
インスタンスXのデストラクタはoriginalをstd::moveした際とmain終了前にotherに対して実行されます。
デストラクタの中にはリソース解放処理が実装されているため、originalが破棄されたタイミングでpAのリソースも解放されてしまいます。
struct X {
X(){
pA = new int;
*pA = 10;
}
//メンバ変数にポインタがあるのでデストラクタ内に解放処理を実装する
~X() {
delete pA;;
}
//MoveとCopyのコンストラクタと代入演算子は良く分からないのでデフォルト実装にしてしまったとする
X(X&& rhs) = default;
X& operator = (X&& rhs) = default;
X(const X& rhs) = default;
X& operator = (const X& rhs) = default;
int* pA = nullptr;
};
int main() {
X original;
X other;
other = std::move(original);//originalのpAはmove代入後originalのデストラクタで破棄される
//これ以降other.pAでアクセスするとnullptrアクセス
}//otherのデストラクタでnullptrアクセス
どのように実装すべきか
基本的には以下の方針で実装する
ムーブコンストラクタ
- ムーブ先へ所有権の移動
- ムーブ元の所有権の放棄
X(X&& rhs) noexcept
// ムーブ先へ所有権の移動
:pA(rhs.pA)
{
// ムーブ元の所有権の破棄(deleteではない。所有権はムーブ先に移動している)
rhs.pA = nullptr;
}
ムーブ代入演算子
- ムーブ先の所有権の解放(ムーブ先がメモリを確保していた場合は解放が必要)
- ムーブ先へ所有権の移動
- ムーブ元の所有権の放棄
X& operator = (X&& rhs) noexcept
{
//1. ムーブ先の所有権の解放
delete pA;
// 2. ムーブ先へ所有権の移動
pA = rhs.pA;
// 3. ムーブ元の所有権の放棄
rhs.pA = nullptr;
return *this;
}
デストラクではMoveによりリソースが既に解放されている場合もあるため解放済みなら解放しないようにする
~X() {
if (nullptr != pA) {
delete pA;
}
}
基本実装方針はこれまでに記載した方針で良いのだが、Mは自己代入と例外安全について考えておく必要があります。
基本的には「Copy and Swap」の技法が使われます。
Moveで自分を代入しようとした場合、deleteで自身のポインタを破棄してnullptrを代入することになります。
X& operator = (X&& rhs) noexcept
{
//自分自身を破棄
delete pA;
// 2. nullptrが格納される
pA = rhs.pA;
// 3. ムーブ元の所有権の放棄
rhs.pA = nullptr;
return *this;
}
自己代入とムーブ先のポインタが既に解放済みである時の対応は愚直に実装すれば以下
X& operator = (X&& rhs) noexcept
{
//自己代入の場合は何もしない
if (pA == rhs.pA) {
if (nullptr != pA) {
//ムーブ先の所有権の解放
delete pA;
}
// 2. ムーブ先へ所有権の移動
pA = rhs.pA;
// 3. ムーブ元の所有権の放棄
rhs.pA = nullptr;
}
return *this;
}
Copy and Swapで実装すると以下
X& operator = (X&& rhs) noexcept
{
X tmp(std::move(rhs)); //一時オブジェクトを作成
//std::move(rhs)でrhsが保持していたポインタはnullptrとなる
tmp.Swap(*this); //左辺と右辺のポインタを交換
//一時オブジェクトが保持するポインタ(元の左辺値が保持していたポインタ)はデストラクタの中で解放される
//元のコードのdelete pAに相当
//まとめて書くと↓
//X(std::move(rhs)).Swap(*this);
return *this;
}
void Swap(X& rhs)
{
std::swap(this->pA, rhs.pA);
}
注意点
noexceptはつけられるときはつけた方が良いようです。move_if_noexceptでnoexceptがついているか判定している処理があるため、noexceptがついていないと期待したmove処理が呼ばれないことがあるようです。
ムーブコンストラクタにnoexceptを付けないと呼ばれないことがある
Copyの実装
デフォルトのCopyコンストラクタ/代入演算子を使用すると何がまずいか
メンバ変数にポインタがあるクラスのインスタンスをCopyした場合にデフォルトのIFを使用した場合はポインタの値がCopyされるだけで、実体はCopyされない。この挙動が望ましい場合はこれで良いが、基本的には新しい実体が生成されていることが期待値となるケースがほとんどであろう。
筆者の経験ではデフォルトのCopy実装のままが望ましかったケースはクラス外部でアライメントしたメモリ実体を確保、破棄を管理する必要があり、そのポインタをあるクラスAに渡すような実装を行っていた場合くらいで、クラスAのCopyやデストラクタはdefaultが望ましく、勝手にメモリ実体の生成やアライメントしたメモリの破棄を行われると困るケースが存在した。
どのように実装すべきか
リソースを共有しない実装を行う。
Copyコンストラクタ
X(const X& rhs)
{
// 実体を生成し
pA = new int;
//実体をCopyする
*pA = *rhs.pA;
}
Copy代入演算子
X& operator = (const X& rhs)
{
delete pA;
pA = new int(*rhs.pA);
return *this;
}
Copy代入演算子でもMove同様に自己代入の問題が発生します。
deleteで自身を破棄する可能性があります。Move同様にCopy and Swapで対応できます。
X& operator = (const X& rhs)
{
//X(rhs).Swap(*this);//まとめて書くと
X tmp(rhs); //一時Copyオブジェクトを作成
Swap(tmp);//tmpとthisを交換
//tmpはデストラクトされる。もともと左辺値だったオブジェクトのメモリが解放される
//自分自身を破棄することはなくなる
return *this;
}
void Swap(X& rhs)
{
std::swap(this->pA, rhs.pA);
}
注意点
SwapのメンバのCopy漏れに気を付けましょう