はじめに
タイトルの通りです。このことに関する日本語の文献がほぼ存在しないため、自分の頭の整理とメモのために書き残します。英語の場合は公式リファレンスマニュアルを読んでください。
Passing a closure to a C function
この記事の内容は下記とほぼ同じです。
Crystalのブロックは2つある
RubyにはProcとLambdaがありますが、Crystalでもブロックは単一のものではなく大きく2つに分けて考えるといいようです。
- インライン化されたブロック
- キャプチャされたブロック
前者は yield
を使うパターンで、コンパイラによってマクロのように式が展開されます。そのため実行時の呼び出しコストはゼロです。後者はProcオブジェクトとしてキャプチャされたブロックです。
Cの関数にコールバック関数としてクロージャーを渡す
次のようなコールバックを取るCの関数があったとします。
void on_tick(void (*callback)(int32_t, void *), void *user_data);
Crystalでバインディングを作成するには次のようにします。
lib LibTicker
fun on_tick(callback : (Int32, Void* ->), data : Void*)
end
さて、ここで、コールバック関数は、引数 Void* からデータを受け取ることができます。イメージとしてはClosureに封入された世界のデータと操作命令を渡すことができます。しかしClosure(Proc)はCrystalの世界のものなので直接Cに渡せないので、Boxでラップして一度メモリに保存してから渡します。
まずBoxを作成し、そこにキャプチャーされたブロック(Proc)を格納します。BoxはCの関数が使い終わるまでガベージコレクションによって回収されないようにクラス変数に保存して参照し保護しておきます。コールバック関数の中で、渡されたBoxをProcに戻します。そしてProcを呼び出します。
コメントなしバージョン
module Ticker
@@box : Pointer(Void)?
def self.on_tick(&callback : Int32 ->)
boxed_data = Box.box(callback)
@@box = boxed_data
LibTicker.on_tick(->(tick, data) {
data_as_callback = Box(typeof(callback)).unbox(data)
data_as_callback.call(tick)
}, boxed_data)
end
end
コメントありバージョン
module Ticker
# ユーザー用のコールバックには Void* が含まれない
@@box : Pointer(Void)?
def self.on_tick(&callback : Int32 ->)
# Proc は {Void*, Void*} なので、単純に Void* に変換できないため、
# メモリを確保し、そこに Proc を格納することで「ボックス化」する
boxed_data = Box.box(callback)
# GC によって回収されないように Crystal 内で保持する (*)
@@box = boxed_data
# クロージャーを形成しないコールバックを渡し、ボックス化したデータを
# コールバックデータとして渡す
LibTicker.on_tick(->(tick, data) {
# 渡されたデータを Box.unbox を使って元の Proc に戻す
data_as_callback = Box(typeof(callback)).unbox(data)
# そして、ユーザーが指定したコールバックを実行する
data_as_callback.call(tick)
}, boxed_data)
end
end
Ticker.on_tick do |tick|
puts tick
end
かなり難しいですが、一つのパターンとして覚えておき、Cの関数の引数としてコールバック関数を渡したくなったときに活用してください。
この記事は以上にしたいのですが、自分の中での理解が必ずしも十分ではなく、確度が低いことについても少し書いておきます。ChatGPTのようなAIではなく、100%人間が自力で文章を書いていますが、書いてある内容の信頼度は80%ぐらいなのでところどころ間違いがあるかもしれません。詳細は自分で詰めて使ってください。
Box化しなくても、クロージャーではなく、外部の変数など参照していない純粋な関数なら呼べる
さて、ここで、「コールバック関数を引数にとるすべてのCの関数に必ずしもデータを転送するための引数があるとは限らないがどうするのか?」と思った人もいると思います。
とてもするどいです。
実はそのような場合であっても、Cの関数にコールバック関数としてProcを渡すことは可能です。しかし、クロージャーの外部にある変数を参照することはできなくなります。
もしもクロージャーが外部の数値を参照した場合は、コンパイルは正常に終了するが、生成された実行ファイルは「実行時エラー」のようなものによって終了するというちょっと珍しい動作が発生します。ちゃんとエラーハンドリングはしているようでセグフォにはなりませんが、問答無用で終了するので注意してください。
しかし、外部の関数を参照しなくてもいいような単純なケース、例えば単にIntの単純な数値計算で完結するようなケースでは問題なく動作すると考えてよいと思われます。例えば、身長と体重を受け取ってBMIを計算するコールバック関数などの場合は大丈夫でしょう。
libffiの場合
Rubyでは、Cバインディングを作成する場合FFIを利用するケースが多いです。FFIでは *Void のようなデータの取り出し口がなくても、特に気にせずProcをコールバック関数として渡すことができますよね。
これは libffi が動的にCの関数のようなものをメモリ上に生成する機能を持っているからです。したがって、Procを表現するCのコールバック関数を動的に生成し、それをCに渡しているようです。
Closureにするときにメモリのコピーはどの程度発生するのか?
これは「トランポリン」という仕組みを使っており、メモリのコピーは特に発生していないようです。
しかし、メモリのコピーが全く発生しないというわけではなく、どうやらRubyがProcを作成する時点でメモリをコピーしているようです。
まあこんな感じで、ちょっと知識があやふやなところもあるのですが、日本語の記事があまりない領域なので、このぐらいの精度の記事でも許してください。もっと詳しいことをご存知の方もそんなに多くないのではないかと想像しますが、コメント欄で追加・指摘してくれたらうれしいです。
この記事は以上です。