RのC APIを使う時、当然ですが任意の操作は失敗する可能性があります。
このために、RのコードでいうtryCatch()
にあたるものがいくつか提供されています。
https://cran.r-project.org/doc/manuals/r-release/R-exts.html#Condition-handling-and-cleanup-code
この中で、もっともオーバーヘッドが少ないのが R_UnwindProtect()
であり、cpp11 でも extendr でも使われているものです。
しかし、これを使うにはけっこう難しい話を理解する必要があります。
何がまずいのか
そもそも、C++やRustの中でRのエラーを呼び出すことはundefined behaviorにつながると言われています。 Rのエラーはlongjmpで実装されているので、unwind時にC++のdestructorが呼び出されないからです。
RustではDrop
traitというdestructorの仕組みがありますが、これも同じ問題を抱えることになります。
Rustの場合はRustonomiconのFFIの章などに情報がまとまっています。
https://doc.rust-lang.org/nomicon/ffi.html?search=#ffi-and-unwinding https://doc.rust-lang.org/nomicon/unwinding.html
こうした問題を乗り切るために、 R_UnwindProtect()
が提供されているのです。
R_UnwindProtect()
の使い方
R_UnwindProtect()
はシグネチャは以下です。
SEXP R_UnwindProtect(SEXP (*fun)(void *data), void *data, void (*clean)(void *data, Rboolean jump), void *cdata, SEXP cont);
fun(data)
実行時にエラーが起こったとき、longjmpの前にまずclean(cdata, TRUE)
が呼び出されるので、そのclean()
の中にC++(やRust)側の片づけをする処理を仕込んでおける、という寸法です。
ちなみにclean()
はエラーが起こらなくても最後に呼び出されます。clean(cdata, FALSE)
という感じで、第二引数jump
に入る値で区別できるようになっています。
ここで、片づけ処理がclean()
の中だけで完結するなら問題ないのですが、いったん別の場所に抜け出す必要がある場合があります。
C++のexceptionを投げてそれを catch して処理するようなケースです。というか、今想定しているのは「ここでexceptionを投げてC++のunwindを発生させたい(C++のdestructorがちゃんと呼ばれるようにしたい)」というのがこの仕組みを使う理由なので、こっちのケースになります。
その場合、C++側の片づけが終わった後でR_ContinueUnwind(cont)
を呼び出すとRの側の処理が再開され、そして問題ない状態でRのエラーが投げられます。
具体的にこれをどう使うのか、例としてcpp11のコードを見てみましょう。
static SEXP token = [] { SEXP res = R_MakeUnwindCont(); R_PreserveObject(res); return res; }(); std::jmp_buf jmpbuf; if (setjmp(jmpbuf)) { should_unwind_protect = TRUE; throw unwind_exception(token); } SEXP res = R_UnwindProtect( [](void* data) -> SEXP { auto callback = static_cast<decltype(&code)>(data); return static_cast<Fun&&>(*callback)(); }, &code, [](void* jmpbuf, Rboolean jump) { if (jump == TRUE) { // We need to first jump back into the C++ stacks because you can't safely // throw exceptions from C stack frames. longjmp(*static_cast<std::jmp_buf*>(jmpbuf), 1); } }, &jmpbuf, token);
token
は、後でR_ContinueUnwind()
に渡してRの側の処理を再開するためのものです。
残りの行は、ちょっと複雑ですが、やっていることはclean(cdata, TREU)
の中でC++のexceptionを投げる、ということです。
コメントにあるように、その際、(WREはここまでは想定してなさそうですが、)Cのstack frameからC++のexceptionを投げるのはよくないので、まずはC++のstack frameに戻る、ということをしています。
setjmp(jmpbuf)
は、通常時はその位置を記憶し、0を返します。longjmpで戻ってきた場合は1を返します。なので、初回実行時はif (setjmp(jmpbuf)) { ... }
の中の処理は実行されず、clean()
の中のlongjmp(*static_cast<std::jmp_buf*>(jmpbuf), 1);
から飛んできた時だけ実行されます。
ここで投げられた unwind_exception
は以下でcatchされます。
そして、errorの中に含まれるtoken
を取り出してR_ContinueUnwind()
が呼び出されています。
Rust の場合どうする?
RustにはC++のようなエラー処理の仕組みはありません。それはRustのコードを書いている限りにおいては問題ないのですが、
今回のような「実行中のclean()
の中から脱出したい」というケースにおいては無力です。
clean()
が終わるとR_UnwindProtect()
の中でR_ContinueUnwind(cont)
が呼び出されてしまうので、その前にRust側のunwindを済ませておく必要があるのですが、
Rustの通常のエラー処理ではここを抜け出すことができません。
そこで、あまり推奨されない方法ではありますが、panic!()
を起こしてそれをcatch_unwind()
でcatchする、という手を使うしかなさそうです。
推奨されない、というのは、公式ドキュメントにこう書かれているからです。
It is not recommended to use this function for a general try/catch mechanism. (https://doc.rust-lang.org/std/panic/fn.catch_unwind.html)
ちなみに、tokenを渡す必要があるので、文字列しか使えないpanic!()
ではなくpanic_any()
を使う必要があるみたいです。
もうひとつ悩ましい点は、Rustにはsetjmp/longjmpがない、ということです。 軽く調べた感じ、setjmpはマクロとして定義されているのでRustから直接呼び出せるものではない、などの事情があるようです。 なので、cpp11がやっていたような「一度Rustのstack frameに戻る」ということはどうやらできず、Cのstack frameから直接panicする必要があるみたいです。 これが安全なことなのかまだよくわかっていません。
extendrは?
extendrもおおむねこんな感じでやってるぽいんですが、R_ContinueUnwind()
を呼んでないけどいいんだっけ?、というのが気になってるとこです。
実際の実装
これからやります...。たぶん...