最終更新:2026-10-12
C++ RAII リソース管理¶
0.はじめに¶
C言語には、静的、自動、動的という三種類のメモリ割り当てがあります。静的変数はソースファイルに埋め込まれた定数であり、サイズが既知で決して変わらないため、それほど面白いものではありません。自動割り当てはスタック割り当てと考えることができます。字句ブロックに入るときに領域が確保され、そのブロックを抜けるときに解放されます。その最も重要な特徴はこれに直接関係しています。C99より前は、自動割り当てされる変数はそのサイズがコンパイル時に分かっている必要がありました。つまり、任意の文字列、リスト、マップ、およびそれらから派生する任意の構造は、ヒープ上の動的メモリに存在しなければなりませんでした。
プログラマは、malloc、realloc、calloc、freeという四つの基本操作を使って動的メモリを明示的に割り当て、解放します。最初の二つは初期化を一切行わず、メモリにはゴミが残っている可能性があります。freeを除くすべての操作は失敗する可能性があります。その場合、それらはヌルポインタを返し、それへのアクセスは未定義の動作となります。最良の場合、プログラムはクラッシュします。最悪の場合、プログラムはしばらく動いているように見え、クラッシュする前にゴミデータを処理し続けます。
例:
int main() {
char *str = (char *) malloc(7);
strcpy(str, "toptal");
printf("char array = \"%s\" @ %u\n", str, str);
str = (char *) realloc(str, 11);
strcat(str, ".com");
printf("char array = \"%s\" @ %u\n", str, str);
free(str);
return(0);
}
出力:
このコードはシンプルですが、すでに一つのアンチパターンと一つの問題のある判断を含んでいます。実際のコードでは、バイト数を直接書くべきではなく、sizeof関数を使うべきです。同様に、ここではchar *配列を必要な文字列サイズのちょうど二倍(終端のナル文字を考慮して文字列長より一つ多く)で正確に割り当てており、これはかなり高くつく操作です。より複雑なプログラムであれば、文字列サイズが成長できるよう、より大きな文字列バッファを構築するでしょう。
1.RAIIの発明:新たな希望¶
少なくとも言えるのは、すべての手動管理は不快だということです。1980年代半ば、Bjarne Stroustrupは彼の全く新しい言語C++のために新しいパラダイムを発明しました。彼はそれを「リソース取得は初期化である(Resource Acquisition Is Initialization)」と呼び、その基本的な洞察は次のとおりです。オブジェクトがコンストラクタとデストラクタを持つよう指定でき、それらは適切なタイミングでコンパイラによって自動的に呼び出される、これが特定のオブジェクトのメモリを管理する、よりはるかに便利な方法を提供するということです。 また、このテクニックはメモリでないリソースに対しても有用です。
上の例はC++ではより簡潔になることを意味します:
int main() {
std::string str = std::string ("toptal");
std::cout << "string object: " << str << " @ " << &str << "\n";
str += ".com";
std::cout << "string object: " << str << " @ " << &str << "\n";
return(0);
}
出力:
上の例では、手動でのメモリ管理は一切ありません!stringオブジェクトを構築し、オーバーロードされたメソッドを呼び、関数を抜けるときに自動的に破棄されます。残念ながら、この同じ簡潔さは別の問題を引き起こします。一つの例を詳しく見てみましょう:
vector<string> read_lines_from_file(string &file_name) {
vector<string> lines;
string line;
ifstream file_handle (file_name.c_str());
while (file_handle.good() && !file_handle.eof() && file_handle.peek()!=EOF) {
getline(file_handle, line);
lines.push_back(line);
}
file_handle.close();
return lines;
}
int main(int argc, char* argv[]) {
// get file name from the first argument
string file_name (argv[1]);
int count = read_lines_from_file(file_name).size();
cout << "File " << file_name << " contains " << count << " lines.";
return 0;
}
出力:
これは非常にシンプルに見えます。vectorが満たされ、返され、呼び出されます。しかし、性能を気にする優秀なプログラマにとっては、この点にある問題が悩みの種になります。return文の中で、値セマンティクスが使われているため、vectorは破棄される直前に新しいvectorへとコピーされるのです。
現代のC++では、これはもはや厳密な要件ではありません。C++11はムーブセマンティクスの概念を導入し、これはオリジナルを有効な状態(正しく破棄できるように)だが未規定の状態に残します。コンパイラにとって、returnでの呼び出しはムーブセマンティクスに最適化しやすいケースです。なぜなら、さらなるアクセスが行われる前に直ちにソースが破棄されることを知っているからです。しかし、この例の目的は、なぜ80年代後半から90年代前半にかけて人々がゴミ収集を持つ言語を大量に発明したかを説明することにあり、当時はC++のムーブセマンティクスが利用できなかったのです。
データ量の比較的大きなファイルにとっては、これは高くつく可能性があります。これを最適化して、ポインタだけを返すようにしましょう。構文はいくつか変わりますが、他のコードは同じです:
vector<string> * read_lines_from_file(string &file_name) {
vector<string> * lines;
string line;
ifstream file_handle (file_name.c_str());
while (file_handle.good() && !file_handle.eof() && file_handle.peek()!=EOF) {
getline(file_handle, line);
lines->push_back(line);
}
file_handle.close();
return lines;
}
int main(int argc, char* argv[]) {
// get file name from the first argument
string file_name (argv[1]);
int count = read_lines_from_file(file_name).size();
cout << "File " << file_name << " contains " << count << " lines.";
return 0;
}
出力:
プログラムがクラッシュしました!上記のlinesに対してメモリ割り当てを行うだけです:
これで動きます!
残念ながら、これは完璧に見えますが、依然として一つの欠陥があります。メモリリークです。C++では、ヒープを指すポインタは不要になった後、手動で削除しなければなりません。そうしないと、最後のポインタがスコープを抜けた後、そのメモリは使用不可能になり、プロセス終了時にオペレーティングシステムが管理するまで回復しません。慣用的な現代のC++では、ここでunique_ptrを使います。これは望まれる動作を実装しており、ポインタがスコープを抜けるときに、その指すオブジェクトを削除します。しかし、この動作はC++11になってようやく言語の一部となりました。
ここでは、C++11より前の構文をそのまま使い、mainだけを次のように変えればよいのです:
vector<string> * read_lines_from_file(string &file_name) {
vector<string> * lines = new vector<string>;
string line;
ifstream file_handle (file_name.c_str());
while (file_handle.good() && !file_handle.eof() && file_handle.peek()!=EOF) {
getline(file_handle, line);
lines->push_back(line);
}
file_handle.close();
return lines;
}
int main(int argc, char* argv[]) {
// get file name from the first argument
string file_name (argv[1]);
vector<string> * file_lines = read_lines_from_file(file_name);
int count = file_lines->size();
delete file_lines;
cout << "File " << file_name << " contains " << count << " lines.";
return 0;
}
手動でメモリを割り当て、解放します。
不幸にも、プログラムが上述のスコープを超えて拡大すると、ポインタがいつどこで削除されるべきかについての推論は急速に難しくなります。ある関数がポインタを返すとき、あなたは今それを所有しているのでしょうか?使い終わったら自分で削除すべきでしょうか、それとも、後で一括解放されるデータ構造に属するのでしょうか?一方で間違えればメモリリーク、他方で間違えれば、今問題にしているデータ構造や他のデータ構造を破壊してしまいます。なぜなら、それらはもはや有効でないポインタを参照解除しようとするからです。
2.「ゴミ収集を使え、flyboy!」¶
ゴミ収集は新しい技術ではありません。それは1959年にJohn McCarthyがLispのために発明しました。1980年、Smalltalk-80の登場とともに、ゴミ収集は主流になり始めました。しかし、1990年代はこの技術の真の開花期でした。1990年から2000年の間に、何らかのゴミ収集を使う多数の言語がリリースされました。Haskell、Python、Lua、Java、JavaScript、Ruby、OCaml、C#が最も有名です。
ゴミ収集とは何でしょうか? 簡単に言えば、手動メモリ管理を自動実行するための一連の技術です。それは通常、手動メモリ管理を持つ言語(CやC++など)用のライブラリとして提供されますが、それを必要とする言語でより一般的に使われます。最大の利点は、プログラマがメモリについてまったく考える必要がないことです。すべてが抽象化されています。例えば、上のファイル読み込みコードに相当するPythonは次のとおりです:
def read_lines_from_file(file_name):
lines = []
with open(file_name) as fp:
for line in fp:
lines.append(line)
return lines
if __name__ == '__main__':
import sys
file_name = sys.argv[1]
count = len(read_lines_from_file(file_name))
print("File {} contains {} lines.".format(file_name, count))
行の配列は最初に代入されたときに生成され、呼び出し側のスコープにコピーされることなく返されます。タイミングが不確定なため、スコープを抜けた後のどこかの時点でゴミ収集器によって回収されます。興味深いことに、Pythonではメモリでないリソースに対するRAIIは慣用的ではありません。許容はされています。withブロックを使う代わりに、単にfp = open(file_name)と書いてGCに回収を任せることもできます。しかし推奨されるパターンは、可能な限りコンテキストマネージャを使い、確定したタイミングで解放できるようにすることです。
メモリ管理は簡素化されますが、大きな代償が伴います。参照カウント式のゴミ収集では、すべての変数代入とスコープ出口が、参照を更新するためのわずかなコストを負います。マークアンドスイープ方式では、GCがメモリを一掃している間、プログラム全体の実行が予測不能な間隔で一時停止します。これは通常、世界停止(stop-the-world)イベントと呼ばれます。この二つのシステムを同時に使うPythonのような実装は、両方のペナルティを受けます。これらの問題により、性能が極めて重要な場合やリアルタイムアプリケーションが必要な場合において、ゴミ収集言語の適用性は下がります。次のようなおもちゃのプログラムでも、実際の性能低下が見られます:
$ make cpp && time ./c++ makefile
g++ -o c++ c++.cpp
File makefile contains 38 lines.
real 0m0.016s
user 0m0.000s
sys 0m0.015s
$ time python3 python3.py makefile
File makefile contains 38 lines.
real 0m0.041s
user 0m0.015s
sys 0m0.015s
Python版の実時間はC++版のほぼ三倍です。この差のすべてがゴミ収集に起因するわけではありませんが、それでも無視できない大きさです。
3.所有権:RAIIの覚醒¶
オブジェクトの生存期間はそのスコープによって決まることを私たちは知っています。しかし、時には、あるオブジェクトを作成したいが、それはそのオブジェクトを作成したスコープとは無関係に存在してほしい、ということが有用である、あるいは非常に便利である場合があります。C++では、そのようなオブジェクトを作成するために演算子newが使われます。オブジェクトを破棄するには、演算子deleteを使えます。new演算子によって作成されたオブジェクトは動的に割り当てられ、すなわち動的メモリ(ヒープまたはフリーストアとも呼ばれる)に割り当てられます。したがって、newによって作成されたオブジェクトは、deleteを使って明示的に破棄されるまで存在し続けます。
newとdeleteを使うときに発生しうるいくつかの誤りは次のとおりです:
-
オブジェクト(またはメモリ)のリーク:newでオブジェクトを割り当て、そのオブジェクトの削除を忘れる。
-
早すぎる削除(またはダングリング参照):オブジェクトを指す別のポインタを保持したまま、そのオブジェクトを削除する。しかし他のポインタはまだそれを参照している。
-
二重削除:一つのオブジェクトを二回削除しようとする。
一般に、スコープ付きの変数が推奨されます。しかし、RAIIはnewとdeleteの代替として、オブジェクトをそのスコープから独立して存在させることができます。このテクニックは、ヒープ上に割り当てられたオブジェクトへのポインタをハンドル/マネージャオブジェクトに入れることから成ります。後者はデストラクタを持ち、そのオブジェクトの破棄を担当します。これにより、そのオブジェクトはそれにアクセスしたい任意の関数から利用可能であり、ハンドルオブジェクトの生存期間の終わりに、明示的なクリーンアップなしに破棄されることが保証されます。
C++標準ライブラリからのRAIIを使用している例としては、std::stringとstd::vectorがあります。
このコードを考えてみましょう:
void fn(const std::string& str)
{
std::vector<char> vec;
for (auto c : str)
vec.push_back(c);
// do something
}
vectorを作成し、要素をvectorにpushするとき、あなたはこのような要素のメモリの割り当てと解放について心配する必要がありません。vectorはnewを使ってヒープ上の要素のために領域を割り当て、deleteを使ってその領域を解放します。vectorのユーザーとして、あなたは実装の詳細を気にする必要がなく、vectorがリークしないことを信頼します。この場合、vectorはその要素のハンドルオブジェクトです。
標準ライブラリでRAIIを使っている他の例としては、std::shared_ptr、std::unique_ptr、std::lock_guardがあります。
このテクニックの別の名前はSBRMで、スコープ束縛リソース管理(Scope-Bound Resource Management)の略です。
さて、上記のファイル読み込みの例を次のように修正します:
#include <iostream>
#include <vector>
#include <cstring>
#include <fstream>
#include <bits/unique_ptr.h>
using namespace std;
unique_ptr<vector<string>> read_lines_from_file(string &file_name) {
unique_ptr<vector<string>> lines(new vector<string>);
string line;
ifstream file_handle (file_name.c_str());
while (file_handle.good() && !file_handle.eof() && file_handle.peek()!=EOF) {
getline(file_handle, line);
lines->push_back(line);
}
file_handle.close();
return lines;
}
int main(int argc, char* argv[]) {
// get file name from the first argument
string file_name (argv[1]);
int count = read_lines_from_file(file_name).get()->size();
cout << "File " << file_name << " contains " << count << " lines.";
return 0;
}
4.最後になって初めて、あなたはRAIIの真の力に気づくのです。¶
コンパイラが発明されて以来、手動メモリ管理はプログラマがなんとか避けようとしてきた悪夢です。RAIIは有望なパターンですが、奇妙な回避策なしにはヒープ割り当てのオブジェクトにはまったく使えないため、C++では苦しい立場に置かれました。そのため、90年代にはプログラマの生活をより快適にすることを目指すゴミ収集言語の爆発的な増加が起きました。たとえ性能を犠牲にしてでもです。
最後に、RAIIを次のようにまとめます:
-
リソースはデストラクタの中で解放される
-
そのクラスのインスタンスはスタック割り当てされる
-
リソースはコンストラクタの中で取得される。
RAIIは「リソース取得は初期化である(Resource Acquisition Is Initialization)」を表します。
よくある例は次のとおりです:
-
ファイル操作
-
スマートポインタ
-
ミューテックス
5.参考記事¶
1. https://www.toptal.com/software/eliminating-garbage-collector#remote-developer-job
関連トピック¶
- C++ デフォルトコンストラクタの回避 | C++那些事
- C++ クラス初期化リストイディオム | C++那些事
- C++ 関数テンプレートよりクラステンプレート | C++那些事
- C++ copy-and-swap イディオム | C++那些事
- C++ 依存性注入 | C++那些事
- C++ enum class 強い型付けされた列挙 | C++那些事
- C++ Header-Only と実装分離 | C++那些事
- C++ 遅延評価イディオム | C++那些事
- C++ ルックアップテーブル法イディオム | C++那些事
- C++ メンバポインタと型情報 | C++那些事