この記事は C++ Advent Calendar 2018 の23日目の記事です。
そういえば、今日は天皇誕生日ですね。Wikipediaによると来年は天皇誕生日のない年となるになるそうです。(来年の12月23日が休日になるかどうか特に情報が見つからないのがちと残念。)
さて、こんにちは。Qiitaに投稿するのはお久しぶりの Chironian です。
最近、業務で制御系のプログラム(FFmpeg APIを使った録画)をC++で書いているのですが、C++の良さを実感しています。その思いを表現したくて、ちょっとアオリ気味なタイトルにしてしまいました。
1.借りたら返す
「借りたら返す」は人として重要なことですね。でも、返すために必要な作業が結構あるので面倒ですよね。誰から借りていているのか?いつまでに返さないといけないのか?など返し忘れないようにちゃんと覚えている必要がありますし、間違って失くしたり汚したりしないようにきちんと管理する必要もあります。正直面倒なのでついつい買っちゃうことも。
リアル世界では面倒でも頑張るしか無いです。でないと本当に「人として」問題になりますから。
しかし、コンピュータの世界ではそうでもないです。OSや処理系側で貸し出したことを覚えていて、更に使い終わったことを検出して自動的に回収する仕組みがあります。そうガベージ・コレクション(GC)です。借りたメモリの返し方をいちいち「設計」しないで良いのでGCのある言語でのメモリ管理は実にありがたいです。
し・か・し、しかしです。GCに回収を任せて良いリソースは実は事実上メモリだけです。メモリは潤沢にあるので多少返すのが遅れても問題になりにくく、GCがちんたら回収するのに任せることができます。しかし、オープンしたファイルのハンドルやウィンドウ・ハンドル、各種ライブラリで獲得するリソース、などなど非常に多くのリソースは使い終わったら直ぐに返さないと次の人が使えず、プログラムが落ちることさえあります。
メモリを除くほとんどのリソースがこれに該当するのではないでしょうか?
そこで、我らがC++の出番です。このようなリソースは「いつ返すのか」きちんと設計し、そのタイミングで返却するようプログラムする必要があります。
C言語の時代にはリソース返却を慎重に慎重に慎重に設計しないと直ぐに返却が漏れてしまい本当に苦労しました。リーク場所を見つける苦労はなかなかつらいものです。そして、C++の時代になり、対象のリソースをコンストラクタで獲得し、デストラクタで開放し、かつ、newやdeleteを使わなければ実にうまいことリソース・リークしなくなります。設計段階でリークを激減できるのです。
2.リソース(というかロック)獲得・開放の例
私が一番ありがたみを感じるのは、マルチ・スレッド・プログラムするときのミューテックスの獲得・開放です。ミューテックスもロックを獲得したら開放しないと他の人がミューテックスが保護する資源をアクセスできません。
そして、一般にちょっとだけロックを獲得し直ぐに開放しますが、開放忘れが「稀に」発生すると地獄です。再現性のないデッドロックとなり、嫌なものです。
そんな時に、便利なのがstd::lock_guardクラス・テンプレートです。
これは、ミューテックスをコンストラクタで受け取り、そのままミューテックスのlock()関数を呼びます。そしてデストラクタでミューテックスのunlock()関数を呼びます。つまり、std::lock_guardが生きている区間はミューテックスで保護されているのです。
保護期間内で例外が発生してもちゃんと開放されるスグレモノです。(これはC++のクラスの特性です。)
template<typename T>
class SafeQueue : public std::queue<T>
{
std::mutex mMutex;
public:
// スレッド・セーフなエンキュー
void enqueu(T const& iElem)
{
std::lock_guard<std::mutex> aLock(mMutex); // ここでlock()
push(iElem); // ここは保護されている(例外を投げてもOK~)
} // ここでunlock()
// スレッド・セーフなデキュー
bool dequeu(T& oElem)
{
std::lock_guard<std::mutex> aLock(mMutex); // ここでlock()
// ここから
if (size() == 0)
return false; // ここでもunlock()
oElem = front(iElem);
pop();
return true; // ここまで保護される
} // ここでunlock()
};
2箇所でunlock()していますが、どちらもaLockオブジェクトが開放されるタイミングで呼ばれるものですから、多重開放される心配はありませんし、return文から戻り始めたところで開放されるので適切に保護されます。
もしも、return true;を保護から外したい場合は次のように書いても良いです。
// スレッド・セーフなデンキュー
bool dequeu(T& oElem)
{
{
std::lock_guard<std::mutex> aLock(mMutex); // ここでlock()
// ここから
if (size() == 0)
return false; // ここでもunlock()
oElem = front(iElem);
pop(); // ここまで保護される
} // ここでunlock()
return true;
}
3.多重開放の落とし穴
リソースを管理しているクラスは、デストラクタでリソースを自動的に開放するので楽ですね。でも、コピーすると多重開放が発生します。コピーできないようにすることが強く推奨されます。(ムーブやswapはOKです。しかし、コピーだけはだめなのです。)
例えば、std::unique_ptrチックなクラスを普通に書くと次のように書いてしまうことが少なくないでしょう。
(メモリへのポインタではなく、ファイル・ハンドルやTCP/IPコネクション・ハンドル等のリソース・ハンドルでもほぼ同様です。リソース・ハンドルは無効値(例えばINVALID_HANDLE_VALUE)が定義されていることが多いので、それをnullptrと読み替えて下さい。)
template<typename T>
class ScopedPointer
{
T* mPointer;
pulibc:
ScopedPointer(T* iPointer = nullptr) : mPointer(iPointer) { }
~ScopedPointer() { delete mPointer; }
T* get() { return mPointer; }
};
しかし、リソースを管理するタイプのクラスは上記では不味いです。
C++は常に気を利かせてくれるので、コピー・コンストラクタとコピー代入演算子が自動的に定義されます。(これはこれで非常にありがたい親切なのですが、デストラクタでリソース開放するようなクラスではちと痛いです。)
int main()
{
ScopedPointer<int> a(new int(123)), b;
b=a; // コピー代入演算子でa::mPointerのアドレス値がb::mPointerへコピーされる。
std::cout << *(b.get()) << std::endl;
} // ここで、bとaが開放される!!
a, bどちらとものデストラクタでdelete mPointer;
が呼ばれます。
a, b共に最初のnew int(123)
で獲得したポインタ値をmPointerで管理(a, b共に同じ値が入っています)していますから、同じ領域をそれぞれが開放するので多重開放となり、運が良ければ異常終了します。運が悪いと何ごともなく終了するので不具合を見落とし、それが潜在バグとなって現場で再現性のない不具合としてお客さんに迷惑をかけるのがオチです。
このような問題を防ぐため下記のどちらかを行うのが常道です。
3-1. コピーできないようにする
コピー・コンストラクタとコピー代入演算子をとムーブ・コンストラクタとムーブ代入演算子をdeleteするのがてっとり速いです。
何故にムーブもdeleteするかといいますと、コピーが未定義だとムーブが自動定義されますが、それは単に各メンバ変数をムーブするだけのものです。ポインタにはムーブが定義されていないため単純にコピーされます。せっかくコピーを禁止したのにムーブでコピーされちゃうのです。なので、ムーブもdeleteする必要があります。
template<typename T>
class ScopedPointer
{
T* mPointer;
pulibc:
ScopedPointer(T* iPointer = nullptr) : mPointer(iPointer) { }
~ScopedPointer() { delete mPointer; }
T* get() { return mPointer; }
ScopedPointer(T const&) = delete;
ScopedPointer& operator(T const&) = delete;
};
3-2. ムーブできるようにする
ムーブ・コンストラクタとムーブ代入演算子を定義するわけです。ムーブを定義するとコピーは自動生成されなくなりますので、コピー側をdeleteする必要はないです。
コピーとの相違点は単にデストラクタでリソース開放しないようにするだけです。今回の例の場合、ムーブ時にムーブ元のmPointerをnullptrにするだけでOKです。delete mPointer;してもmPointerがnullptrですからdeleteは何もしません。無事、多重開放の罠を回避できます。
template<typename T>
class ScopedPointer
{
T* mPointer;
pulibc:
ScopedPointer(T* iPointer = nullptr) : mPointer(iPointer) { }
~ScopedPointer() { delete mPointer; }
T* get() { return mPointer; }
ScopedPointer(T&& iRhs) : mPointer(iRhs.mPointer)
{
mPointer = nullptr;
}
ScopedPointer& operator(T&& iRhs)
{
// 自分自身がムーブ代入されても問題を起こさないようにする
if (this != &iRhs)
{
delete mPointer;
mPointer = iRhs.mPointer;
iRhs.mPointer = nullptr;
}
return *this;
}
};
3-3.ちなみに、std::lock_guardはコピー/ムーブ禁止
ムーブできるようにしてもバチは当たらないような気もしますが、ムーブすると保護範囲がかなり分かりにくくなるのでバグを生みそうな気がします。必要性も少ないので非対応なのだろうと思います。
4.std::scope_exitが欲しいけど無いから作る
2014年にN4189で提案されたものにscope_exitがあります。C++17で採用されると期待していたのですが、採用されなかったようです。非常に残念です。ですが、実装は簡単なので自分で実装すればOKです。
2014-10-pre-Urbana mailingsのレビュー: N4183-N4189
N4189 - Generic Scope Guard and RAII Wrapper for the Standard Library
冒頭で書いたようにFFmpeg APIを使った録画アプリを開発していて感じたのですが、scope_exit便利です。実にありがたいです。
また、現場で問題が発生した時のログを解析する時、特定の件数の実行が正常に終わったのか、そうでないのかログに残したい時が多々あります。このような時もscope_exitが便利です。
scope_exitの考え方は極簡単です。デストラクタで実行する関数を「その場」で指定するだけです。
何が嬉しいかというと、何か初期化したらその終了処理を「その場」で登録できることです。これで開放漏れが激減します。
4-1.scope_exitの実装例
C++17ならクラス・テンプレートのテンプレート引数推論があるのでmake_scope_exitを作らなくて済みます。楽なのでC++17対応前提で書きます。
フールプルーフを省略した骨子のみの実装を、簡単なログ出力を例に書いてみます。(サンプルは標準出力へ出していますが、通常は何かログへの出力操作となります。)
4-1-1.例外で終了する例
#include <iostream>
// ----- ScopeExitの骨子のみ実装
template<typename tFunc>
class ScopeExit
{
tFunc mFunc;
public:
explicit ScopeExit(tFunc&& iFunc) : mFunc(std::move(iFunc))
{ }
~ScopeExit()
{
mFunc();
}
};
// ----- end of ScopExit
void doSomething()
{
std::cout << "start doSomething()" << std::endl;
auto aScopeExit = ScopeExit([]() { std::cout << "return from doSomething()" << std::endl; });
std::cout << "do something\n";
throw 123; // エラー発生で例外を投げる!!
std::cout << "does not excute\n"; // エラー発生時に実行するべきでない処理
}
int main()
{
try
{
doSomething();
}
catch (int& e)
{
std::cout << "catch(): " << e << std::endl;
}
}
4-1-2.エラーを返却する例
returnが返却する戻り値を見たいこともよくあります。
#include <iostream>
// ScopeExitの定義は4-1-1と同じなので省略
bool doSomething()
{
bool ret = false;
std::cout << "start doSomething()" << std::endl;
auto aScopeExit = ScopeExit([&ret]() { std::cout << "return from doSomething(): ret = " << ret << std::endl; });
std::cout << "do something\n";
return ret; // エラー終了する
std::cout << "does not excute\n"; // エラー発生時に実行するべきでない処理
ret = true; // ちょっと有縁ですが結果を残せます
return ret;
}
int main()
{
return doSomething();
}
4-1-3.FFmpeg API使用時の例(抜粋)
例えば、FFmpeg APIを使う場合、このリンク先の解説のようにいくつかのリソースを獲得し、最後に開放する必要があります。このリンク先のサンプルは分かりやすさのために事実上エラー処理していませんが、実際の場面では対処が必要です。
例えば、次のように書けます。(一部のみ抜粋しました。コメントの付いた行は私が追記したものです。)
// (前略)
int main_(int argc, char* argv[])
{
av_register_all();
const char* input_path = "hoge.mov";
AVFormatContext* format_context = nullptr;
if (avformat_open_input(&format_context, input_path, nullptr, nullptr) != 0) {
printf("avformat_open_input failed\n");
return 1; // 追加したエラー処理
}
// format_contextを借りたので直ぐに返却処理を登録する
auto aScopeExit0 = ScopeExit([&format_context](){ avformat_close_input(&format_context); })
// (中略)
AVCodecContext* codec_context = avcodec_alloc_context3(codec);
if (codec_context == nullptr) {
printf("avcodec_alloc_context3 failed\n");
return 1; // ここでformat_contextが開放されます
}
// codec_contextを借りたので直ぐに返却処理を登録する
auto aScopeExit1 = ScopeExit([&codec_context](){ avcodec_free_context(&codec_context); })
if (avcodec_parameters_to_context(codec_context, video_stream->codecpar) < 0) {
printf("avcodec_parameters_to_context failed\n");
return 1; // ここでcodec_contextとformat_contextが適切な順序で開放されます
}
// (中略)
// 以下の2行は不要になります。(aScopeExit0, 1により開放されますので。)
//avcodec_free_context(&codec_context);
//avformat_close_input(&format_context);
return 0;
}
普通は、main()関数ではなく、デコード関数の中で上記のような処理を書くでしょう。その場合、main()でtry-catchしておいて、return 1;
の代わりにthrow std::runtime_error("エラーメッセージ");
などしてもリークしません。
例えば、C#からFFmpeg API を呼び出そうとすると、上記の各ScopeExitでusingするか、returnを諦めて必ず例外を投げるようにしてfinallyブロックで開放処理を書くことになります。前者はそもそもusingできるようにする手間が結構かかります。後者は各変数はtryブロックの外で確保する必要があり無駄にスコープが広がります。
そんな面倒さから開放される C++ バンザイ!!です。
4-2.注意点
わかり易さのため、上述のScopeExitは骨子だけですので、インスタンスをコピーできてしまいます。間違ってコピーしてしまうと、3.で述べたように多重開放に至ります。
aScopeExit0 や aScopeExit1 のようなインスタンスをコピーすることはまずないとは思いますが、コピー/ムーブを禁止するか、ムーブのみ可能なようにしておいた方がより安全です。
コピーやムーブのコンストラクタ、コピーやムーブ演算子については、第34回目 6個のスペシャルなメンバ関数にて詳しく解説していますので、よろしかったら参考にされて下さい。
なお、N4189で提案されているコードではコピー禁止/ムーブ許可になっています。
さいごに
例えばC#で上述したような確実なりソース開放処理を書くのは結構手間がかかります。Disposeを実装したクラスを作ってusingするか、try-finallyで処理することになります。結構面倒なのです。
C#はスコープを外れた時にコンストラクタを実行してくれるわけではありません。GCが回収を担うためです。「スコープを外れたらコンストラクタ実行オプション」があれば良いのにとも思いますが、GCとの共存が難しそうな気がします。
つまり、GUIはC#で作ると楽ですが、制御系のプログラムは C++ 最強!!ですね。
実は私が使っているPCは7年前のものです。i7-3820と16GBytesメモリ搭載です。未だに十分使えているのですが、流石に故障も怖いし、そろそろ更新しようと調べているのですが、う~~んPCの性能が上がっていません。7年前と同程度の価格帯だと性能が倍くらいしか出ないのです。どうもムーアの法則が終焉を迎えつつあるようです。
これが何を意味するかといいますと、今までハードウェアの「力で押し切っていた」部分が通用しなくなり、ソフトウェアで工夫して高速化する需要が増えるということです。
つまり、C++の需要がこれから更に伸びそうです。
C++は学習難易度が高いですがプログラマに要求される注意力を大きく減らす能力を持っています。これは信頼性と生産性が上がることを意味します。つまり、よりC++を使いこなせる人はより信頼性が高いプログラムをより短時間で開発できるというわけです。その分ペイも増えないと可笑しいですね。そして、きちんとそれを評価し適切な対価を支払って頂けるクライアントさんもいらっしゃいます。