2023年5月を振り返る

オクトパストラベラー2をクリアした

クリアしました。裏ボスは倒してないけど、これ以上進めない気がする(飽きたので)。

やはり、戦闘システムはとても良いが、アウトゲームやストーリーはあんまり好みに合わないなあという感想ではある。エルデンリングのときも思ったけど、「自分の思った順序で攻略したい」という欲求が自分にはあんまりない。

ギター買った

渋谷の楽器屋さんでこのギターを買いました(色は違う)。

雰囲気でクリープハイプの曲を弾いてみたり、気まぐれに入門書を読んで練習してみたり、そういった活動を行っております。

肉体を複雑に操作する活動をするのは久しぶりで脳が疲れる。シャル・ウィ・ダンス の序盤で役所広司がステップを踏めなくてテンパってるときに近い感情を日々味わっている。

全体的に学生のときよりもちょっとだけ上達してきてる感触がある。

出社強化月間を自主的に開催した

出社は良い(通勤時間を除けば)という結論である。

今は全体の出社比率が低く、人口密度が低いために特にそう感じるということはあるかもしれない。

読んだ本

大岳鍾乳洞

鍾乳洞を見に行った。

【公式】大岳鍾乳洞 大岳キャンプ場

なかなか良い観光スポットでした。

これは道中にあったエモいトンネルの写真です。

鍾乳洞本体は険しすぎて写真を取れなかった。

オーケストラのコンサートに行った

www.nhkso.or.jp

オーケストラのコンサートに行くのは多分人生で初めてだったんだけど思いの外良かった。

今月のまとめ

 

ABC size を可視化し、闇を払う

これは Ruby Advent Calendar 2022 の7日目の記事です。

TL; DR

ABC size を可視化する abc_size_visualizer という gem を作りました。 こんな感じで、メソッドの各行がどれだけ ABC size の増加に貢献しているかを可視化する CLI ツールです。

赤が代入、黄色がメソッド呼び出し、青が条件式の数を表しています。

また、各行の末尾にある # <0, 2, 1> のようなコメントも abc_size_visualizer が付加したもので、それぞれ代入、メソッド呼び出し、条件式の大きさを表しています。

使い方

以下のコマンドでインストールできます。

$ gem install abc_size_visualizer

そして、以下のコマンドで ABC size の可視化を実行します

$ visualize_abc_size some_code.rb
...
# ABC size の情報が付与された some_code.rb の内容が出力される

背景

abc_size_visualizer を実装するに至った背景を説明します。

ABC size はどのように理解されているか

多くの Rubyist は、以下のような警告によって ABC size を知ります。

$ bundle exec rubocop
(略)
Metrics/AbcSize: Assignment Branch Condition size for auth is too high. [20.5/15]

このような警告に直面した人々は、インターネットを検索して回った後、おもむろに .rubocop.yml を編集し、ABC size に関する警告を「解決」します 1

Metrics/AbcSize:
 Max: 100

このように、一般的に ABC size は、

  • なんかよくわからないけど rubocop が怒ってくるもの
  • (ホントは良くないと分かっているが... という disclaimer 付きで)無視するもの

と理解されています。

ABC size とは何であるか

では、そもそも ABC size とは何でしょうか?

ABC size は、代入(assignment)、メソッド呼び出し(branch)、条件式(condition)の出現回数をそれぞれ2乗して和をとり、その平方根を計算したもの2であり、コードの「サイズ」を表す指標です3

定義を述べてもよくわからないと思うので、例を挙げます。以下のメソッドを考えると、

def some_method
  if [true, false].sample # if による条件式(C)、sample メソッドの呼び出し(B)
    puts "#{100 + 200}" # puts メソッドの呼び出し(B)、 `+` メソッドの呼び出し(B)
  end
end

ABC size は、

 \displaystyle
\sqrt{A^2 + B^2 + C^2} = \sqrt{0^2 + 3^2 + 1^2} \simeq 3.2

となります。

ABC size の問題点

ABC size の問題点は分かりづらいことにあります。

例えば、以下のコードには「メソッド呼び出し(branch)」が何回出現するでしょうか?

puts user_name

正解は..................「わからない」です!

なぜなら、 user_name がローカル変数なのかメソッド呼び出しなのか、この一行だけでは判定できないからです。

ABC size は、メソッド全体の文脈を加味しないと算出することができず、見た目が全く同じコード断片であっても、文脈によって ABC size は全く異なる可能性があります。

def some_method1
  puts user_name # user_name というローカル変数がないので user_name はメソッド呼び出しである
end

def some_method2(user_name:)
  puts user_name # user_name というローカル変数が定義されているので user_name はメソッド呼び出しではない
end

ほかにも、ABC size には非自明なケースがいくつも存在します。

  • メソッドの引数は assignment としてカウントされるか?
  • &.some_method は condition としてカウントされるか? branch としてカウントされるか?
  • rescue SomeError => e は condition としてカウントされるか? assignment としてカウントされるか?
  • else は condition としてカウントされるか?
  • ||= は condition としてカウントされるか? assignment としてカウントされるか?
  • > は condition としてカウントされるか? branch としてカウントされるか?
  • デフォルト値付きのキーワード引数は assignment としてカウントされるか? condition としてカウントされるか?

また、これらの非自明なケースはドキュメント等にも特に記載がありません。 そもそも、Rubocop の Metrics/AbcSize では ABC の内訳は表示されません。

そのため、ABC size 削減チャレンジは、

  1. 当てずっぽうで色々書き換える
  2. rubocop に怒られないことを祈る
  3. 怒られたら 1 に戻る

という方法で行われる4ことになり、不毛度が高いです。

abc_size_visualizer で ABC size の闇を払う

abc_size_visualizer の存在する世界では、ABC size 削減チャレンジは

  1. ABC size を可視化し、ABC size が大きい原因を理解する
  2. 問題となっている行を書き換える
  3. rubocop に怒られないことを確認する

というステップで実施することができます。

また、rubocop に鍛えられて Ruby のベストプラクティスを学ぶように、人間側が ABC size をより深く理解するようになり、abc_size_visualizer がなくても適切に ABC size を削減できるようになっていくことでしょう。

実装方法とその問題点

abc_size_visualizer は、rubocop の内部で利用されている AbcSizeCalculator クラスを流用して実装しています。

これは、rubocop の ABC size 計算と実装をなるべく一致させることを意図したものですが、今後 rubocop 側の実装が変更されたときに追従できない可能性が高いため、なんらかのうまいやり方を考える必要があるナアと思っています...

まとめ

ABC size を可視化する CLI ツールを実装しました。 これにより、rubocop の Metrics/AbcSize の闇を払い、Rubyist の皆さんをサポートすることができれば嬉しいです。

また、この CLI ツール自体の実装には闇が多いため、どうにかマトモにしていきたいなと考えています。


  1. # rubocop:disable Metrics/AbcSize によって「解決」することもある。
  2. https://nacl-ltd.github.io/2016/02/23/ruby-abcmetrics.html から。
  3. https://en.wikipedia.org/wiki/ABC_Software_Metric から。ソースコードの「複雑さ」や「計算量」を表す値ではないことに注意。
  4. ABC size を完全に理解している人は別ですが

Minutus という mruby の Rust バインディングを作った

このところ、夏休みの自由研究として「mruby と Rust をいい感じにつなぎこむ」というのをやっていました。

github.com

お盆休みのすべてを費やし、なんとか「実用可能」といえそうなレベル*1まで来たので、この記事で簡単に説明したいと思います。

(↓は Matz にリツイートされてとても嬉しかったツイート)

Minutus とは

Minutus は、Rust と mruby をいい感じに連携するためのライブラリです *2

Minutus を使うと、Rust の中で mruby のスクリプトを eval できます。以下のようなイメージです。

gist.github.com

minutus/minutus-test/src を覗くと、他にも多くの例を見ることができます。
例えば、mruby のメソッドを Rust から呼べたりします。

また、それとは別に、 mrbgem (mruby 用の ライブラリ) を Rust で作ることもできます。
minutus/examples/mruby-polars はその例で、Rust のデータフレームである polars を mruby に(全く不完全な形で)ポートした mrbgem です。

Minutus で達成したいこと

Minutus で達成したいことは2つあります。

  1. 最新の mruby (3系) で動く
  2. 利用者が難しいことを考えずに、とにかく mruby と Rust を接続できる

これらを目指す理由を次で述べます。

mruby と Rust を接続する際の問題点

人類には「Rust で作ったツールに mruby の DSL を入れたい」という普遍的な欲求がある、ということは広く知られています。また、mruby のネイティブ拡張を Rust で書きたいという人類も多く存在していると思います。

しかし、その実現には高いハードルがあります。

まず、新しい mruby (3系)で動く Rust / mruby のバインディングは存在しません。例えば、mrusty がサポートする mruby のバージョンは 1.2.0 です。現在の mruby の最新のバージョンは 3.1.0 なので、これは相当古いと言えるでしょう*3*4

かといって、mruby の C API を、 Rust から直接触るのは茨の道です。

  • 避けられない unsafe 祭り
  • 自前で mruby をビルドし、リンクする必要がある
  • mruby の C API に習熟する必要がある
    • つまり、Rust も mruby も書ける、というだけではダメ
  • mruby の C API はマクロの形態で提供されるモノが多く、bindgen が認識してくれない*5
  • mruby から取り出した値を Rust の型に変換するのは面倒
  • Rust で作ったバイナリを mrbgem にするのは地味にハマりどころが多い
    • src/ 以下に C のソースコードがないと(おそらく) _init 関数が呼ばれない
    • mrbgem のインストール時に C言語以外のバイナリをビルドさせる正式な方法が(おそらく)ない*6

むかし私もこの問題にぶち当たり、Rust に mruby を埋め込むのを諦めたことがあります。
Rubyで設定を書けるLinux用キーマッパー 「rumap」をRustで作った - さんちゃのblog

Minutus の工夫

これらの問題を受け、最新の mruby で動き、かつ mruby の C API に詳しくなくても使える mruby / Rust のバインディングが必要だと感じました。

特に後者に関しては、以下のような工夫をしています。

  • mruby と接続するためのコードを生成するマクロを提供する
  • mrbgem のテンプレートを生成する CLI ツールを提供する
  • 実装例を提供する 
    • 前述の CLI ツール自体も Minutus の利用例となっています

これらの工夫により、mruby の C API に詳しくない人でも、気軽に mruby と Rust を接続できます。

Minutus の課題

「実用可能」といえるレベルにはなったと思うものの、Minutus には様々な問題が残っています。

  • GC と向き合えていない
    • 基本的には正しく実装できているはずで、利用中の値がうっかりGCされることはおそらく無いが、メモリリークしていない自信がない
    • evaluate 関数の実装に使っている mruby_load_string の返り値に対する GC の挙動がよくわからない
  • mruby のメソッドを Rust から安全に呼ぶ方法がない
    • 現状では、`define_funcall!` で定義した関数で問題が発生するとパニックするようになっている
    • これはマクロを改良したらいい感じにできるはずなので、暇を見つけて直したい
  • キーワード引数のある mruby のメソッドを呼ぶ方法がない
    • Rust の関数にはキーワード引数がなく、`define_funcall!` に渡すシグネチャが Rust の文法からどうしても乖離してしまうため、マクロの文法をどうするか悩んでいる
    • mruby 側に適当なメソッドを定義すれば、そのメソッド経由で呼び出すことはいちおう可能
  • マクロのエラーメッセージが雑すぎて文法ミスをデバッグできない
    • 例えば `define_funcall! { fn hoge(i64) -> i64 }` はコンパイルが失敗するが、`unexpected token` みたいなエラーメッセージしか出ないため、何が悪いのか全然わからない*7
  • Rust のモジュールシステムと組み合わせて動かせるのか未検証
    • `define_funcall!` や `wrap` と、それらを利用するコードが同じファイルにまとまってるパターンしか検証しておらず、それらが別モジュールに分離したときにうまく動くか怪しい
  • パフォーマンス
    • mruby と Rust の世界で値をやり取りするときの関数呼び出しの回数がエグいし、inline 展開とかもしてない
  • `wrap` で生成したクラスに initialize_copy を実装してないので、たぶん dup すると死ぬ
    • やるだけなので暇なときにやる
  • 実用的な例が存在しない
    • Rust の資産を mruby にポートするというようなことをやって、minutus 自体の欠陥を洗い出して洗練させつつ、minutus 利用者が参考にできるようにしたい
    • 例えば actix-web の mruby ラッパーを作りたいなと思っている

書き出してみると色々ありますが、暇を見つけて一つづつ潰していこうと思っています。

おわりに

この記事では、mruby と Rust をいい感じに接続するためのライブラリである Minutus について説明しました。

個人的には割と良いコンセプトのものができたと思っているので、興味のある方は使っていただけるととても嬉しいです。
こんなことはできないの?とか、ここがイケてないとか、ドキュメントが分かりづらいとか、なんでも良いのでフィードバックをいただけると泣いて喜びます。

以下の記事で言及されている通り、mruby 界隈は、特に周辺のエコシステムが古くなりつつあり*8、厳しい気持ちになることもあるのですが、mruby 自体は触っていてとても面白いので、盛り上げていけたらいいなと思っています。

udzura.hatenablog.jp

以上

*1:割と限定された用途ではあるが...

*2: Ruby の Rust バインディングである Magnus をめっちゃ参考にしました

*3:2系に追従しようとしている人もいるが、PR がスルーされている Support mruby 2.1.0 by wordijp · Pull Request #100 · anima-engine/mrusty · GitHub

*4:頑張って探したら他にもあるかもしれないが、サッと見た感じでは無さそう

*5: see: Improve macro defined constants to recognize #define CONSTANT ((int) 1) · Issue #316 · rust-lang/rust-bindgen · GitHub

*6: つまり、mrbgem をインストールするときに `cargo build --release` を実行させる方法がない

*7:このケースでは、引数名を書き忘れていることが原因。そもそも引数を省略しても動くようにしろという話もある。

*8: mrusty が 1.2.0 しかサポートしていないのはその一例

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:あるいは縮小を緩和できる