読者です 読者をやめる 読者になる 読者になる

きままにブログ

プログラミングを主とした私のメモ帳です。寂しいのでコメントください笑

C++初心者向け講座その1 ~RAIIのお話~

概要

C++の初心者向けに、クラスを利用したリソース管理及び特殊なメンバ関数であるコンストラクタ、デストラクタ、コピーコンストラクタ、ムーブコンストラクタと、デフォルトで定義されうるコピー代入演算子、ムーブ代入演算子について簡単な解説をしながらRAIIというC++で有名なイディオムの理解を深めていきます。

ついでに、例外について簡単な解説をした後、例外安全について触れ、RAIIの利点を紹介します。ただし第1回目は私の都合でムーブコンストラクタまでご紹介します。第2回目で後半を説明します。

さて、初心者にありがちなのですが、ポインタを使いこなそうとしています。残念ながら、実際はポインタをなるべく使わない方向に向けた方がいいのです。そのためにRAIIを活用していきましょう。

コンストラクタとデストラクタ

既にクラスの概要についてはご存じだとは思いますが、クラスにはコンストラクタという特殊なメンバ関数が存在します。これはクラスの初期化をするためのものですが、人によっては次のように書く人がいるみたいです。

class S {
public:
	S() {  }
	void initialize() {
		// 初期化処理
	}
};

これはこのクラスを使う人に負担を押しつける書き方なのでやってはいけません。初期化を遅延したければstd::optional (C++17予定)やそれに類するoptional (自作可能)を使用すればいい話です。では何故ダメなのでしょうか?

まずはじめに呼び出す時を考えます。

S s; // コンストラクタが呼ばれる
// この間はsは使えない、意味を持たない
s.initialize(); // これを呼ばないといけない

このようにコンストラクタを実行した後に無効な状態が出来てしまい、危険です。また、ユーザーは呼び忘れるかも知れません。初期化処理はコンストラクタに書きましょう。

そしてデストラクタですが、後で述べますが、通常はデストラクタはデフォルトのものを使用するべきです。つまり定義しません。あるいはデフォルト宣言します。

  • 定義しない
class S {
public:
	S() {  }
};
  • デフォルト
class S {
public:
	S() {  }
	~S() = default;
};

ただし、コンストラクタで何らかのリソース(メモリファイル系ソケット系ハンドル系など)を獲得した場合は、デストラクタで解放しなければなりません。後で述べますが、通常はメンバに任せます

デストラクタが呼ばれるとき

class S {
public:
	S() {  }
	~S() = default;
};

void func() {
	S s;
}

関数func内でクラスSのインスタンスsがローカル変数として宣言されています。このため、関数func内ではスタックにクラスSのインスタンスを積みます。このとき変数sを初期化するためにSのコンストラクタが呼び出されます。そしてこの関数を抜けるときに(returnや例外のthrowあるいは関数の終端)デストラクタが呼び出され、スタックを破棄します。

つまり、ローカル変数のスコープを抜けるときにデストラクタが呼び出されます。

あるいは次のような場合もあります。

void func() {
	S* s = new S; // コンストラクタが呼ばれる

	delete s; // デストラクタが呼ばれる
}

この例では、関数func内でS型へのポインタS* sがローカル変数として宣言されています。このポインタは64bit環境では8byte(=64bit)の変数ですが、ポインタの指す先(実体)は確保できるどんなサイズでも構いません。実体はnew演算子によって生成され、スタック上にはなく、ヒープ上にあります。そのヒープ上を指し示すポインタをスタックに積んでいます。そして、要らなくなったら明示的にdeleteしなければなりません。ポインタが関数を抜けても、そのポインタが使えなくなるだけで、実体は残ったママなのです。

newすると実体が生成されますから、そのときにコンストラクタが呼ばれます。また、deleteするときにデストラクタが呼ばれて最後にメモリが解放されます。

このような例を出してあれですが、初心者はnew/deleteを一切使わない、これに限ります。ただし今回はこのnew/deleteを使った場合、即ちライブラリを作る側として話を進めますので、ついでに使い方も覚えてしまいましょう。new/deleteを使わないから、new/deleteを閉じ込めるにステップアップです。

RAII

さてリソースについてですが――先ほどはnew/deleteを使ったメモリがそのリソースに相当しますが――先ほどのようにnewとdeleteが分かれていると管理が大変です。次の例を考えましょう。

S* func() {
	S* s = new S;

	if (s->xxx()) {
		delete s; // これ書き忘れそう
		return nullptr;
	}

	return s; // というか誰がdeleteするの?
}

クラスSには何らかの条件判断を行うxxxメンバ関数が定義されているとします。この関数が成功したときには0を返し、そうでないときはそれ以外を返します。そして失敗したときはfuncはnullptrを返し、成功したときはnewしたポインタを返すとしましょう。

まず、newしたポインタを関数が返すのは間違っていることは置いておいて、関数から抜けるに当たってその前にdeleteしないといけないのが怪しいです! この例だと1つだけですがこんなのが何個もあったら地獄絵図です。

では、どうしたらいいでしょうか? 関数から抜けたときに、というのがポイントです。そう、先ほどのデストラクタですね。これを利用してみましょう。

class P {
public:
	P() : s { new S } {}
	S* get() { return s; }
	~P() { delete s; }
private:
	S* s;
};

まずはこのようなSのラッパークラスを定義します。ラッパーとは、プログラミングでよく出てきますが、あるものを覆い被すようにして使いやすくするものです。上位レイヤーともいいます。ここではクラスPがクラスSのラッパーで有り、今回の主役です。

コンストラクタではSをnewしてメンバ変数S* sに保存します。そしてデストラクタではsをdeleteします。従って、このクラスPのインスタンスはスコープを抜けたときに自動的にdeleteしてくれます。

使ってみましょう。

S* func() {
	P p;

	if (p.get()->xxx()) {
		// delete s; 不要
		return nullptr;
	}

	return p.get(); // ここでもdeleteされてしまう!
}

このように、自動的にdeleteしてくれます。このようにコンストラクタでリソースを獲得し、デストラクタで後処理をする方法をRAIIといいます。ところがこのままでは問題があります。S*を返すときに消されてしまいまうのです! 後で改善してみましょう。

コピーコンストラクタ

コピーコンストラクタは、そのオブジェクトを渡してコピーするのが役目です。後で述べますが、通常はデフォルトにすべきです。

例えば次のクラスを考えます。

class C {
public:
	int x;
	int y;
};

デフォルトでコピーコンストラクタが定義されているので、クラスのコピーが可能です。次のように使います。

C c { 1, 2 }; // cをx=1,y=2で初期化
C c2 { c }; // c2をcをコピーして初期化

デフォルトのコピーコンストラクタだとメンバ(x, y)のコピーを逐一行ってくれます。ただし、メンバがコピー可能出ない場合はその限りではありません!従って、メンバにはコピー可能なものだけを並べる必要があります。

コピーコンストラクタを自分で定義してみましょう。コピーコンストラクタも、いわばコンストラクタです。ただ、条件があって自分自身の参照を引数に取ります。

class C {
public:
	C(C const& it) : x { it.x }, y { it.y } {}
	
	int x;
	int y;
};

コンストラクタのように初期化子: メンバ{初期化値}で初期化します。

ポインタのコピーは危険!

さて本題です。ポインタがある場合です。ポインタをコピーするのは多くの場合誤りです。先ほどのクラスPのなかにポインタがありますね! これをコピーするとどうなるか見てみましょう。 (分かりやすくコピーコンストラクタを定義していますが、この場合はポインタ値はコピーできるので、デフォルトでコピー可能です。)

class P {
public:
	P(S* s) : s { s } {}
	P(P const& it) : s { it.s } { }
	S* get() { return s; }
	~P() { delete s; }
private:
	S* s;
};

これを使った関数funcを見てみましょう。

void func() {
	P p;
	P p2 { p }; // pと同じものを指すp2
	p.get() == p2.get(); // true
} // pとp2のデストラクタが呼び出される

pとp2の中のsは同じポインタを指しています。従って、deleteするとき同じポインタをdeleteしてしまいます!! 2重のdeleteはやってはいけません。選択肢は3つです。

  • 新しくSをnewして、中身をコピーする
  • コピーを禁止する
  • 何らかの機構で2重deleteできないようにする

参考までに標準ライブラリで言うと、1番目がstd::vectorのようなもの。2番目がstd::unique_ptrのようなもの。3番目がstd::shared_ptrのようなものです。自分が必要とするものを選びます。

コピーコンストラクタでリソース確保

ここでは、1番目の新しく確保してコピーを考えてみましょう。

class P {
public:
	P(S* s) : s { s } {}
	P(P const& it) : s { new S { *it.s } } { }
	S* get() { return s; }
	~P() { delete s; }
private:
	S* s;
};

とは言っても簡単で、コピーコンストラクタを改良しただけです。つまり、新しくnewするのですが、その際にコピー元となるit.sの実体である*it.sの参照を渡して、Sのコピーコンストラクタに任せる、というものです。クラスの責任はわけて、SのコピーはSに任せるのです。

こうすることでコピーしても2重deleteされなくて済みます!

void func() {
	P p;
	P p2 { p }; // pとは異なるものを指す
	p.get() == p2.get(); // false
} // pとp2のデストラクタが呼び出されるが、大丈夫

こうすることで先ほどの例である次のものが改善されます。

S* func() {
	P p;

	if (p.get()->xxx()) {
		// delete s; 不要
		return nullptr;
	}

	return p.get(); // ここでもdeleteされてしまう!
}

これが、

P func() {
	P p;
	
	if (p.get()->xxx()) {
		// delete s; 不要
		return ???;
	}
	
	return p; // 新たにコピーを作って返す
}

と書けます。関数の戻り値では新しくコピーを作って返します。ただし、nullptrのようなものは返せなくなります。対策は3つあります。

  • 例外を使ってreturnではなくthrowする。
  • Pがnullptrを持てるようにする。
  • std::optionalのように無効値を持つクラスでラップする。

このエラーがシステムの正常に稼働する範囲の状態の一つの時は例えばstd::optionalでラップしたり、nullptrを保持できるようにした方が良いでしょう。そうではなく、起こったらユーザーに選択を迫らなければならなかったり、直前のロジックを辞めなければならなかったりするときは例外を使用するのが良い選択です。

P func() {
	P p;
	
	if (p.get()->xxx()) {
		throw std::exception { "初期化に失敗しました。" };
		// 内部のsはdeleteされる。
	}
	
	return p; // 新たにコピーを作って返す
}

これでめでたくSをラップしてメモリ管理をPに任せることが出来るようになりました。ちなみに何度も言いますが、通常はnew/deleteを使う必要がありません。今回はライブラリを作る側の話をしています。

さて早速関数funcから受け取ってみましょう。

int main() {
	try {
		P p = func(); // これは代入ではない
		// pを使う
	} // 勝手に解放される
	catch (std::exception const& it) {
		std::cout << it.what() << std::endl;
	}
}

ポイントは4つです。まずは例外の使い方ですが、tryブロックとcatchブロックに分かれます。tryのなかで例外がthrowされると、catchへ飛びます。このときローカル変数のデストラクタは全て呼び出されます。今回はfuncの中で例外が発生する可能性がありますが、まだpが構築されていないのでpのデストラクタは呼び出されません。

2つめは例外はstd::exception const& itで受け取ることです。throwはどんなデータを渡しても良いのですが、お行儀の用意例外の書き方は、std::exceptionを継承した例外クラスを(必要なら)自分で定義してそれを渡します。これについてはまたいつか述べます。

3つめはもしもfuncが成功したら、pは初期化されます。代入演算子ではなく、これはコピーコンストラクタが呼ばれます。P p = func();P p { func() };と等価です。

4つめは、tryブロックを抜けたらpのデストラクタが呼ばれ、中でnewしたSは自動的にdeleteされます!大事なことなのでもう一度言いますが、tryブロックを抜けたらpのデストラクタが呼ばれ、中でnewしたSは自動的にdeleteされます! (ここ感動するところです。)

無駄を省くムーブコンストラクタ

勘のいい人は気付くかも知れませんが、先ほど定義したクラスPには無駄があります。

P func() {
	P p; // 中ではここでSをnew
	
	if (p.get()->xxx()) {
		throw std::exception { "初期化に失敗しました。" };
	}
	
	return p; // 中ではここでもSをnew
}

先ほども触れましたが、関数の戻り値にポインタや参照を指定しない場合、クラスをコピーして返します。

P func() {
	P p;
	return p;
}

は、

P func() {
	P p;
	return P { p };
}

のような感じになります。つまりコピーされてしまうのです。もちろん、ローカル変数のポインタや参照は絶対に返してはいけませんのでこれはこれで正しいプログラムなのですが、無駄がありますね。newは1回で良いはずなのに……。そんな理由で登場したのがムーブコンストラクタです。

ムーブコンストラクタ

ムーブコンストラクタは定義自体は簡単で、P&&を引数に取るコンストラクタです。次のようになります。

class P {
public:
	P() : s { new S } {}
	P(P const& it) : s { new S { *it.s } } { }
	P(P&& it) { } // ムーブコンストラクタ
	S* get() { return s; }
	~P() { delete s; }
private:
	S* s;
};

このコンストラクタが何か特別なことをしてくれるかというとそういうわけではありません。この中に、ムーブする処理を書かないといけないのです。とは言っても簡単です。

要は、新しくnewしてコピーをするのではなく、ポインタを直接コピーしてしまえばいいんです! え? それじゃあ最初の2重deleteと同じじゃないかって? そうなのです。コピーコンストラクタだと、さきほどのように2重delete問題が起こってしまうのです。

class P {
public:
	P(S* s) : s { s } {}
	P(P const& it) : s { it.s } { } // itは書き換えられないなあ
	S* get() { return s; }
	~P() { delete s; }
private:
	S* s;
};

ところが、ムーブコンストラクタの引数P&& (2回&が付いてますが間違ってません!)はconstじゃないので、itを書き換えられます。そして! 引数itのit.sをnullptrにしてあげることで、2重deleteが防がれるのです!

class P {
public:
	P() : s { new S } {}
	P(P const& it) : s { new S { *it.s } } { }
	P(P&& it) : s { it.s } { // ポインタをコピー
		it.s = nullptr; // でも相手のポインタはnullptrにしちゃう
	}
	S* get() { return s; }
	~P() { delete s; }
private:
	S* s;
};

これじゃあムーブ元のポインタが無効になっちゃうじゃないか! とお思いでしょうが、それでいいのです。ムーブ=移動ですからね。自分は死ぬ代わりに、相手にポインタを託す、そんなものです。

呼び出す側について考えてみましょう。要はムーブしたいときは、コピーとは違って、P&&型にキャストすれば良いのです。

P func() {
	P p;
	P p2 { (P&&)p }; // ムーブされる
}

もちろん、いくら初心者でもこのような書き方はC++を書くものとして許されません。static_castを使って、

P func() {
	P p;
	P p2 { static_cast<P&&>(p) }; // ムーブされる
}

と書きます……が、実は&&にキャスとしてくれる便利な関数が標準で用意されています。

P func() {
	P p;
	P p2 { std::move(p) }; 
}

ムーブしているのが見た目に分かりやすいですね。当然、pはムーブされたのでpの中のsはnullptrになっています。そして、pのデストラクタではdelete nullptr;が実行されるわけですが、deleteはnullptrを渡してもよく、その場合は何も起こらないので正しい挙動です。

むしろ重要なのは、ムーブ後のデータは、デストラクタで正しく、それもなるべく高速に解体されることが望まれます。この場合は相手のポインタをnullptrにすることで、2重deleteされず(正しく)、高速に(nullptrチェックも要らずに)解体されるのです。

右辺値参照

ところでこの&&は何物かというと、右辺値参照と言います。この辺の話は話し始めると長くなるので省略しますが、簡単に言うと=の左に置けるものが左辺値で、それ以外が右辺値です! 右辺値をコンストラクタに渡すとムーブしてくれます。そして、右辺値にするための便利な関数がstd::moveです。

さて、話を戻して次の例を見ましょう。

P func() {
	P p; // 中ではここでSをnew
	
	if (p.get()->xxx()) {
		throw std::exception { "初期化に失敗しました。" };
	}
	
	return std::move(p); // ムーブするからnewしない
}

最後のstd::move(p)に注目ですが、実はこのstd::moveは要りません。なぜならばreturn後はローカル変数pは不要なのでmoveしても何も問題が無いからです。仕様で右辺値扱いされます。つまり、こう書けます。

P func() {
	P p; // 中ではここでSをnew
	
	if (p.get()->xxx()) {
		throw std::exception { "初期化に失敗しました。" };
	}
	
	return p; // ムーブしてくれる
}

実はこのように何も書かないとコンパイラが最適化してムーブすらしない場合があります。つまり、コンストラクタのみ実行され、それが利用されるのです。これは自分で調べてみて下さい。(キーワード : RVO, NRVO)