はじめに
CrystalはRubyによく似た文法でありながら静的型付けコンパイラ言語であり、高速に動作します。そうなるとRubyの拡張ライブラリをCrystalでかけたら良いのになと思ったりします。
前回の記事ではCrystalとC++の連携を解説しました。C++(C言語)とCrystalの関数・メソッドは相互にコールできます。一方でRubyの拡張ライブラリはC言語でかきます。このことから、C言語を介してRubyとCrystalの連携ができそうだなと想像されます。
この記事は、Rubyの拡張ライブラリをCrystalで書く例を解説します。
検証した環境
OS: MacOS
Crystal 1.6
Ruby 3.1
[12/11追記]大規模開発では検証しておらず問題が発生する可能性もあります。最後の参考も合わせてみてください。
Rubyの拡張ライブラリの書き方
Crystal側のコード
そもそもRubyの拡張ライブラリはC言語を前提としています。以下の公式ドキュメントなどを参照してある程度は仕組みを理解しておく必要がありますが、ここではサンプルを先に上げて、理解に必要なポイントをまとめていきます。
lib Ruby
# RubyのVALUE
type VALUE = Void*
$rb_cObject : VALUE
# グローバルメソッド定義
fun rb_define_global_function(name : LibC::Char*, func : VALUE, VALUE -> VALUE, argc : Int32)
# 整数の変換
fun rb_num2int(value : VALUE) : Int32
fun rb_int2inum(value : Int32) : VALUE
end
# Rubyから呼び出されるメソッド
# valueが引数
def fib_cr(selfobj : Ruby::VALUE, value : Ruby::VALUE)
# Ruby整数 --> CrystalのInt32へ変換
ini = Ruby.rb_num2int(value)
# Crystal の Int32をRubyの整数に変換して戻す
Ruby.rb_int2inum(fibonacci(ini))
end
# 実際にフィボナッチ数列を求める
def fibonacci(n)
return n if n <= 1
fibonacci(n - 1) + fibonacci(n - 2)
end
fun init = Init_rubyext : Void
# GC初期化
GC.init
# トップレベルコード実行
LibCrystalMain.__crystal_main(0, Pointer(Pointer(UInt8)).null)
# 引数が1個のメソッド"fib"を定義する
Ruby.rb_define_global_function("fib", ->fib_cr, 1)
end
Crystalから呼ぶC言語の関数を宣言
lib Ruby
...の部分はCrystalから呼び出すC言語の関数を書きます。Ruby拡張はC言語で書くので、ここで必要なC言語の関数を記述します。このlib
の記述については、
https://ja.crystal-lang.org/reference/syntax_and_semantics/c_bindings/index.html
や
https://qiita.com/theanine/items/5b9e98f9091153a0f170
などをごらんください。
まず
# RubyのVALUE
type VALUE = Void*
RubyのオブジェクトであるVALUEはCrystalではVoid*なので、このような別名をつけています(Cでいうところのtypedef)。Rubyは動的言語なので、型は実行時に決まるため、すべてのデータの型はVALUE1つです。よってCrystal側でRubyから渡ってくるデータはすべてVALUEです。VALUEに型の情報などが含まれています。Crystal側で扱うにはRubyオブジェクトのCrystlaのオブジェクトに変換が必要です。
C言語でRuby拡張を書く場合は、C言語からはrb_????
といった関数を使うことでRubyと連携します。以下のページにさまざまな関数の説明があります。
https://docs.ruby-lang.org/en/3.0/extension_ja_rdoc.html
使う関数をここに書いておく必要があります。今回使う3つの関数は
void rb_define_global_function(const char *name, VALUE (*func)(ANYARGS), int argc)
long rb_num2int(VALUE)
VALUE rb_int2inum(long)
です。1つめはRubyのトップレベルメソッドを定義するための関数です。残り2つの関数はデータ変換用の関数です。Rubyの数値変換はマクロNUM2INTなどを使うようです。しかし、CrystalからはC言語のマクロは使えないため、マクロの定義を見に行って実際に呼び出している元の関数を探す必要があります。rubyのソースを見に行って探す必要がありました。
これらの型の情報をもとに以下のように関数を定義します。
# グローバルメソッド定義
fun rb_define_global_function(name : LibC::Char*, func : VALUE, VALUE -> VALUE, argc : Int32)
# 整数の変換
fun rb_num2int(value : VALUE) : Int32
fun rb_int2inum(value : Int32) : VALUE
今回はfuncの型は、定義するメソッドの引数に依存します。今回定義したいメソッドは引数1個なのでVALUE -> VALUE
な気がしますが、1個増やしてVALUE, VALUE -> VALUE
です。これはselfが渡ってくるためです。これは後で説明します。
https://docs.ruby-lang.org/ja/latest/function/NUM2INT.html
https://docs.ruby-lang.org/ja/latest/function/INT2NUM.html
初期化用関数「Init_ライブラリ名」を定義する
Rubyがライブラリを読み込んだときに呼び出される関数で、この中でメソッド定義やクラス定義などを行います。rubyext
という名前にしたのでInit_rubyext
です。
Crystalの方の必要な初期化も兼ねています。前回の記事でも述べられているようにCrystal側はGCの初期化とCrystalのmain関数を呼ぶことが必須です。したがって、
GC.init
LibCrystalMain.__crystal_main(0, Pointer(Pointer(UInt8)).null)
は書いておく必要があります。
それ以降にRuby拡張としての初期化をしていきます。ここでは、Rubyのクラスやメソッドなどを定義していきます。ここでトップレベルメソッドを定義しています。
Ruby.rb_define_global_function("fib", ->fib_cr, 1)
"fib"という名前のメソッドを定義しました。実態はfib_crで、->
でメソッドをProc化したものを呼び出します。最後の引数は正の数を与える場合は、引数の個数となります。詳しくは以下を参照してください
https://docs.ruby-lang.org/ja/latest/function/rb_define_method.html
https://rurema.clear-code.com/3.2.0/function/rb_define_global_function.html
Crystal側でのライブラリの中身
最後にfib_crを用意します。
# Rubyから呼び出されるメソッド
# valueが引数
def fib_cr(selfobj : Ruby::VALUE, value : Ruby::VALUE)
# Ruby整数 --> CrystalのInt32へ変換
ini = Ruby.rb_num2int(value)
# Crystal の Int32をRubyの整数に変換して戻す
Ruby.rb_int2inum(fibonacci(ini))
end
# 実際にフィボナッチ数列を求める
def fibonacci(n)
return n if n <= 1
fibonacci(n - 1) + fibonacci(n - 2)
end
fib_cr
がトップレベルメソッドfib
の実態です。引数は1個のメソッドですが、fib_cr
は2個引数があり、1つ目の引数はself
を受け取るようにするようです。2つ目の引数が実質1つ目の引数となります。
value
はRubyのオブジェクトなので、Crystalで使うには変換が必要です。rb_num2int
でCrystalのInt32型の整数に変換します。
これを使って、Crystalのfibonacci
メソッドをコールします。ここからはCrystalの世界です。普通に再帰で求めています。fibonacci
の戻り値はInt32です。
Rubyの世界に戻すには、CrystalのInt32をRubyのオブジェクトつまりVALUE
に変換する必要があり、rb_int2inum
をコールしています。
以上で、Crystal側の準備はできました。
ビルド
makefile
は以下の通りです。
TARGET = rubyext.bundle
LIB=(libruby.3.1.dylibとかがあるパス)
rubyext.bundle: rubyext.cr
crystal build --release --single-module --link-flags="-dynamic -bundle -L$(LIB) -lruby" -o rubyext.bundle rubyext.cr
clean:
rm -f $(TARGET)
パスについては各自の環境にあわせてかきましょう。
make
でrubyext.bundleができます
実行
require_relative 'rubyext'
puts fib(10)
これで
ruby test.rb
55
とでれば成功です
パフォーマンス
Rubyとの比較をしてみましょう
require_relative 'rubyext'
require "benchmark"
def rb_fib(n)
return n if n <= 1
rb_fib(n - 1) + rb_fib(n - 2)
end
Benchmark.bm do |x|
x.report { rb_fib(40) }
x.report { fib(40) }
end
結果は以下のようになりました。
user system total real
Ruby 9.264494 0.011057 9.275551 ( 9.276154)
Crystal 0.394152 0.000243 0.394395 ( 0.394432)
確かに高速化できたことが確認できました。
ちなみに、メモ化なしのフィボナッチ数列の再帰による計算はRubyでもJITが効きやすいようで、
user system total real
Ruby(--jitあり) 2.073805 0.001984 2.075789 ( 2.078277)
となりました。
オブジェクトの相互変換の改善
上記でとりあえず動くのですが、やはりRubyとのオブジェクトのやりとりが面倒です。なので、Ruby用のC関数を繋ぐメソッドやマクロをCrystal側で用意するのが良いだろうと思います。今回はこういうものを作ってみました。
struct Int32
def to_ruby
Ruby.rb_int2inum(self)
end
def self.from_ruby(value : Ruby::VALUE)
Ruby.rb_num2int(value)
end
end
# valueが引数
def fib_cr(selfobj : Ruby::VALUE, value : Ruby::VALUE)
# Ruby整数 --> CrystalのInt32へ変換
ini = Int32.from_ruby(value)
# Crystal の Int32 --> Rubyの整数に変換
fibonacci(ini).to_ruby
end
Rubyのオブジェクトからの変換用のInt32.from_ruby
とRubyへのオブジェクトへの変換の.to_ruby
を作りました。多少はCrystal的(Ruby的)な記述で可読性は上がったと思います。
RubyとCrystalで相互変換できそうなオブジェクトはたくさんありますので、こういう変換メソッドを用意していくと書くのが楽になるだろうなと思います。
おわりに
今回はサンプルコードレベルでRubyからCrystalを使ってみました。しかし、RubyからCrystalを呼び出すインターフェイス部分を書くのはかなり面倒です。何らかのサポートする仕組みをCrystal側で用意する必要がありあそうです。今回はその1つの方法の例としてRuby,Crystalのオブジェクトの相互変換メソッドを作ってみました。メソッドの変換もサポートするマクロが作れそうな気がしていますが、まだできていません。
その点、wasmを使う方法は、RubyオブジェクトとCrystalオブジェクト間の相互変換するところを面倒見なくてよいみたいなのでとても楽そうです。速度も結構速くて有望な方法に見えます。
以上で、この記事を終わります。
参考
本記事の執筆には以下の記事を参考にしました。
CrystalでRuby拡張を書く`proof of concept'。今回もっとも参考にしました。Rubyへのオブジェクト変換のメソッドなどもあって、この方針でCrystal側でライブラリを整備していくのが筋が良さそう。
https://github.com/manastech/crystal_ruby
CrystalをC++から使う
https://qiita.com/theanine/items/5b9e98f9091153a0f170
Ruby拡張をCrystalで書く例
https://tech.actindi.net/2016/05/12/rubyext.html
https://gist.github.com/notozeki/7159a9d9ab9707a22129
CrystalでRuby拡張の書き方解説
https://www.slideshare.net/AnnaKazakova/how-to-write-ruby-extensions-with-crystal
https://www.slideshare.net/5t111111/writing-ruby-extension-with-crystal
CrystalでRuby拡張を書くための支援。便利そうだが2022年12月現在、更新が止まっている。
https://github.com/phoffer/crystalized_ruby
CrystalでWebAssemblyに出力した関数をRubyから呼び出す
https://qiita.com/kojix2/items/b233f1419b26f7fc0e1b
[追記12/11]C#の場合のRubyとの連携。GCは独立して動いてしまう問題が述べられています。Crystalでも当てはまるかもしれません。
https://magazine.rubyist.net/articles/0021/0021-RubyWithCSharp.html