4
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

CrystalAdvent Calendar 2022

Day 11

Rubyの拡張ライブラリをCrystalで書く

Last updated at Posted at 2022-12-10

はじめに

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言語を前提としています。以下の公式ドキュメントなどを参照してある程度は仕組みを理解しておく必要がありますが、ここではサンプルを先に上げて、理解に必要なポイントをまとめていきます。

rubyext.cr
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ができます

実行

test.rb
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

4
2
2

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
4
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?