GPT-2の論文中に出てくるBloom filterの解説
OpenAIが出したGPT-2の論文中でtrainデータセットとtestデータセットの間で重複したデータが無いか確かめるためにBloom filterを使ったと述べられている。 Bloom filterは与えられた集合に、ある要素が含まれるかを判別するデータ構造である。要素が無いと判別された場合は確実に存在しない(false negativeは存在しない)が、要素が有ると判別された場合に本当に存在するかは確定しない(false positiveが存在する)。このように確率的な動作をするが、メモリ使用量や計算量オーダーに優れている。
データを挿入する場合は、k個のハッシュ関数を使ってデータをk個の整数にする。フラグ配列を用意し、これらの整数をインデックスとしたk個の要素にフラグを立てる。 データが存在するかの確認は同様にデータをk個のハッシュ関数を使ってk個の整数にして、フラグ配列の対応する要素のフラグが全て立っているかどうかを確認する。全てのフラグが立っている場合は集合にデータが存在する可能性があり、立っていないフラグがある場合は集合には確実にそのデータが存在しない。 false positiveの確率はkと整数のbit数、集合の要素数に依存する。
なぜ変数の微分に小さな値を掛けたものを変数から引くと損失が減るのか
とすると、
はxを極少量増加させたときのyの増加量を表す(増加量が負になることもありえることに注意)。なので、yが損失だった場合にyを最小化するためにはxからを引けばyを減少させることができる。 ここでは変数xのyへの影響度の大きさと考えることができる。影響度が小さいものよりも、影響度が大きいものを大きく動かしたほうが速くyが小さくなると素朴に考えることができる。 なのでだけ引けばうまくyを減少させることが期待できる。
ResidualNNメモ
ILSVRC2015 classification taskで1位だった手法.開発はMicrosoft Researchの人.
[1512.03385] Deep Residual Learning for Image Recognition
NNの層数を増やすと精度はサチった後どんどん低下する.これはテストエラーが増加する(オーバーフィッティング)だけでなく,トレーニングエラーも増加する(これはオーバーフィッティングでは説明できない). これは変な話である.浅い学習済みのNNの後ろに恒等変換する層を足せば同じ精度でより深いNNが得られるはずなのに,実際の学習では同じ精度どころか悪くなっていくのだ. そこで,明示的に恒等変換の層を加えて結果を足してやるというのがResidualNNのアイデア.
レイヤの構造それ自体は単純で,層数を増やして深くしても精度が悪くならなくなったため,マシンパワーさえあれば適当な設計でも良い精度が出せるようになった. 逆に言えばうまくチューニングすればより少ない層数で精度を高めることはできるようだ(cifar10はもっと精度の高い浅いモデルが存在する.CIFAR-10 Competition Winners: Interviews with Dr. Ben Graham, Phil Culliton, & Zygmunt Zając | no free hunch).
Boost.Computeでグラボを燃やす
はじめに
この記事ではBoost.Computeの紹介と使い方の説明をします。
Boost.Computeとは
kylelutz氏が作成しているOpenCLのC++ラッパです。Boostにはまだ正式に採用されていませんが、主要な部分の実装は既に終わっており、十分使えるレベルに達しています。
とりあえず使用例
#include <vector> #include <algorithm> #include <boost/compute/algorithm/transform.hpp> #include <boost/compute/container/vector.hpp> #include <boost/compute/functional/math.hpp> namespace compute = boost::compute; int main() { // デフォルトのdeviceを取得して、contextとcommand_queueを作成 compute::device device = compute::system::default_device(); compute::context context(device); compute::command_queue queue(context, device); // hostでランダムな値を持つvectorを作成 std::vector<float> host_vector(10000); std::generate(host_vector.begin(), host_vector.end(), rand); // deviceに領域を確保 compute::vector<float> device_vector(host_vector.size(), context); // hostからdeviceにデータをコピー compute::copy( host_vector.begin(), host_vector.end(), device_vector.begin(), queue ); // 各要素の平方根をdevice上で並列に求める compute::transform( device_vector.begin(), device_vector.end(), device_vector.begin(), // in-place compute::sqrt<float>(), queue ); // deviceからhostへデータをコピー compute::copy( device_vector.begin(), device_vector.end(), host_vector.begin(), queue ); }
返り値を確認するようなエラー処理やリソース管理をする必要がないため、そのままOpenCLを使うよりはるかに簡単です。
この例ではtransformを使いましたが、その他のSTLアルゴリズムに対応するアルゴリズム(accumulate、sort、find、max_elementなど)も用意されています。
OpenCLカーネルのカスタマイズ
用意されたアルゴリズムを組み合わせるだけでも様々な計算ができますが、アルゴリズムにカーネルを指定する必要がある場合や、アルゴリズムのデフォルトの挙動を変えたい場合、自分で書いたカーネルを直接使いたい場合もあるでしょう。
そうした場合のためにBoost.Computeではカーネルをカスタマイズする方法が複数用意されています。
ラムダ式を使う
ここで言うラムダ式とはC++11で言語機能として導入されたものではなく、Boost.Phoenixなどで用いられている式テンプレートによるものを指します。
次のように使います。
using boost::compute::lambda::_1; using boost::compute::lambda::_2; using boost::compute::lambda::sqrt; using boost::compute::lambda::exp; boost::compute::transform( input1.begin(), input1.end(), input2.begin(), input1.begin(), sqrt(_1)*_2+42 // ラムダ式 ); auto num = boost::compute::count_if( input1.begin(), input1.end(), exp(_1) <= 666 // ラムダ式 );
余談ですが、内部実装としては式テンプレートからOpenCLカーネルコードを動的に生成しています。
アルゴリズム用のカーネルを書く
ちょっとしたカーネルなら簡単に書くこともできます。
BOOST_COMPUTE_FUNCTION(int, add_four, (int x), { return x + 4; // OpenCLコード }); boost::compute::transform( input.begin(), input.end(), output.begin(), add_four );
カーネルを書いてcommand_queueで実行する
より複雑なカーネルを実行したい場合は、OpenCLの作法に従って自分で書いたカーネルを実行することもできます。
const char source[] = BOOST_COMPUTE_STRINGIZE_SOURCE( // OpenCLカーネルコード __kernel void foo(int k, __global int *x, __global int *y) { x[get_global_id(0)] = -k*y[get_global_id(0)]; } ); auto foo_kernel = boost::compute::kernel::create_with_source(source, "foo", context); boost::compute::vector<int> x(16, context); boost::compute::vector<int> y(16, context); ... // xの初期化(省略) foo_kernel.set_args(42, x, y); queue.enqueue_task(foo);
この例ではcommand_queue::enqueue_taskを使いましたが、多次元データに対してはcommand_queue::enqueue_nd_range_kernelなどもあります。詳しくはBoost.Computeのドキュメントを参照してください。
使用上の注意点
hostからdeviceメモリに直接アクセスしない
boost::compute::vectorなどにはoperator[]演算子が定義されており、添字を指定して要素に直接アクセスすることができますが、時間がかかる上に自分で同期を取らなければならないため止めましょう。
代わりに、copyアルゴリズムなどで範囲ごとに一遍にアクセスするようにします。
In-order実行する
command_queueはデフォルトではIn-order実行するように指定されます。In-order実行であれば同じcommand_queueに入れられたカーネルは入れられた順番に実行され、前のカーネルが終了した後に後のカーネルが実行されます。そのため、後に入れたカーネルが前に入れたカーネルの実行結果に依存していてもデータ競合を起こしません。
一方、command_queueのオプションでOut-of-order実行を指定するとカーネルの実行がcommand_queueに入れられた順番と関係なく実行されるようになります。これは高度な計算の最適化のためには必要なことですが、プログラムの設計が著しく難しくなります。
簡単のために、なるべくIn-order実行することを強くおすすめします。以降、この記事ではIn-order実行を前提にします。
In-order実行で複数のカーネルを並列に実行したい場合は、それ以前の処理が終わるのをwait()やfinish()で待ってから、カーネルごとにqueueを用意してそれぞれで実行し、wait()やfinish()で並列に実行している全てのカーネルの処理が終わるのを同期します。
// kernelAは依存なし // kernelB1はkernelAの結果に依存 // kernelB2はkernelAの結果に依存 // kernelCはkernelB1とkernelB2の結果に依存 // 各カーネルに引数は既にセットされていると仮定する auto queue = boost::compute::system::default_queue(); auto queue2(queue.get_context(), queue.get_device()); auto kernelA_event = queue.enqueue_nd_range_kernel( kernelA, .../*略*/... ); // kernelB1とkernelB2は並列に実行できる。 // ここでkernelAが終了するのを待つ必要はない。同じqueueなのでIn-order実行される。 auto kernelB1_event = queue.enqueue_nd_range_kernel( kerlenB1, .../*略*/... ); kernelA_event.wait(); // ここでkernelAが終了するのを待つ必要がある。 auto kernelB2_event = queue2.enqueue_nd_range_kernel( kernelB2, .../*略*/... ); kernelB1_event.wait(); // kernelB1が終了するのを待つ kernelB2_event.wait(); // kernelB2が終了するのを待つ queue.enqueue_nd_range_kernel(kernelC, .../*略*/...);
一方で、OpenCLコードのコンパイラを信頼して、全部順番に処理するように書くのも手です。
もしコンパイラが十分賢ければ、kernelB1とkernelB2には依存性がないことを見ぬいて、並列に効率よく実行するようにしてくれるかもしれません(保証はありませんが)。
// kernelAは依存なし // kernelB1はkernelAの結果に依存 // kernelB2はkernelAの結果に依存 // kernelCはkernelB1とkernelB2の結果に依存 // 各カーネルに引数は既にセットされていると仮定する auto queue = boost::compute::system::default_queue(); queue.enqueue_nd_range_kernel(kernelA, .../*略*/...); // kernelB1とkernelB2は並列に実行できるが、あえてそうしない。 queue.enqueue_nd_range_kernel(kerlenB1, .../*略*/...); queue.enqueue_nd_range_kernel(kernelB2, .../*略*/...); queue.enqueue_nd_range_kernel(kernelC, .../*略*/...);
最初の実装ではこのように全部順番に処理するように書くことをおすすめします。シンプルで分かりやすく、ミスしにくいためです。
後で性能に問題があれば、深刻な部分から順に明示的に並列化すればいいのです。 ただし、明示的に並列化することでコンパイラによる最適化が阻害される恐れもあるため、明示的に並列化した後は再度計測を行うべきでしょう。
コピーは特別
アルゴリズムの中でもコピーは特別です。hostからdevice、deviceからhostへのコピーはhost-device間で同期されるからです(逆に言うと他のアルゴリズムは基本的に非同期に実行される(ただし、コピーと同じく条件によっては同期実行されるものもある。accumulateとか))。
// 同期バージョンのコピー。コピーが終わるまで処理が進まない。 boost::compute::copy( host_vec.begin(), host_vec.end(), device_vec.begin(), queue ); ... // 何かhost上での処理(前にあるコピーが終了するまで実行されない)
データ量やメモリ帯域の制限によってコピーには時間がかかることがあるため、非同期にコピーしたい場合があります。
そのような場合にはcopy_asyncを使います。
// 非同期バージョンのコピー。コピーの終了を待たずに直ちに処理が返ってくる。 auto copy_event = boost::compute::copy_async( host_vec.begin(), host_vec.end(), device_vec.begin(), queue ); ... // 何かhost上での処理(この裏ではdeviceへのコピーが実行されている……) ... // host_vecにアクセスしないように注意。 copy_event.wait(); // eventをwait()して同期する。 // これ以降はhost_vecにアクセスしても大丈夫
なるべく明示的にcommand_queueを指定する
用意されているアルゴリズムでは実はcommand_queueを指定する必要は必ずしもありません。指定しなかった場合はboost::compute::system::default_queue()が代わりに指定されます。
また、boost::compute::vectorの要素数を指定するコンストラクタもcontextを指定しなかった場合は、boost::compute::system::default_context()が自動で用いられるため省略できます。
そのため、冒頭のコード例は次のように書けます。
#include <vector> #include <algorithm> #include <boost/compute/algorithm/transform.hpp> #include <boost/compute/container/vector.hpp> #include <boost/compute/functional/math.hpp> namespace compute = boost::compute; int main() { // hostでランダムな値を持つvectorを作成 std::vector<float> host_vector(10000); std::generate(host_vector.begin(), host_vector.end(), rand); // deviceに領域を確保 compute::vector<float> device_vector(host_vector.size()); // hostからdeviceにデータをコピー compute::copy( host_vector.begin(), host_vector.end(), device_vector.begin() ); // 各要素の平方根をdevice上で並列に求める compute::transform( device_vector.begin(), device_vector.end(), device_vector.begin(), // in-place compute::sqrt<float>() ); // deviceからhostへデータをコピー compute::copy( device_vector.begin(), device_vector.end(), host_vector.begin() ); }
こちらのほうがシンプルで分かりやすいですが、後で明示的に並列実行させたくなった場合や複数デバイスに対応させようとした場合に修正しにくいです。
また、後述するboost::compute::vectorのコピーの話もあるため、なるべく明示的にcommand_queueを指定することをおすすめします。
挙動がおかしい場合
OpenCLのプログラムを書いていて、同じパラメータを与えているはずなのに計算結果が毎回変わったり、エラーが出てプログラムの実行に失敗する場合は、データ競合を起こしている可能性が高いです。
ただし、同じプログラムでも異なるdeviceで走らせた場合(例えば、GPUとCPUで走らせた場合)に浮動小数点の扱いが異なったりするため、計算結果が異なることはよくあります。これはバグではありません。
OpenCLコードのデバッグは通常困難ですが、いくつか僕が採用している指針を示します。
カーネルがデータ競合していないかチェックする
自分で書いたカーネルそれ自体がデータ競合を引き起こしているかもしれません。
特にカーネルのソースコードのメモリに書き込んでいる部分の添字を見て、デバイス上の異なるプロセッサが同時に同じメモリにアクセスしていないかチェックしましょう。
異なるcommand_queueに順番に実行したい処理を入れてしまっていないかチェックする
先程も説明したとおり、異なるcommand_queueに入れられたカーネルが実行される順番は定まりません(deviceがひとつしかなくてもdevice上のプロセッサは複数あるため並列に実行される可能性がある)。
順番に実行したいなら同じcommand_queueに入れるか、明示的にeventをwait()するか、command_queueをfinish()しましょう。
特に、boost::compute::vectorをコピーしようとして次のようなコードを書いてしまうとアウトです。
// 悪い例 some_kernel.set_arg(device_vec1); boost::compute::system::default_queue().enqueue_nd_range_kernel( some_kernel, .../*省略*/... ); auto device_vec2(device_vec1); // データ競合が発生
boost::compute::vectorのコピーコンストラクタはboost::compute::system::default_queue()ではなくインスタンスごとに独立して内部に持っているcommand_queueを使用するため、カーネルの実行が終わる前にデータがコピーされてしまいます。
// 良くない例 some_kernel.set_arg(device_vec1); boost::compute::system::default_queue().enqueue_nd_range_kernel( some_kernel, .../*省略*/... ); auto device_vec2(device_vec1.begin(), device_vec1.end()); // OK
今度はboost::compute::system::default_queue()が使用されるため、問題ありません。
しかし、どのようにすればdefault_queue()が使われるのか、いつ独自のcommand_queueが使われるのかなどということをいちいち気にしていたら埒が明きません。
やはり、先程も説明したとおり明示的にcommand_queueを指定するようにしましょう。
// 良い例(おすすめ) some_kernel.set_arg(device_vec1); boost::compute::system::default_queue().enqueue_nd_range_kernel( some_kernel, .../*省略*/... ); auto device_vec2( device_vec1.begin(), device_vec1.end(), boost::compute::system::default_queue() // command_queueを明示的に指定。もちろんOK );
もしくは明示的に同期を取りましょう。
// 良い例 some_kernel.set_arg(device_vec1); boost::compute::system::default_queue().enqueue_nd_range_kernel( some_kernel, .../*省略*/... ); boost::compute::system::default_queue().finish(); // 明示的に同期 auto device_vec2(device_vec1); // データ競合は発生しない。
前者の明示的にcommand_queueを指定する方法をおすすめします。うっかり忘れた場合でも間違いを発見しやすく、効率的に実行される可能性がより高いためです。
同じプログラムを複数回実行して値が変わらないかチェックする
データ競合が起きている場合は、計算結果が実行するたびに異なることが多いです。そのため、複数回実行することでデータ競合を検出できます。
ただし、deviceによってはデータ競合があっても見た目上はきちんと動作する場合があります。このことを考えれば、複数の異なるdeviceでチェックするのが望ましいでしょう。
問題のある部分を切り分ける
これはデバッグする上での基本的な手法ですが、使用できるツールが限られているため、OpenCLプログラミングでは特に重要になります。
おわりに
今回は書きませんでしたが、Boost.Computeには他にもOpenGLやOpenCVと組み合わせるための機能や任意のユーザ型をOpenCLでデータ型として扱えるようにする機能、float4などのベクトルデータ型を扱う機能などもあります。
Kylelutz氏のブログやBoost.Computeのexampleに実際のそうしたコード例があるので、参考にしてみてください。
Boost.Computeを使えばGPUを含むOpenCLに対応したあらゆるデバイスを簡単に燃やすことができるので、大量のデータを一律に処理したい場合に使ってみては如何でしょうか。
「unique_ptrにポインタ以外のものを持たせるとき」で話したことと話さなかったこと
歌舞伎座.tech#8「C++初心者会」で「unique_ptrにポインタ以外のものを持たせるとき」というタイトルで発表したが、この記事ではそのまとめと、追加の話題について述べる。
発表資料はここにある。
TL;DR
std::unique_ptrはハンドルがポインタライクな型のリソースでしか使用できない。それに対して、C++標準委員会のペーパーN4189で提案されているunique_resourceを使えば、ポインタ以外のハンドルで扱われ、排他的に所有されるリソースを自動管理できる。
実際に使用できるunique_resourceの実装はここにある。
unique_resourceは普通、カスタムデリータを設定して使用するが、デリータオブジェクトのコピーと実行が強い保証を満たしていない場合は例外安全性を担保するのが困難、もしくはほぼ不可能である。
デリータ型がムーブ代入不可能の場合(ラムダであるなど)はunique_resourceはムーブ代入不可能になる。
「unique_ptrにポインタ以外のものを持たせるとき」で話したこと
動的にメモリ確保したオブジェクトは最終的に解放する必要がある。
auto* widget = new Widget(); // .... // widgetを使う何らかの処理 // .... delete widget; // きちんと解放する
解放しなかった場合はメモリリークとなり問題が発生する恐れがある。簡単なコードでは解放忘れしないように注意すれば済むかもしれないが、コードが複雑になるにつれて解放処理を行うべきコードパスの把握は指数的に困難になる。
きちんと然るべきタイミングでリソースが解放されるよう処理を自動化できれば便利だ。std::unique_ptrはまさにそのためのものである。
auto* widget = std::unique_ptr<Widget>(new Widget()); // .... // widgetを使う何らかの処理 // .... // スコープの終わりでwidgetは自動で解放される。
話が少し逸れるが、C++14ではunique_ptrのファクトリ関数であるmake_unique関数が標準ライブラリに入ったため、C++14において上例の用法ではnewのような忌まわしいキーワードを打ち込む必要はない。
auto widget = std::make_unique_ptr<Widget>(); // newなし // .... // widgetを使う何らかの処理 // .... // やはりスコープの終わりでwidgetは自動で解放される。
さて、std::unique_ptrではメモリリソースの解放時に呼び出される関数(デリータ)を設定することができる。
// Widgetのファクトリ関数 decltype(auto) make_widget() { auto deleter = [](Widget* wp) { std::cout << "deleter is called" << std::endl; delete wp;// きちんとdeleteしておく }; // デリータを設定する場合はmake_unique関数は使えない return std::unique_ptr<Widget, decltype(deleter)>( new Widget(), std::move(deleter)); } int main() { auto wp = make_widget(); }
出力
deleter is called
このようなユーザによるカスタムデリータはメモリの解放以外に後処理を行わなければならない場合に用いられることが想定されているが、ここでちょっとした思いつきが出てくる。デリータがカスタマイズできるなら、メモリリソース以外のリソースの管理にも使えるのではないだろうか?
namespace others /*他人のAPI*/ { int GetHandle() {/*略*/} // リソースハンドルはint型 void ReleaseHandle(int handle) {/*略*/} } decltype(auto) make_unique_handle() { auto deleter = [](int handle) { others::ReleaseHandle(handle); // deleteの代わりに解放関数を呼ぶ }; return std::unique_ptr<int, decltype(deleter)>( others::GetHandle(), std::move(deleter)); } int main() { auto h = make_unique_handle(); }
上例のコードはコンパイルできない。GetHandle関数の返り値型がポインタではないというようなエラーが出る(int*を渡すべきところを単にintを渡しているだけなので当たり前の話だが)。
実はunique_ptrでラップする型はデリータのメンバ型pointerをカスタマイズすることで設定することができる。
namespace others {/*略*/} // ラムダではメンバ型が定義できないので、昔ながらの関数オブジェクトクラスを使う struct deleter { using pointer = int; // デフォルトではint*になるのを、intと指定 void operator()(int handle) { others::ReleaseHandle(handle); } }; decltype(auto) make_unique_handle() { return std::unique_ptr<int, deleter>( others::GetHandle(), deleter{}); } int main() { auto h = make_unique_handle(); }
これで問題ないように思えるが、やはり上例もコンパイルできない。intとnullptrの比較ができないというようなエラーが出る。 unique_ptr用デリータのメンバ型pointerはnullptr_t型(nullptrの型)と比較可能であることが要件として定められているのだ。
ここで詰んでしまう。どうやってもintとnullptrの比較関数を定義できない。組み込み型とnullptrの比較は規格で禁止されているためである。
An operator function shall either be a non-static member function or be a non-member function that has at least one parameter whose type is a class, a reference to a class, an enumeration, or a reference to an enumeration. --N4527 13.5 6
リソースハンドルが組み込み型でない場合は比較関数を定義することは可能であるが、そもそもリソースハンドルをnullptrと比較することにどういう意味があるのだろう。
だめだ。認めるしかない。std::unique_ptrはポインタをハンドルとするリソースにしか使えないのである。 ポインタでないハンドルで扱うリソースをunique_ptrのように自動で管理するためには、自分で型を書くしかないのだろうか。
class unique_handle { void safe_release() { if(handle_ == others::NullHandle) { return; } others::ReleaseHandle(handle_); handle_ = others::NullHandle; } int handle_; public: unique_handle() : handle_(others::GetHandle()) {} unique_handle(unique_handle&& rhs) : handle_(others::NullHandle) { std::swap(handle_, rhs.handle_); } decltype(auto) operator=(unique_handle&& rhs) { safe_release(); std::swap(handle_, rhs.handle_); return *this; } ~unique_handle() { safe_release(); } decltype(auto) get() const { return handle_; } };
しかし自分でリソースごとにハンドル管理クラスを書くのは面倒な上に、以下の事柄に注意しなければならない。
実際にこれらの点に注意して独自のリソース管理クラスをかちっと書くのも悪くないだろうが、面倒なのは確かである。
少し整理しよう。今、我々に必要なのは、次に挙げる特徴を備えたリソース管理クラスである。
- 任意の型のリソースハンドルに使える
- スコープを抜けたら自動で指定したデリータを実行する
- ムーブできる
- 標準ライブラリ並に信頼できる
そんな都合のいいライブラリがどこかにないのだろうか。実は、あるのだ。
N4189 Generic Scope Guard RAII Wrapper for the Standard Library 「unique_resource」である。
unique_reourceとはC++標準化委員会のペーパーで次期C++標準ライブラリに加えることを提案されているライブラリであり、unique_ptrの一般化である。
unique_resourceを使えば先のリソース管理クラスは次のように書ける。
namespace others {/*略*/} decltype(auto) make_unique_handle() { return make_unique_resource( others::GetHandle(), &others::ReleaseHandle); }
至極簡単に書ける。そして次のように使える。
int main() { auto h1 = make_unique_handle(); auto h2 = make_unique_handle(); auto h3 = make_unique_handle(); h2 = std::move(h3); // move可能 h2の元々のハンドルはリリース auto h4 = make_unique_handle(); std::cout << h4.get() << std::endl; // 生のハンドルにアクセス h4.reset(); // 明示的にハンドルをリリース }
素晴らしい。
歌舞伎座での発表時は、unique_resourceはN4189にC++14の実装例があるため、そこからコピペすれば今すぐ使うことができるというふうに紹介したが、ライセンスの微妙な問題があった。そこでN4189の提起者であり、実装例コードの作成者であるPeter Sommerlad氏とAndrew L.Sandoval氏に連絡を取ったところ、Boost Software License 1.0で公開する許可を快くいただけたため、ここで公開する。GCCのためのワークアラウンドを追加し、C++11でも動作するようにコードの部分的な書き換え等も行っているので、直接コピペしたものより使いやすいだろう。
「unique_ptrにポインタ以外のものを持たせるとき」で話さなかったこと
デリータを指定するときの例外安全性
unique_ptrやunique_resourceのようなリソース管理クラスはRAIIラッパと呼ばれる。リソース漏れを防ぐために、RAIIラッパを使うというのは正当なやり方であるが、それだけでは不十分である。ただ使うだけではなく、正しく使わねばならない。
次の例ではunique_ptrにデリータとして、デリータオブジェクトdeleterを渡している。
auto w = std::unique_ptr(new Widget(), deleter);
実を言うと、std::unique_ptrを使っているにもかかわらず、このコード片を見ただけではリソース漏れがないとは言い切れない。デリータ型のコピーコンストラクタが例外を発生させる恐れがあるためである。
C++では関数呼び出しにおける引数の評価順は定められていない。言語処理系の実装者が自由に決めて良いのだ。
When a function is called, each parameter (8.3.5) shall be initialized (8.5, 12.8, 12.1) with its corresponding argument. [ Note: Such initializations are indeterminately sequenced with respect to each other (1.9) — end note ] --N4527 5.2.2 4
仮に、以下のような順番で評価されたとしよう。
2でエラーが発生した場合、unique_ptrオブジェクトは生成されず、newによって生成されたWidgetオブジェクトはユーザの手の届かない場所へ消えていく。これは紛うことなきメモリリソース漏れである。
ではデリータ型のコピーコンストラクタが強い保証を備えていればよいのかと問われれば、話はそう単純ではない。 次の例ではdeleterをムーブしている。
auto w = std::unique_ptr(new Widget(), std::move(deleter));
上例の場合は、moveによりムーブコンストラクタが呼び出されるならばムーブコンストラクタが強い保証を備えていることが望ましい。 他にも次のようにコンストラクタを呼び出して一時オブジェクトを渡す場合も考えてみよう。
auto w = std::unique_ptr(new Widget(), deleter_t());
上例の場合は、deleter_t型のコピーコンストラクタが(ムーブによるコピーが発生するならムーブコンストラクタが)強い保証を備えていることが望ましいことに加え、deleter_t型のコンストラクタ(今回はデフォルトコンストラクタ)呼び出しで例外が発生する恐れもあるため、deleter_t型のコンストラクタも強い保証を備えていることが望ましい。
これらが示すように、特定の関数が強い保証を備えていればよいという話ではないのである。指定の仕方にも関わってくるのだ。unique_ptrでデリータを指定する場合には例外が一切発生しないことが望ましい。例外が発生する恐れがある場合は、リソース漏れを防ぐために例外に対処する煩わしいtry-catchの処理を書かなければならないだろう。
unique_resourceでも同じ問題がある。 unique_ptrと違い、unique_ptrではリソースハンドルが必ずしも組み込み型(ポインタ型)でないためにハンドルのコンストラクタやコピーコンストラクタが例外を発生させる恐れもある(一方、組み込み型のコピーはエラーを発生させることはない)が、その場合はまだハンドルオブジェクトの生存期間が始まっていないため、std::unique_resourceの例外安全性とは関係がない(ハンドル型の問題である)。しかし、unique_ptrではデリータの設定が任意であるのに対して、unique_resourceでは必須であるため、やはりデリータのコピーコンストラクタの例外安全性には常に十分注意しなければならない。
デリータの実行の例外安全性
一般に、デストラクタで例外が発生しないことが保証できない場合、プログラムを例外安全に動作させることはほぼ不可能である。 ところで、unique_resourceのデリータはデストラクタで実行される。 ゆえにunique_resourceのデリータの実行は強い保証を備えているべきである。
実際に、unique_ptrのデリータの要件として、その実行が例外を発生させないことが要求されている。
デリータとしてラムダを用いた場合の制限
ラムダによって生成されたクロージャオブジェクトはコピー代入演算子がdeleteされていることが規格で定められている。
The closure type associated with a lambda-expression has no default constructor and a deleted copy assignment operator. It has a defaulted copy constructor and a defaulted move constructor (12.8). [ Note: These special member functions are implicitly defined as usual, and might therefore be defined as deleted. — endnote ] --N4527 5.1.2 20
コピー代入演算子がdeleteされていることにより、コンパイラによるムーブ代入演算子の暗黙の生成が抑制される。unique_resourceオブジェクトのムーブ代入はデリータのムーブ代入を含むため、デリータがラムダによって生成されたクロージャオブジェクトだった場合、unique_resourceオブジェクトのムーブ代入を行うコードはコンパイルできない(一方、ラムダによって生成されたクロージャオブジェクトのムーブコンストラクタはコンパイラにより暗黙に生成されるため、デリータがラムダによって生成されたクロージャオブジェクトであるunique_resourceオブジェクトのムーブコンストラクトは可能である)。
decltype(auto) make_unique_handle_lambda() { return unique_resource(others::GenHandle(), [](auto const& handle){ others::ReleaseHandle(handle); }); } decltype(auto) make_unique_handle_funptr() { return unique_resource(others::GenHandle(), &others::ReleaseHandle); } int main() { { auto h1 = make_unique_handle_lambda(); auto h2 = std::move(h1); // move construct OK h1 = std::move(h2); // move assignment NG } { auto h1 = make_unique_handle_funptr(); auto h2 = std::move(h1); // move construct OK h1 = std::move(h2); // move assignment OK } }
現在clangではデリータがラムダによって生成されたクロージャオブジェクトであるunique_resourceオブジェクトのムーブ代入を行うコードのコンパイルが可能であるバグがある。うっかり規格に準拠していないコードのコンパイルが通ってしまう恐れがあるため、コンパイルにclangを使っている、かつ、デリータにラムダを用いる場合は、GCCなど別のコンパイラでコードをダブルチェックするなどして注意すべきであろう。
個人的には、デリータにはラムダを用いるのではなく、関数ポインタか、自分で書いた関数オブジェクトを用いるのが良いと思う。
デリータとしてstd::functionを用いる場合の注意点
リソースの種類は同じでも、異なるデリータで後処理したい場合がある。しかしunique_resourceのデリータ型はunique_resourceの型の一部なので、デリータの型が異なればunique_resourceの実体化した型は異なる。そこで、std::functionをデリータの型として設定し、同じunique_resourceの実体化した型でありながら、オブジェクトごとに異なるデリータで処理できるようにすることを考える。
当然、先に挙げたデリータの設定の例外安全性は考慮する必要がある。特にstd::functionの場合は、std::functionのムーブコンストラクトがnoexceptでないため、デリータの設定でstd::functionオブジェクトをムーブするのは避けたほうがよいだろう。
コピーコンストラクトについては内部に保持している関数オブジェクトの動的なコピーが発生するため、その関数オブジェクトのコピーコンストラクタは強い保証を備えていることが望ましい。動的なコピーなので、std::bad_alloc例外が発生する恐れもあるが、どちらにせよstd::bad_alloc例外が発生するような状況でできることはあまりないため、それについてはそこまで気にする必要はないだろう。
std::function型でない関数オブジェクトでstd::functionをコンストラクトする場合は、やはりその関数オブジェクトの動的なコピーが発生するため、std::functionをコピーコンストラクトする場合と同様の注意が必要である。ただし、関数ポインタやstd::reference_wrapperでラップされた関数オブジェクトでコンストラクトする場合は、例外が発生しないことが保証されていることは特筆すべきだろう。
{ auto deleter = [](auto handle){ others::ReleaseHandle(handle); }; // deleterはコンパイラによって生成されたラムダ型 auto h1 = unique_resource<handle_t, std::function<void(handle_t)>>(others::GetHandle(), deleter); // 安全。このデリータはコピーコンストラクトで強い保証を備えており、このデリータでstd::functionをコンストラクトするのは安全(メモリが十分あれば)。 auto h2 = unique_resource<handle_t, std::function<void(handle_t)>>(others::GetHandle(), std::move(deleter)); // 安全。このデリータはムーブコンストラクトとコピーコンストラクトで強い保証を備えており、このデリータでstd::functionをコンストラクトするのは安全(メモリが十分あれば)。 } { std::function<void(handle_t)> deleter = [](auto handle){ others::ReleaseHandle(handle); }; // deleterはstd::function型 auto h1 = unique_resource<handle_t, std::function<void(handle_t)>>(others::GetHandle(), deleter); // 安全。このデリータはコピーコンストラクトで強い保証を備えており、このデリータでstd::functionをコピーコンストラクトするのは安全(メモリが十分あれば)。 auto h2 = unique_resource<handle_t, std::function<void(handle_t)>>(others::GetHandle(), std::move(deleter)); // 危険。std::functionのムーブコンストラクタ呼び出しは例外を発生させるかもしれない。 } { auto h = unique_resource<handle_t, std::function<void(handle_t)>>(others::GetHandle(), &others::ReleaseHandle); // 安全。関数ポインタによるコンストラクト。 } deleter_t unsafe_copy_ctor_deleter; // コピーコンストラクタで例外が発生する恐れのある関数オブジェクトのデリータ { auto h = unique_resource<handle_t, std::function<void(handle_t)>>(others::GetHandle(), unsafe_copy_ctor_deleter); // 危険。デリータのコピーで例外が発生する恐れがある。 auto h = unique_resource<handle_t, std::function<void(handle_t)>>(others::GetHandle(), std::reference_wrapper(unsafe_copy_ctor_deleter)); // 安全。std::reference_wrapperによるコンストラクト。 }
まとめ
- ハンドルがポインタでない、排他的に所有されるリソースの自動管理のためにunique_resourceが提案されている。
- unique_resourceはC++11/14で使うことができる。
- unique_resourceのデリータの指定では例外が一切発生しないことが望ましい。
- unique_resourceのデリータの実行は強い保証を備えているべきである。
- デリータにラムダを用いた場合、unique_resourceはムーブ代入できなくなる。
- デリータにstd::functionを用いる場合もデリータ指定時の例外安全性については注意する必要がある。