RustからRのC APIを使う時、エラーをちゃんとハンドリングしたい

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);

https://github.com/r-lib/cpp11/blob/3c36f7f48a4998c0cd0abb2fc964b24393eafe21/inst/include/cpp11/protect.hpp#L90-L115

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()が呼び出されています。

https://github.com/r-lib/cpp11/blob/3c36f7f48a4998c0cd0abb2fc964b24393eafe21/inst/include/cpp11/declarations.hpp#LL38C20-L38C20

Rust の場合どうする?

RustにはC++のようなエラー処理の仕組みはありません。それはRustのコードを書いている限りにおいては問題ないのですが、 今回のような「実行中のclean()の中から脱出したい」というケースにおいては無力です。 clean()が終わるとR_UnwindProtect()の中でR_ContinueUnwind(cont)が呼び出されてしまうので、その前にRust側のunwindを済ませておく必要があるのですが、 Rustの通常のエラー処理ではここを抜け出すことができません。

https://github.com/r-devel/r-svn/blob/e29a22b8faf9c329ad4aafc2a0823666d43d1d84/src/main/context.c#L972-L973

そこで、あまり推奨されない方法ではありますが、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()を呼んでないけどいいんだっけ?、というのが気になってるとこです。

実際の実装

これからやります...。たぶん...