Ruby の拡張ライブラリを、Rust を使ってお手軽に実装する

magnus というcrate を利用すると、超簡単に Ruby の拡張ライブラリが実装できます。

具体的には、Rust 側の記述はこんな感じになります。

use magnus::{define_class, function, method, prelude::*, Error};

#[magnus::wrap(class = "Point")]
struct Point {
    x: isize,
    y: isize,
}

impl Point {
    fn new(x: isize, y: isize) -> Self {
        Self { x, y }
    }

    fn x(&self) -> isize {
        self.x
    }

    fn y(&self) -> isize {
        self.y
    }

    fn distance(&self, other: &Point) -> f64 {
        (((other.x - self.x).pow(2) + (other.y - self.y).pow(2)) as f64).sqrt()
    }
}

#[magnus::init]
fn init() -> Result<(), Error> {
    let class = define_class("Point", Default::default())?;
    class.define_singleton_method("new", function!(Point::new, 2))?;
    class.define_method("x", method!(Point::x, 0))?;
    class.define_method("y", method!(Point::y, 0))?;
    class.define_method("distance", method!(Point::distance, 1))?;
    Ok(())
}

これをビルドすると、Ruby 側に Point クラスが生えます。 Ruby の世界 と Rust の世界 の間で相互に自動で型が変換されていてすごいですね。

point_1 = Point.new(0, 0)
point_2 = Point.new(100, 100)
point_1.distance(point_2) # => 141.4213562373095

この記事では、渡された文字列をシャッフルする Shuffle という gem の実装を通じて、Rust で拡張ライブラリを作る方法を説明します。

なお、実行環境は macOS Monterey 12.4、CPU は M1 Pro です。とはいえ、他の環境でも似たような手順で行けると思います。

gem を作る

拡張ライブラリを入れるための gem を作っていきます。

gem を初期化する

$ bundle gem shuffle
$ cd shuffle

諸々のバージョンを揃える

Ruby のバージョンを 3.1.1 以上にする必要があります*1。 2022/06/12 現在では、 3.1.2 を入れておけば良いと思います。

$ rbenv install 3.1.2
$ rbenv local 3.1.2

また、Rubyems のバージョンを 3.3.11 以上にする必要があります*2

$ gem -v
3.3.15

# 古い場合はバージョンを上げる
$ gem update --system

Rust をセットアップする

rustup を利用して適当に Rust をセットアップします。 1.57.0 以上をインストールしてください*3

gemspec を適当に埋める

shuffle.gemspec の TODO になっている欄を適当に埋めます。埋めないとこの先の作業ができません。

参考: fix gemspec · genya0407/shuffle@5563f9c · GitHub

Rust で拡張ライブラリを作る

いよいよ拡張ライブラリを作っていきます。

拡張ライブラリをビルドする環境を作る

まず、拡張ライブラリをビルドするのに必要な gem を dependency として追加します。 shuffle.gemspec に以下を書き加え、bundle install を実行します。

  spec.add_dependency "rake-compiler"
  spec.add_dependency "rb_sys"
$ bundle install

次に、bundle exec rake compile で拡張ライブラリをビルドするため、 Rakefile に以下を書き加えます。

require "rake/extensiontask"

Rake::ExtensionTask.new("shuffle") do |ext|
  ext.lib_dir = "lib/shuffle"
  ext.source_pattern = "*.{rs,toml}"
end

そして、 ext/shuffle/extconf.rb を作成します。

require "mkmf"
require "rb_sys/mkmf"

create_rust_makefile("shuffle/shuffle")

最後に、 shuffle.gemspecextconf.rb を認識させるため、以下を書き加えます。

  spec.extensions = ["ext/shuffle/extconf.rb"]

ここまでで、Rust 拡張をビルドする準備が整いました。

参考: setup rake-compiler && rb_sys · genya0407/shuffle@61af062 · GitHub

Rust を書く

まず、 ext/shuffle で Rust のプロジェクトを初期化します。

$ cd ext/shuffle
$ cargo init --lib
$ echo 'target' >> .gitignore

そして、 Cargo.toml に以下を書き加えます。

[lib]
crate-type = ["cdylib"] # 拡張ライブラリを作るために必要

[dependencies]
magnus = "0.3"
rand = "0.8.5" # 文字列をシャッフルするのに使う

最後に、文字列をシャッフルするクラスを定義しましょう。 ext/shuffle/src/lib.rs に以下を記述します。

use magnus::{define_class, function, method, prelude::*, Error};
use rand::seq::SliceRandom;

#[magnus::wrap(class = "Shuffle")]
struct Shuffle {
    original: String,
}

impl Shuffle {
    fn new(original: String) -> Self {
        Self { original }
    }

    fn shuffle(&self) -> String {
        let mut rng = rand::thread_rng();
        let mut v = self.original.chars().collect::<Vec<_>>();
        v.shuffle(&mut rng);
        v.into_iter().collect()
    }
}

#[magnus::init]
fn init() -> Result<(), Error> {
    let class = define_class("Shuffle", Default::default())?;
    class.define_singleton_method("new", function!(Shuffle::new, 1))?;
    class.define_method("shuffle", method!(Shuffle::shuffle, 0))?;
    Ok(())
}

そしておもむろに bundle exec rake compile を実行すると、 lib/shuffle/shuffle.bundle というファイルが生成されます。

$ bundle exec rake compile
$ file lib/shuffle/shuffle.bundle
lib/shuffle/shuffle.bundle: Mach-O 64-bit dynamically linked shared library arm64

このファイルを require すると、 Shuffle クラスが使えます。

$ bundle exec irb
irb(main):001:0> require './lib/shuffle/shuffle'
=> true
irb(main):002:0> Shuffle.new('abc').shuffle
=> "bca"

gem のお作法的には、 lib/shuffle.rb から require_relative しておくのが良いです。

# frozen_string_literal: true

require_relative "shuffle/version"
require_relative "shuffle/shuffle" # これを追加

class Shuffle
  class Error < StandardError; end
  # Your code goes here...
end

なお、 bundle gem 実行時に、Shuffle は module として宣言されているので、そのままだと「Shuffle は class じゃなくて module だよ」みたいなエラーが出ます。 これを回避するために、 lib/shuffle.rblib/shuffle/version.rb を修正しておきましょう。

参考:implement extension · genya0407/shuffle@4389ef1 · GitHub

作った gem を利用する

RubyGems に publish するのも何なので、 github からインストールしてみます。 先程の shuffle gem を github に push しておきつつ、以下のような Gemfile を作って bundle install してみましょう。

$ cat Gemfile
# frozen_string_literal: true

source "https://rubygems.org"

gem 'shuffle', github: 'genya0407/shuffle', branch: 'trunk'

$ bundle install
Fetching https://github.com/genya0407/shuffle.git
Fetching gem metadata from https://rubygems.org/.
Fetching rake 13.0.6
Installing rake 13.0.6
Using bundler 2.3.15
Fetching rake-compiler 1.2.0
Fetching rb_sys 0.9.4
Installing rb_sys 0.9.4
Installing rake-compiler 1.2.0
Using shuffle 0.1.0 from https://github.com/genya0407/shuffle.git (at trunk@f24fe1f)
Bundle complete! 1 Gemfile dependency, 5 gems now installed.
Bundled gems are installed into `./vendor`

$ bundle exec irb
irb(main):001:0> require 'shuffle'
=> true
irb(main):002:0> Shuffle.new('abcdefg').shuffle
=> "gdecafb"

このように、 gem として利用できることが確認できました。 なお、利用者側の環境にも Rust が必要である点に注意して下さい。

magnus に関する雑感

magnus の型変換がちゃんとしててすごいと思いました。 感動ポイントを箇条書きします*4

  • 関数の型宣言を見て、勝手に型変換をやってくれる
  • 型変換ができないときは手動でも変換できる
  • Ruby のオブジェクトと Rust の構造体の紐付けを勝手にやってくれる
    • C拡張みたいに TypedData_Wrap_Struct とかしなくていい。

全体的に、「そうなってほしい」という思いが達成されていて、おまじないが少ない作りになっているのが良いです。

まとめ

拡張ライブラリを実装したくなる機会は現状ではそれほどないと思いますが、このレベルまで簡単に実装できるようになったことで、Rust の拡張ライブラリを作るという選択肢が一般的になってくると Ruby の将来という観点でも面白い と思いました。

例えば、 GitHub - genya0407/reing_print_image は、 GitHub - genya0407/reing_text2image を拡張 gem にしたものです。

reing_text2image はもともと、 Rails で作った 自作質問箱 でOGPの画像を生成するために作ったツールでした。

以前はこれをCLI ツールとしてRuby から呼び出して、一度ディスクに画像ファイルとして書き出したものを Ruby から read して send_data で返却するという、結構すごい作りをしていました。

しかし、Rust を使った拡張 gem が手軽に作れるようになったことで、ファイルを介すことなく、png のバイナリを直接 Rust → Ruby で受け渡せるようになりました。

reing_print_image/reing_print_image_spec.rb at trunk · genya0407/reing_print_image · GitHub

このように、全体的には RubyRails で実装しておき、無理なところだけ Rust に切り出す、ということができるようになったことで、Ruby が関与できる世界が広がっていく*6のではないかと思いました。

Rust で拡張ライブラリを作るのは思いの外面白かったので、他にもネタを見つけてやっていきたいと思います。

*1:RubyGems 3.3.11 を利用するため

*2:Rust extension support を利用するため 3.3.11 Released - RubyGems Blog

*3:magnus がサポートしているのが 1.57.0 以上なので GitHub - matsadler/magnus: Ruby bindings for Rust

*4:筆者は拡張ライブラリを真面目に書いたことがないので、的はずれなことを言っている可能性も高いですが...

*5:Rust の String は UTF-8 として valid な文字列しか入らない

*6:あるいは縮小を緩和できる