ローファイ日記

出てくるコード片、ぼくが書いたものは断りがない場合 MIT License としています http://udzura.mit-license.org/

Fukuoka.rb #256 LTの補足: RustでRubyGemを書く話

fukuokarb.connpass.com

docs.google.com

キリがいい回で話しました。およそLTで収まる話ではなかった(5分、一番難しい)ため、最も肝心の「どうやってコードを書くか」の部分を全部端折ってこのブログで補足することにしました。


サンプルプロジェクト

github.com

ここを読み込めば大体書けるようになるのでは、という感じなのですが、書く際の留意点をつらつら残します。

(5月初旬現在)事前にそもそも最新のRubyGemsをインストールする必要がある

色々方法があるんですが、10年前の方法を思い出すために ruby setup.rb を叩く方法を使いました。逆にRubyGemsさえ最新ならRubyを3.2まで上げなくて良い? と思う。

rbenv shell 3.2.0-preview1
git clone --depth=1 https://github.com/rubygems/rubygems.git
cd rubygems
ruby setup.rb

プロジェクトの初期化

gemとしてもlib crateとしてもvalidなディレクトリ構成にすればOKです。あと、拡張gemの名前で - を含めると難儀します(自動で読み込むC関数名に - を含もうとするため)ので気をつけましょう。

bundle gem oye_rust
cd oye_rust
cargo init --lib

ちなみにこれで生成されたプロジェクトは Cargo.lock がgitignoreされていますが、gemとしてはこのファイルは必要なのでgitの管理対象にしましょう。

このあと Cargo.toml を更新する必要があり、パッチ作者が作ったプロジェクトが参考になりますが、罠があって rb-sys を使うときは features = ["link-ruby"] を指定していないと私の環境(Apple Silicon の Macです)では読み込み時に関数が見つからないなどと言われました。この辺は怪しい(これだとlibrubyを二重にリンクしてね?とか)感じもしますが、きっと誰かが精査してくれるということで...。

crate-type = ["cdylib"] になるというのもポイントです。

[package]
name = "oye_rust"
version = "0.1.0"
edition = "2021"

[lib]
crate-type = ["cdylib"]

[dependencies]
libc = "0.2.125" # どうせ後で使うので宣言しています
rb-sys = { git = "https://github.com/ianks/rb-sys", branch = "main", features = ["link-ruby"] }

一度 cargo build して Cargo.lock を更新しましょう。

Rust側実装

rb-sys は基本的にruby.hからbindgenで生成した関数群なので、ほぼ全部unsafeです。頑張りましょう。逆にいうとRustでただのFFIのコードを書くだけとも言えます(補完も効きますし)。

コードを見てあとはコンパイラ耳をすませば書けると思うんですが、渋いかもポイント*1をいくつか書いておくと、

マクロが基本使えないので、 Qnil/Qtrue/QfalseRSTRING_LEN() やとにかく色々使えない

ここはbindgenの制限だと思います。抽出されている定数もあるのでbindgenでの運用がどうだったかもうちょい調べようとは思いますが...。

Qnil について言えばは実態はenumなのでそっちを直接使えば大丈夫です。

#[repr(u32)]
#[derive(Debug, Copy, Clone, Hash, PartialEq, Eq)]
pub enum ruby_special_consts {
    RUBY_Qfalse = 0,
    RUBY_Qtrue = 20,
    RUBY_Qnil = 8,
    RUBY_Qundef = 52,
    RUBY_IMMEDIATE_MASK = 7,
    RUBY_FIXNUM_FLAG = 1,
    RUBY_FLONUM_MASK = 3,
    RUBY_FLONUM_FLAG = 2,
    RUBY_SYMBOL_FLAG = 12,
}

しかしこれはこれで別の渋いポイントが。

型のサイズに厳しい

上記のように Qnil/Qtrue/Qfalse の大きさは本来 32bit ですが、筆者の環境(M-1 Mac)では VALUE は64bitです。なので以下のコードはコンパイルできません。

use rb_sys::ruby_special_consts;
let qnil = ruby_special_consts::RUBY_Qnil;
std::mem::transmute::<ruby_special_consts, VALUE>(qnil);

ひとまずこういう構造体を挟んで回避しました。これは as u64 でもいいのかな、試していませんが、エンディアンのことを考えると言い切れない(試そうね)...。

#[repr(C)]
struct WrappedValue {
    value: u32,
    padding: u32,
}

let qnil = WrappedValue {
    value: ruby_special_consts::RUBY_Qnil as u32,
    padding: 0,
};
std::mem::transmute::<WrappedValue, VALUE>(qnil)

VALUEruby_special_constsのサイズは当然フラグや環境で変わるということでしょうので*2、厳密にはRust側でも #[cfg] などで切り替えるべきでしょう。

メソッドに対応する関数は、引数を持つ場合基本的に std::mem::transmute() で置き換えることになる

Rustにおける関数ポインタ型は、引数と戻り値の型がセットでそれぞれ別の型として認識されます。

しかしCRubyでは引数に何個VALUE型を取っても問題なくメソッド定義として利用できる関数ポインタ型として扱われます。

Rustの表現で言うと、 fn() -> VALUE 型でも fn(VALUE) -> VALUE 型でも fn(VALUE, VALUE) -> VALUE 型でもvalidであり、 rb_define_method() の引数に使えます。

従ってC言語のノリで関数を書いていた場合、Rustではこれもtransmute()が必要になります。

#[no_mangle]
unsafe extern "C" fn test_meth(rbself: VALUE) -> VALUE {
    ....
}

#[no_mangle]
pub extern "C" fn Init_test() {
    // ...
    let function_name = CString::new("test").unwrap();
    let callback = unsafe {
        std::mem::transmute::<unsafe extern "C" fn(VALUE) -> VALUE, unsafe extern "C" fn() -> VALUE>(
            test_meth,
        )
    };

    unsafe { rb_define_method(klass, function_name.as_ptr(), Some(callback), 0) }
}

引数は原則 rb_get_args() のような仕組みで取り扱うのも手かと思います*3

雑感

Rustで拡張gemを書くにしても libruby の関数を使う必要があるわけで、それを現状提供されている rb-sys crate 経由で利用しようとすると unsafe 祭りになるのが現状です。Rustの安全性とは...という気持ちになる。

しかしそれでも静的検査してくれるところは強力だし、他ツールやライブラリ(ことパッケージマネージャ)が整っているなど生産性はかなり上がるのですが。

一方でストレスなくRustでgemを書きたいなら、 nix crate のようなノリでRubyGemを書くための薄いラッパーcrateがあるといいと思います。OSSチャンスだ!

まだほぼ中身はないですが名前を思いついたので押さえておくテスト。一応最低限は書きました。

このcoffretを使っていないとこういう感じで、

#[allow(non_snake_case)]
#[no_mangle]
pub extern "C" fn Init_oye_rust() {
    let name = CString::new("Rust").unwrap();
    let object = unsafe { rb_cObject };
    let klass = unsafe { rb_define_class(name.as_ptr(), object) };

    let function_name = CString::new("mostrarme").unwrap();
    let callback = unsafe {
        std::mem::transmute::<unsafe extern "C" fn(VALUE) -> VALUE, unsafe extern "C" fn() -> VALUE>(
            test_show_self,
        )
    };

    unsafe { rb_define_method(klass, function_name.as_ptr(), Some(callback), 0) }
}

coffretを使うととりあえずこういう感じにできました。もっと綺麗にできると思います。

use coffret::class;
use coffret::exception;

fn init_oye_rust_internal() -> Result<(), Box<dyn Error>> {
    let object = class::object_class();
    let klass = class::define_class("Rust", object);

    let callback = class::make_callback(&test_show_self);

    class::define_method(klass, "mostrarme", callback, 0);
    Ok(())
}

#[allow(non_snake_case)]
#[no_mangle]
pub extern "C" fn Init_oye_rust() {
    match init_oye_rust_internal() {
        // これはRustのErrorをRubyの例外として上げるというかっこいい関数
        Err(e) => exception::rustly_raise(e.as_ref()),
        Ok(_) => {}
    }
}

雑感

個人的にはRubyGemsが公式対応したことで、妙なハックをせずにgemとしてもcrateとしてもvalidなプロジェクトを作ればそれが使えるようになるので、楽になったなあと思いました。色々アイデアが思い浮かびます。

*1:LTでも話すかもですが

*2:その辺のルールは流石に把握していないんですが

*3:c.f. mrubyは完全にそっちに寄せてる