この記事は初心者 C++er Advent Calendar 2015の21日目の記事です.
昨日はshunonymousさんの「 C++でプログラムの動作を一定時間止める」でした.
まるで初心者向けの記事を書けないかのような言われようですが1,氏の期待通り初心者向けの記事を書いたつもりです.
初心者の皆さんがこの記事を読んで初心者から一歩脱してくだされば幸いです.
また,マサカリは大歓迎なのでバンバン投げてください.
#あじぇんだ
C++において多用されるRAIIという技法の考え方とメリット・デメリットについて,同様の目的で用いられることのあるGCとの対比も交えつつ紹介していきます.
ちなみに私はGCとかJavaとかよく知らない初心者なので,大嘘を書く可能性があります.もし誤った記述を見かけた際には是非コメントを下さい.
初心者向けです.
考え方自体はC++98でさえも通用しますが,コードはC++11で書いていきます.
#前提知識 : スコープ
さて,RAIIについて語る前に前提知識として変数のスコープについての知識が必要ですので,簡単に触れておきたいと思います.
ここでいうスコープとは,変数の生存範囲のことを指します.
int main(){
std::cout << "ここはまだなにもない" << std::endl;
{
int a = 0;
std::cout << "ここで変数aが登場" << std::endl;
std::cout << a << std::endl;
}//!Here!
std::cout << "もうaはいない" << std::endl;
//std::cout << a << std::endl; //Error!!
}
上のコードで,7行目の//!Here!
の部分に}
がありますが,このようにC++の局所変数2(この場合はint a
です)は{
と}
の間(ブロックと呼びます)で定義されたとき,}
を越えた際に破棄されます.
この定義されてから}
までが変数の生存範囲・スコープであり,該当する変数を使うことが出来るのはこの範囲内のみです.
これはブロックの手前にwhile(cond)
3やif(cond)
などの制御構文が来ても同様です.
また関数の中身を記述する{
と}
の間でもスコープは存在しますので,関数内で定義された局所変数は関数の呼び出しが終了した(return
が呼ばれた,例外送出で関数を脱した,などの)ときに破棄されます4.
#RAIIとは
RAIIとはResource Acquisition Is Initializationの略で,リソースの確保(Acquisition)と解放を変数の初期化(Initialization)と破棄に紐付けるという考え方を指す言葉です.
##例 : std::unique_ptr
と言われて,何を言っているのかさっぱり分からない,という方も多いと思いますので,1つ例を挙げましょう.
13日目にてやましろさんが紹介なさっているスマートポインタです.
#include<memory>
int main(){
std::cout << "ここはまだなにもない" << std::endl;
{
std::unique_ptr<int> ptr = new int(3);
std::cout << "ここでメモリを確保かつ変数を初期化" << std::endl;
std::cout << *ptr << std::endl;
}//!Here!
std::cout << "ptrはもう存在しない" << std::endl;
}
スコープの例と殆ど何も変わりませんね.
6行目ではnew
を用いてメモリというリソースの確保を行い,同時にstd::unique_ptr<int> ptr
の初期化を行っています.
そして,9行目の//!Here!
の}
を越える際にブロック内のptr
が破棄されることになります.
しかし,ptr
はスマートポインタですので,ここで自動的にdelete ptr.get()
,即ちメモリというリソースの解放が実行されます.
と,このようにリソースの確保と変数の初期化,リソースの解放と変数の破棄を紐付けるのがRAIIです.
ちなみに,unique_ptrを使わずに書くとこんな感じ.
int main(){
std::cout << "ここはまだなにもない" << std::endl;
{
int* ptr = new int(3);
std::cout << "ここでメモリを確保かつ変数を初期化" << std::endl;
std::cout << *ptr << std::endl;
delete ptr;//メモリを解放
}//ptrを破棄
std::cout << "ptrはもう存在しない" << std::endl;
}
このように,メモリの解放処理を手で書く必要があります.
忘れたらメモリリークです.面倒くさいですね.
##何故RAIIが重要なのか
- メリットがたくさんあって
- C++との親和性が高く
- 結果標準ライブラリでも多用されている
からです.綺麗なC++コードを書くためにはRAIIの理解が必須だと思います.
どちらにせよ,標準ライブラリで多用されているので学ぶ価値はあると思います.
RAIIのメリット
###GCとの比較
一般にメモリを自動で解放する,と聞くとGC(Garbage Collection)を思い浮かべる方も多いと思いますが,RAIIはGCと比較して優れた点がいくつかあります.
####解放タイミングが明確
GCは不要になったオブジェクト(とそのメモリ領域)を自動で判断して解放しますが,これがいつ実行されるかはGCのみが知っていることで,我々が知ることは出来ません.
また,不要になった瞬間に解放したい,とかこのタイミングで解放したい,といった小回りが効きません.
一方,RAIIのリソース解放タイミングは「スコープを抜けたとき」と明確です.コードのどこで解放されるのかが我々でも分かります.
またRAIIでは手で解放処理を明記しないというだけでしかなく,スコープの終了位置によってある程度解放処理のタイミングをこちらで決めることが出来ます.
####どんなリソースでも同じ方法で正しく解放出来る
GCはメモリの解放は出来るかもしれませんが,より広範なリソースへの対応に関して,RAIIには一歩及ばないと言えるでしょう.
Javaでループ内で生成した同一ファイルへのFileOutputStream
のclose()
を忘れると例外を吐きます.
JavaのFileOutputStream
はGCで回収されたときに自動でclose()
が呼ばれる作りになっています5が,GCで回収された直後ではなく2回ほどたらい回しされた末に呼ばれるため解放が間に合わず,まだそのファイルを開いているハンドルが残っているうちに再度開こうとしてしまうことが原因です6.
また,件のJavaの自動解放は解放順序が規定されていないという大変問題な作りになっています.
つまり,リソースAに依存するリソースB,という構図でリソースBの解放が優先されない可能性があるのです.
C++のスコープは,定義した順番とは逆順に変数を破棄していきます.
従って,リソースAを生成し,次にAを使ってリソースBを作った場合,RAIIを使えば確実にリソースBが先に解放されます.
挙げ句の果てに,件の自動解放はプログラム終了時点で必ず解放されるという保証は無いとのこと7.
もちろんRAIIはスコープを脱したときに必ず解放します.それこそ,プログラムの終了時には静的変数8だろうと大域変数9だろうと生成とは逆順に破棄します10ので,リソースを確実に解放します.11
このように開放タイミングが不明確なリソース管理は,リソースリークを助長します.
「今解放されなくてもいずれ解放されればいいじゃん」と思うかもしれませんが,昨今のモバイル環境全盛時代,バッテリーの消費を削減するためにもバッテリーセーバーの抑制は必要最低限の時間かつ出来る限り自動で行うべきです.
バッテリーセーバーの抑制を悠長にやったり,うっかりセーバーの起動を忘れたりすると電力消費の増大を招きます12.
無駄にバッテリーの消費が激しいアプリは良くないですよね?
そして何よりRAIIが素晴らしいのは,リソースの確保と解放を統一的に扱える点です.
RAIIを使ったとき,オブジェクトを構築し,スコープによってそれを破棄する,ただそれだけを考えればよいのです.
###それ以外のメリット : 例外安全
さて,冒頭のスコープの説明に以下の様な記述をしました.
関数内で定義された局所変数は関数の呼び出しが終了した(
return
が呼ばれた,例外送出で関数を脱した,などの)ときに破棄されます
つまり,
void f(){
int* ptr = new int(42);
call_throwable_function(); //この関数内で例外が送出されたとすると自動的に例外はこの関数の外に再送出される
delete ptr; //結果このdeleteは実行されない,リソースリークが発生
}
のような問題も,
void f(){
std::unique_ptr<int> ptr = new int(42);
call_throwable_function(); //この関数内で例外が送出されたとすると自動的に例外はこの関数の外に再送出される
}//例外送出だろうとなんだろうとスコープを抜ければptrは破棄されるのでリソースリークは発生しない
のようにRAIIを使えば起こりえないということ.これにより,例外安全13なコードの記述が(使わない場合と比べれば)いともたやすく行えるようになったのです.
ちなみに,これをRAII無しで防ぐためには以下のようになります.
#include<stdexcept>
void f(){
int* ptr = new int(42);
try{
call_throwable_function(); //この関数内で例外が送出されたとすると
}catch(...){
delete ptr; //ここでptrが指す先のメモリ領域を開放してから
throw; //例外再送出
}
delete ptr; //例外が発生しなかった際にはこちらのdeleteが実行される
}
当然リソース確保/解放が増えてくればコードはより複雑化していきます.RAIIは簡潔なコードと確実な動作を両立するのです.
###RAIIのデメリット
デメリットというよりはRAIIが不向きな場合,と言ったほうが正しいかもしれません.
RAIIはデストラクタを用いてリソース解放を自動で行う性質上,解放に失敗してもそれを知る術が非常に限定的になります14.
特にその失敗が例外送出という形で通知される場合,やっかいな問題となります.
例えばRAIIによって管理されたファイルハンドルのcloseに失敗し,例外を送出したとしましょう.これが発生したのはデストラクタです.
C++において,デストラクタで例外を投げるのは基本的にご法度となっています.
理由ですが,まずそのデストラクタの処理が例外を投げたところから先全てすっ飛ばされるため,リソースリークを起こしかねない15こと.
次に,例外送出中のスコープ脱出によって呼ばれたデストラクタ内でさらに例外を送出した場合16,プログラムは終了処理もすっ飛ばして即落ちします.当然リソースリークです.
前者はどうしようもないですし,後者についてもこれを防ぐためにはデストラクタを以下のようにするしかありません.
class class_name{
public:
~class_name(){
try{
close(); //例外を発生しうる解放処理
}catch(...){
//なにかするとさらなる例外を生む可能性があるのでなにもしない
}
}
};
これによってリソースリーク自体は防げますが,万一close
に失敗したとしてもその情報を得る手段は実質的に皆無14ですから,解放処理の失敗をトラップしたい場合にはどうしようもなく使いにくいということが分かるかと思います.
こういった場合には,例えば標準ライブラリのstd::fstream
などにはclose()
メンバ関数があるので事前に呼び出すことで例外が発生した際に処理を行うことが可能です.
###RAIIのメリットまとめ
0. どんなリソースでも対応するオブジェクトを作るだけ
- 解放処理を手で書かなくても勝手に解放してくれる
- 解放のタイミングが明確
- 例外送出時もちゃんと対応
安全なコードを簡単に書ける,これほど素晴らしいことはありません!
##RAIIは何故C++との親和性が高いのか
RAIIを実現するためには,「オブジェクトの破棄のタイミングで呼び出される処理」というものが必要になります.そこでリソースの解放処理をするからです.
C++には,デストラクタというまさしくそれそのものな存在があるため,RAIIの実現が容易なのです.
また,個人的にはC++の変数の扱いが「値のセマンティクス17」であることは非常に重要な点だと考えています.
値のセマンティクスであるということは,オブジェクトとリソースを完全に1対1対応させることが可能だということです.
オブジェクトをコピーしたらリソースもコピーする,といったようなクラス設計が考えられます18.
もしこれが「参照のセマンティクス19」であった場合,結局「リソースオブジェクトのハンドル」でしかないため,オブジェクトとリソースの対応づけ,という考え方があまり浸透しなかったのではないかなぁ,と思います20.
##C++標準ライブラリにおけるRAII
###スマートポインタ
<memory>
ヘッダに定義されている,std::shared_ptr
やstd::unique_ptr
など.
従来はmalloc
/free
やnew
/delete
といった確保と解放をセットで操作しなければならなかった動的メモリ確保ですが,スマートポインタの登場によって解放が自動化されます.
###std::string
そもそもC言語における文字列とは「ヌル文字終端のメモリ上に連続した文字型の値の列」であり,ものすごく大雑把に言えばchar
の配列(とか)です.
実際には文字列長が可変であることを考えると,固定長配列ではなく実行時に動的に長さを変更できる作りにする必要があります.そう,メモリの動的確保です.
というわけで,<string>
ヘッダに定義されているstd::string
はfind
やappend
,empty
などの便利なメンバ関数ももちろんですが,そもそも可変長の文字列のためのメモリ管理を完全に手放しで行えるクラスなのです.
###STLコンテナ
std::vector
などのSTLコンテナは要素を格納するためのメモリを動的に確保し,またコンテナ自体を破棄する際にコンテナの要素となっているオブジェクトをそれぞれ破棄,最後にメモリを解放する,という作りになっています.
また,std::string
やSTLコンテナ
はみな,C++の値のセマンティクスに沿った挙動をします.つまり,それぞれのオブジェクトのコピーを取ると必要なだけのメモリが別に確保され,そこに値がコピーされます.
リソースとオブジェクトの1対1の対応が取れていて,個人的には非常に分かりやすいと思います.
###std::fstream
std::fstream
は仮にファイルをオープンしたままの場合,自動でclose
メンバ関数を呼ぶためファイルの閉じ忘れの心配がありません.ファイルそのもの化のように扱うことが出来ます.
ただし,先述の通りclose
は例外を発生する可能性があるので,正しくハンドリングしたい場合には手動でcloseをしてその結果を確かめる必要があります.
###std::lock_guard
初心者向きではない気もしますが,std::lock_guard
というクラスもRAIIを用いています21.
これは,並行処理の際に複数のスレッドが同時に同じメモリ領域にアクセスしないよう22にロックという処理を行い単一のスレッドのみがアクセスできるようにするのですが,これもまたlock
をしたらunlock
をせねばならないのです.RAIIを使うことで,unlock
忘れを防止できます.
#まとめ
多くの場合デメリットが無いし,便利なのでみんなもRAIIとRAIIを使ったライブラリ(標準ライブラリ含む)を使おう.
というわけで,明日は22日目,yumechiさんです.
-
クソザコなどと自称してconstexpr-Lambdaライブラリを作った話を発表したりするとこういったあらぬ誤解を受けるので気をつけましょう(要するに自業自得です) ↩
-
ローカル変数とも.関数内で
static
などを用いずに宣言された変数だと考えてください. ↩ -
while(cond){...}
の場合は,cond
を評価→trueだったら{...}
を実行→cond
を評価…となるので,各ループ間(例えば,1回目の{...}
と2回目の{...}
同士)で局所変数は共有できません.ループ毎にブロック内で定義された局所変数は破棄と生成が繰り返されます. ↩ -
関数の外から関数内の変数にはアクセス出来ないのも関数内にスコープが存在するからです. ↩
-
らしいです.finalizeメソッドなる機構で実現されているとか. ↩
-
これも解放タイミングが不定なことが問題の根幹にあります. ↩
-
プログラム終了時にGCが走らないため,オブジェクトに紐付いたリソースが解放されない…ということらしい.一般的なOSであれば,アプリケーション終了の際にメモリ自体は開放される. ↩
-
static
が付いてる変数 ↩ -
関数外・クラス外で宣言されてる変数 ↩
-
ただし,翻訳単位を跨いだ静的変数の初期化順序は未定義なのでうまいことやらないとおかしくなります. ↩
-
一応補足しておくと,Javaも2011年の規格であるJava 7でtry-with-resources構文なるものに対応したことで,RAIIのように
try
ブロックを抜けたところで自動でclose()
をすることが出来るようになっているみたいです.ただ,複数のリソースを同時に定義しない場合(具体的にはjava.sql.PreparedStatement
とjava.sql.ResultSet
とか)はオブジェクトの定義毎にネストが深くなっていく(PreparedStatement.setHoge
を呼び出そうとするとResultSet
の定義がPreparedStatement
の定義と同時には行えませんので,別のtry
ブロックに記述する必要があります)ので,残念ながらC++におけるRAIIほど読みやすくはならないかなぁ,というのが個人的な見解です.ただし,close処理の際に発生した例外を(try
ブロック・catch
ブロックどちらの例外も含めて)正しく処理出来る点は好感が持てます. ↩ -
電力バグ(ebugs)などと呼ばれています. ↩
-
例外が発生した際にプログラムの動作に影響を及ぼさないこと…みたいな概念.例外が発生しても変数の値が例外発生前と変わらないとか,例外が発生しても自動でリカバリして動作を続けるとか.ここでは,例外によってリソースリークが生じないこと. ↩
-
例えば戻り値チェックやデストラクタ内部でのtry-catchを使うことで失敗を検出し,それを外部の変数に突っ込むことで後から成否を確認すること自体は可能です.とても良い設計とは思えませんが… ↩ ↩2
-
例えば2つのリソースを扱っていた場合,先に解放するリソースの解放時に例外が発生すると後で解放するつもりだったリソースは解放されないままとなります(例外安全のサンプルコードを思い出してください) ↩
-
例外発生中にさらに例外を発生させることをdouble faultなどと言い,これに陥った場合C++では
std::terminate
関数を呼ぶことになっています.こいつはプログラムのプロセスを後処理など考えずにその場で殺す関数です. ↩ -
変数の概念が,文字列型
String
に対して,String a, b = "fuga"; a = b; b = "hoge";
とした時にa == "fuga"
が成り立つようなものであること.つまり,a
とb
は別個の変数であり,それぞれがメモリを持ち,値の代入はコピーである,という扱い. ↩ -
後述しますが,
std::string
やSTLのコンテナはこうなっています. ↩ -
変数の概念が,文字列型
String
に対して,String a, b = "fuga"; a = b; b = "hoge";
とした時にa == "hoge"
が成り立つようなものであること.つまり,a
とb
は特定のオブジェクトに対する参照でしかなく,値の代入は参照先の切り替えである,という扱い.JavaのObject
はこっち.C++だとポインタやstd::shared_ptr
などはこちらに近い. ↩ -
std::shared_ptr
なんかは見事にこの考え方に反した存在ですが,あれはあくまでポインタのラッパーなので…(言い訳) ↩ -
Lockをリソースと考えれば,Scoped Locking PatternもRAIIの一種です. ↩
-
タイミングによって結果がおかしくなる可能性があるためです.よく言われる例え話だと,AさんとBさんが同じ銀行口座を使っていて,Aさんが入金,Bさんが引き落とし,を同時に行った結果Aさんの入金分の額がどこかにふっとんだ(Bさんが引き落とした結果の額が口座に書き込まれた)…といったようなことが起こりえます. ↩