🌉

RustのstructをRubyのclassとして扱う

2020/12/26に公開

やりたいこと

RustのstructをRubyのクラスにマッピングして、Ruby側からオブジェクト的に操作したい。
Rubyのアプリケーションにおいて、実行に時間がかかる処理を部分的にRustの実装で動かして処理を高速化する目的。

FFIをブリッジに使う

Ruby-FFIはRubyからネイティブコードを呼ぶためのライブラリ。
今回はこれを使ってRubyからRustのコードを扱う。

Ruby・Rust間でのFFIの基本的な利用方法は、以下の記事などを参考にするとイメージがしやすい。
RubyからRustの関数をつかう → はやい - Qiita

FFI以外の候補

2019年秋頃に調査した際のメモ。FFIの他にもRustとRubyの橋渡しに使えるライブラリはいくつか存在するよう。

  • Fiddle
    Ruby標準のライブラリ。こちらでも良さそうだったが機能的にはFFIと同じような印象だったので、情報の多そうなFFIを利用することにした。
  • Helix
    インターフェースは良さそうだったが、当時最新バージョンのRust(1.33.0)で動作しなかったので見送りに。(最近改めて見てみるとDeprecatedになっていた…。)
  • rutie / ruru
    Rust側からRubyオブジェクトの操作ができるライブラリで、今回の目的とは合わなかった。rutieは開発の止まってしまったruruのforkかと思われる。

動作イメージ

サンプルとして、Rust側で定義した以下のようなstructを用意して、

src/user.rs
pub struct User {
    pub id: u64,
    pub name: String,
}

impl User {
    pub fn get_display(&self) -> String {
        format!("id: {}, name: {}", self.id, self.name)
    }
}

Ruby側でクラスとして利用できるようにする。

sample.rb
user = User.new(4) # 初期化, idの指定
user.name = 'piyo' # nameの指定
user.display       # => 'id: 4, name: piyo'

ソースコード

今回動作させたコードはこちらのリポジトリに置いた。
https://github.com/koyopro/ruby_object_with_rust

READMEにビルドやテストの実行コマンドも記載しているので、動作を見ながら確認できると思う。

連携周りのソースコード

上の項で記載したsrc/user.rs(struct Userの定義)以外で特筆すべきファイルは以下の3つ。

  • 他言語からRustの実装を呼ぶ為のインターフェース定義(src/lib.rs)
  • Ruby-FFIを利用したブリッジの実装(lib/ruby_object_with_rust.rb)
  • Ruby側のUserクラスの実装(lib/ruby_object_with_rust/user.rb)

この3ファイルについて、注意すべきポイントについてコメントをつけたソースコードをリポジトリから抜粋して以下に載せた。

他の言語からRustの実装を呼び出す用のインターフェース定義

src/lib.rs
mod user;
use std::ffi::{CStr, CString};
use std::os::raw::c_char;
use user::User;

// ユーザーを初期化する関数。
// FFI経由でポインタをRuby側に渡すことになるので`*mut User`型の返り値が必要になる。
// `Box::new`でスマートポインタを得た上で、`Box::into_raw`で生ポインタを作っている。
#[no_mangle]
pub extern "C" fn create_user(id: u64) -> *mut User {
    return Box::into_raw(Box::new(User {
        id,
        name: String::new(),
    }));
}

// Userのidを取得する関数。
// create_userでRuby側に渡したUserのポインタをここで受け取って処理することになる。
// 生ポインタを操作する処理なので、`unsafe`を使う必要がある。
#[no_mangle]
pub unsafe extern "C" fn get_id(user: *mut User) -> u64 {
    let u: &mut User = &mut *user;
    return u.id;
}

// Userのnameを更新する関数。
// Ruby側から文字列を受け取るには`*const c_char`型を使う。
// ポインタからStringを生成してnameにセットしている。
// 今回は変換に失敗することが無いとして`unwrap()`を用いているが、状況に応じて要エラーハンドリング。
#[no_mangle]
pub unsafe extern "C" fn set_name(user: *mut User, name: *const c_char) {
    let u: &mut User = &mut *user;
    u.name = CStr::from_ptr(name).to_str().unwrap().to_string();
}

// Userのget_displayメソッドを呼んで得られた文字列を返す関数。
// set_name関数の引数で文字列を受け取ったのと同じく、文字列を返すときも`*const c_char`型を使う。
// CStringを生成してそのポインタを返している。
#[no_mangle]
pub unsafe extern "C" fn get_display(user: *mut User) -> *const c_char {
    let u: &mut User = &mut *user;
    let display = u.get_display();
    return CString::new(display).unwrap().into_raw();
}

// ポインタからメモリを開放する関数。
// ここまでの関数で、Userや文字列のポインタを外部に渡しているため
// 最終的にそれらを解放するために利用する。
#[no_mangle]
pub unsafe extern "C" fn release(c_ptr: *mut libc::c_void) {
    libc::free(c_ptr);
}

Ruby-FFIを利用したブリッジの実装

lib/ruby_object_with_rust.rb
require 'ffi'
require 'ruby_object_with_rust/user'

module RubyObjectWithRust
  extend FFI::Library
  # FFIを利用するために、`extend FFI::Library`したモジュールを用意する。
  # MacとLinuxではRustをビルドした時に生成されるバイナリの拡張子が異なるので、環境で分岐している。
  ext = `uname` =~ /Darwin/ ? 'dylib' : 'so'
  ffi_lib File.join(__dir__, '..', "target/release/libruby_object_with_rust.#{ext}")

  class AutoPointer < FFI::AutoPointer
    def self.release(ptr)
      RubyObjectWithRust.release(ptr)
    end
  end

  class Error < StandardError; end

  # FFIの機能でRubyのメソッドとRust側で用意したインターフェースを紐付けている。
  # 引数は<Ruby側メソッド名, Rust側関数名, 引数の型の配列, 返り値の型>の4つ
  # (メソッド名と関数名が一致する場合は一つだけ書けば良い)
  # Userを指定している部分は`:pointer`でも動作する。
  attach_function :create_user, :create_user, [:int], :pointer
  attach_function :get_id, [User], :int
  attach_function :set_name, [User, :string], :void
  attach_function :get_display, [User], :strptr
  attach_function :release, [:pointer], :void
end

Ruby側のUserクラスの実装

lib/ruby_object_with_rust/user.rb
module RubyObjectWithRust
  #
  # Userクラス
  #
  # FFI::ManagedStructを継承する
  class User < FFI::ManagedStruct
    layout :user, :pointer

    # このクラスの各メソッドではRubyObjectWithRustモジュールで定義したメソッドを使う

    # 以下で定義したメソッドを利用してstruct Userのポインタを取得
    #   attach_function :create_user, :create_user, [:int], :pointer
    # super (= FFI::ManagedStruct.new ) には得られたポインタを渡す
    def self.new(id)
      super(RubyObjectWithRust.create_user(id))
    end

    # 以下で定義したメソッドを利用してidを取得
    #   attach_function :get_id, [User], :int
    # 引数にUserを指定しているので、selfを渡している
    def id
      RubyObjectWithRust.get_id(self)
    end

    # 以下で定義したメソッドを利用してnameを設定
    #   attach_function :set_name, [User, :string], :void
    # new_valueはRubyの文字列で、:stringタイプとしてそのまま渡せる
    def name=(new_value)
      RubyObjectWithRust.set_name(self, new_value)
    end

    # 以下で定義したメソッドを利用して文字列を取得
    #   attach_function :get_display, [User], :strptr
    #   Rust側の返り値が文字列のポインタなので、:strptrタイプを使う
    # resultには文字列、ptrにはポインタが渡ってくる
    # AutoPointerを使って、文字列の利用が終わったらGCで処理されるようにしている
    def display
      result, ptr = RubyObjectWithRust.get_display(self)
      RubyObjectWithRust::AutoPointer.new(ptr)
      result
    end

    # UserクラスのインスタンスがGCで開放される際に実行される
    # 以下で定義したメソッドを利用してメモリを開放する
    #   attach_function :release, [:pointer], :void
    def self.release(ptr)
      RubyObjectWithRust.release(ptr)
    end
  end
end

所感

今回やりたかったことを実現するためには、FFIについていくつかのページの情報を集めて組み合わせる必要があったので一度整理したいと思っていた(以下に役立ったリンクを貼ったのでFFIを利用する場合は参考にしてみてください)。Rust側でメモリを確保してRuby側に渡す場合にはメモリリークに気をつける必要があるのがちょっと怖い…。

Rubyアプリケーション中の処理を高速化したい誰かの役に立つと嬉しい。

参考

ライブラリ比較

FFI関連

Discussion