0
0

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.

RubyAdvent Calendar 2021

Day 24

mruby でもリファインメントを使いたい!!!

Posted at

Rubyist の皆さんはリファインメント機能が好きですよね?僕も大好きです。

ですが残念なことに mruby では実装されておらず使えません。

まあ、実装されていないだけならば実装してみようと思い立ち、作業を始めてすでに3年ほどが経ったと思います。その作業途中で行き詰まって数ヶ月停滞することが何度もありましたが、それなりに完成してきたこともあってどこまで出来ているのかをまとめてみようと思いました。

なおすでにその途中の成果の一部は本当の背景を明かさずにそれっぽい理由をつけてプルリクを送ったところ、だいたい取り込まれています😋。

直近だと OP_ASET に関する「Fixed a discrepancy in OP_ASET #5579」がそれに当たります。これは Symbol#to_proc メソッドの再実装作業中に見つけたものです。キーワード引数が引き継がれないことがある問題に関しても、この作業途中で発見したことでした。

早速使ってみよう

使ってみようとは言っても、まだ本家には取り込まれていません。開発途上版であれば 僕の開発ブランチ から試すことが出来ます。

リファインメント機能は mrbgems/mruby-refinement を取り込んで使うようになっています。つまり既定のままビルドすると「なにも出来ねーじゃん」状態となってしまうので気をつけて下さい。なので build_config へ明示的に追加する必要があります。

MRuby::Build.new do
  toolchain
  gembox "default"
  gem core: "mruby-refinement"  ## 超大事なオマジナイ!!!
end

このように超大事なオマジナイを追加してビルドすれば、ESP32 上でもリファインメントが使えるようになります!

なお開発ブランチでは一時的に mrbgems/mruby-refinement を有効としているので、そのままビルドするだけです。

% curl -L https://github.com/dearblue/mruby/archive/refs/heads/refinements.tar.gz | tar xf -
% cd mruby-refinements
% rake -m

ビルドできたらすぐにお試し出来ます!

% cat <<RUBYCODE > test.rb
class Girl
  def age = 12
  def place = "小学校"
end

module Fake
  refine Girl do
    def age = 20
    def place = "ふくしの……大学?"
  end
end

girl = Girl.new
2.times do
  puts "#{girl.age}歳なんですけど! #{girl.place}に通ってるんですけど!"
  using Fake
end
RUBYCODE

% bin/mruby test.rb

欠点

RAM をいっぱい消費するようになります。すぐ上で ESP32 でも使えると書きましたが、RAM が 1 MiB 以下の機器で使うことはおすすめできません。それにリファインメントが有効な範囲ではメソッド探索が遅くなる上に、メソッドキャッシュ処理も迂回されます。

メソッドを定義できるようにしよう (課題あり)

メソッドを登録するのであれば struct RClass を使うようにすればいいはずです。MRB_TT_MODULE と区別するために MRB_TT_REFINEMENT を追加したのは作業を始めた最初期の頃でした。

リファインメントオブジェクトが include / extend 出来ることを知らなかったので、まあしゃあない。そのうち MRB_TT_MODULEstruct RClass::flags |= MRB_MODULE_REFINEMENT みたいな形に変えなきゃ。

module M
  R = refine Object do
  end

  module R  # => M::R is not a module (TypeError)
            # CRuby は問題なし
  end
end

リファインメント情報を保存しよう

using メソッドで有効化させますが、どうやってその情報を保存させるかは悩みどころでした。

リファインメントはスコープ途中で動的に有効となります。その仕組み上、現在呼び出し中のスコープでリファインメント情報を更新する必要があります。どうしても呼び出し情報 (mrb_callinfo) にメンバを増やす必要がありますが、こちらはまだいいです。

メソッドとして登録されることを考えるとブロック (struct RProc) に保存する必要がありますが、追加する余地がありません。

なので、struct RProc::body メンバに目をつけて、これを拡張しました。リファインメントが有効な時は struct RProc::body.extra メンバを経由して、ここに irepattached_refinements を保持するための構造体を新設します。

これまでは struct RProc::body.irep メンバを直接参照することがありましたが、body.direct_irep という名前に変更しました。struct RProc::body.irep を直接参照するのではなく、MRB_PROC_IREP() マクロを使うようになります。

こうすることで struct RProc::body.extra を想定していない古いソースコードはコンパイルエラーになります。互換性は犠牲になりました。

struct RProc {
  MRB_OBJECT_HEADER;
  union {
    const mrb_irep *irep_direct;    // irep から変更❕
    mrb_func_t func;
#ifdef MRB_USE_REFINEMENT           // 🆕❕
    struct mrb_proc_extra *extra;   // 🆕❕
#endif                              // 🆕❕
  } body;
  const struct RProc *upper;
  union {
    struct RClass *target_class;
    struct REnv *env;
  } e;
};

リファインメントを実現するためのデータ構造を整理・把握するために図を作りました。作業を投げ出した後で再開する時にとても有用です。

refinements.png

コールスタックの増加を抑えよう

有効化したリファインメント情報を保持する 1 ワード (struct RArray *) を mrb_callinfo へと追加したことを前述しました。

作業を始めた時期が mruby-1.4.1 の頃だとすると、この時点で sizeof(mrb_callinfo) は 32 ビット CPU で 12 ワードです。ここでさらに 1 ワードを追加することは躊躇してしまいますね。mruby-2.1.2 の時点では 10 ワードですが、この頃には減らす算段がついていました。

ある時 mruby のリポジトリへファイバーに関する問題が報告され、いい切っ掛けと思って送ったプルリクエストが「Reorganize mrb_callinfo #5272」です。この変更によって、6 ワードにまとめることができました。これなら 1 ワードくらい増えても文句が出にくいことでしょう。なお MRB_USE_REFINEMENT が未定義であれば増やさずに 6 ワードを維持します。

子スコープ・ブロックへリファインメント情報を伝搬させよう

新しいブロックやスコープが生成された場合、リファインメント情報を継承します。現在の呼び出し情報 (mrb_callinfo *ci = mrb->c->ci) からリファインメント情報を取得し、mrb_proc_new() (あるいは mrb_closure_new()) にそのまま渡します。このリファインメント情報は、リファインメントが定義されている配列オブジェクトである struct RArray へのポインタです。

ここでモジュールやクラスのスコープでリファインメントの適用範囲が変わってくることを思い出して下さい。配列オブジェクトを共有することは望ましくないと思うかもしれません。

親スコープも子スコープも共有できるうちは共有して、#using メソッドが呼ばれたらその中でコピーと追加を行うようにしました。そのため #using が連続する場合、一時的に配列オブジェクトのコピーが多発することになります。

このあたりも多くの RAM を必要とする理由になります。

refine ブロックの中で自身のリファインメントを影響させよう

#refine メソッドに与えたブロックの内部では、それ自身で定義したメソッドも呼び出せる仕様になっています。つまり暗黙のうちに #using メソッドが限定的に使われているわけですね。

mruby では Ruby 空間で呼び出し情報 (mrb_callinfo *ci) を細工出来ないので、C 空間で ci をいじってから渡されたブロックに制御を渡しています。

ところで mruby だとメソッドの可視性が常に public なので、直接 Kernel.refine を呼んでリファインメントを定義できてしまいますよね。

どうにかしようかなと思いましたが、Ruby-3.1 preview1 で

class Module
  public :refine
end

Kernel.refine Object do
  def ほげ
  end
end

というコードで確認したら、まあいいかという気になってそのままです。

refine メソッドの戻り値をリファインメントオブジェクトにしよう (課題あり)

Module#refine メソッドの戻り値はリファインメントオブジェクトです。引数として与えたブロックの戻り値ではありません。

すでに普通のブロック呼び出しを行えないことは述べました。なのでさらに特殊な呼び出し方を行っています。

mrb_irep_exec() による末尾呼び出しを用いますが、さらに手を突っ込んで予め return self するだけのブロックで refine メソッドの呼び出しをすり替え、その上にブロック引数を呼び出すようにしています。こうすることでブロックの戻り値に関係なくリファインメントオブジェクトを返すようになっています。これは以前 Binding を実現するときに取ったフックブロックのような手法です。

ただ、これを書いている時に mrb->c->ci->stack を一つずらすだけでリファインメントオブジェクトを返せるなと思い至りました。そうすれば mrb_cipush() を露出させる必要もなくなるし。

モジュールでもリファインメントを使えるようにしよう (課題あり?)

Ruby-2.4 からはモジュールに対してもリファインメント機能が有効になっています。mruby でもモジュールに対してリファインメントを対応させました。

特に悩んだのは prependsuper を組み合わせた場合のメソッド探索です。作業をちょっとして数ヶ月投げ出してを何度か繰り返し、結局満足行く出来になるまで1年を越えることになりました。

根本的な原因は継承やミックスインの実現方法を分かった気になっていたことです。そのため、途中でどのようにポインタが継っているのかを mrb_prepend_module() の動作から確認することにしました。

class.png

これでもまだ試行錯誤を繰り返しましたが、どうにか満足の行く結果を得ることが出来るようになりました。

そのあと忘れていた CRuby の挙動と確認してみたら……あれ、結果が違う?

refine-super.rb
module A
end

module M
  refine A do
    def downcase
      "<<#{super}>>"
    end
  end
end

class String
  prepend A
end

using M
p "ABCDEFG".downcase
% bin/mruby refine-super.rb
"<<abcdefg>>"

% ruby31 refine-super.rb
refine-super.rb:7:in `downcase': super: no superclass method `downcase' for "ABCDEFG":String (NoMethodError)
Did you mean?  downcase!
        from refine-super.rb:17:in `<main>'

gem に分離しよう

最近になってリファインメント機能の大部分をまとめてコアから分離できることに気が付きました。ソースコードの見通しも良くなるし、build_configconf.defines << "MRB_USE_REFINEMENT" とするよりは conf.gem core: "mruby-refinement" とした方が好ましいだろうという判断もあります。

なので、リファインメント関連の以下のメソッドは gem で定義されます。

  • main#using
  • Module#using
  • Module#refine
  • Module.used_modules

そのため mruby のコアには、リファインメントを実装するためにどうしても必要となる拡張部分のみが残ることとなりました。その拡張部分は多くが #ifdef MRB_USE_REFINEMENT 〜 #endif で囲まれているので、リファインメントを使わなければバイナリサイズの増大は最小限に抑えられているはずです。正確な比較はそのうち……。

Symbol#to_proc をリファインメントに対応しよう

Ruby ではシンボルをブロックとして与えることが出来ます。

p [1, 2, 3].reduce(&:+)

この時の + メソッドはリファインメント対象となります。

mruby において Symbol#to_proc メソッドは mrblib/symbol.rb ファイルで定義されていますが、ファイル拡張子から分かるように Ruby で書かれています。ここで生成されるラムダオブジェクトはリファインメントの影響を受けるブロック呼び出しが行なえません。

# 引用元: https://github.com/mruby/mruby/blob/39191cd9fadfea4940298cbcac2533597f76aa71/mrblib/symbol.rb#L2-L6

  def to_proc
    ->(obj,*args,**opts,&block) do
      obj.__send__(self, *args, **opts, &block)
    end
  end

リファインメントを有効にした状態のラムダオブジェクトを返せばいいので、Symbol#to_proc は C で再実装し、生成されるラムダオブジェクトに細工をすれば目的は達成できます。このラムダオブジェクトは Ruby で書いたものを bin/mrbc でコンパイルしたバイトコードから構成されています。

000 OP_ENTER     1:0:1:0:0:0:1 (0x41001)
004 OP_MOVE      R4  R1         ; R1:obj
007 OP_LOADSELF  R5
009 OP_ARRAY     R5  R5  1
013 OP_MOVE      R6  R2         ; R2:args
016 OP_ARYCAT    R5  R6
019 OP_MOVE      R6  R3         ; R3:block
022 OP_SENDB     R4  :__send__  n=*|k=* (0xff)
026 OP_RETURN    R4

だけどスプラッタ引数のための配列オブジェクトを再利用できないかな? ということで新しい命令を加えて、ハンドアセンブルします。

000 OP_ENTER     0:0:1:0:0:1:1 (0x1003)
004 OP_JMPEMPTY  R1  +17 ; to 025
008 OP_AREF      R4  R1  0
012 OP_ASET      R0  R1  0
016 OP_MOVE      R0  R4
019 OP_SSENDB    R0  :__send__  n=*|k=* (0xff)
023 OP_RETURN    R0

025 OP_GETCONST  R1  ArgumentError
027 OP_STRING    R2  L[0]  ; "no receiver given"
030 OP_RAISEEXC  R1  (R2)
032 OP_RETURN    R0  ; not reached

ここで2つの新命令を追加しました。レジスタのオブジェクトが空 (obj.empty?) であれば分岐する OP_JMPEMPTY 命令、そして指示された例外を発生させる OP_RAISEEXC 命令です。

最初にちょろっと紹介した OP_ASET 命令を使っているのがわかるでしょうか?

この時はいいアイディアだと思いましたが、間もなく気が付きます。__send__ メソッドまでリファインメントの餌食となってしまうことに。

それで結局は次のようになりました。

000 OP_ENTER     1:0:1:0:0:1:1 (0x41003)
004 OP_FORWARD   R1.__send__(R0, *R2, **R3, &R4)
005 OP_RETURN    R0

OP_FORWARD 命令を追加していますが、とてもシンプルです。Symbol#to_proc に関してはこれでよさそう。

Proc のリファインメント情報を更新しよう

2.times {
  a.namamugi
  using Namagome
}

こんなコードがあった場合、一度目は普通の #namamugi メソッドが呼ばれます。二度目はリファインメントが効いた状態の #namamugi メソッドが呼ばれます。

Proc オブジェクトが ROM に置かれていたら対応できませんが、書き換え可能であれば対応するようにしました。

文字列展開される時の暗黙の変換に対応しよう (課題あり)

Ruby では文字列の中でオブジェクトが暗黙的に #to_s されますが、これを行う時の処理をどうするか脳内で審議中です。とりあえず OP_SENDOP_STRCAT の前に追加するようにしていますが、正直望ましくないよなあということでこの案は没になります。

# "1#{2}3"

000 OP_STRING   R1  "1"
003 OP_LOADI_2  R2
005 OP_SEND     R2  :to_s  0  ; 本当はいらない
009 OP_STRCAT   R1
011 OP_STRING   R2  "3"
014 OP_STRCAT   R1            ; => "123"

その後、OP_SEND 命令の代わりに OP_TOSTRING 命令を追加しましたが、やっぱり望ましくありません。

現在は OP_STRCAT で文字列以外 (mrb_type(obj) != MRB_TT_STRING) が与えられたら、VM の中から #to_s を強制するようなブロック呼び出しを行い、再度 OP_STRCAT を実行するようにしようかなと思案中です。この時変換しても String インスタンスでなければ OP_STRCAT で永久ループになるため、型の確認を行う新規命令も必要そうです。

000 OP_SEND      R0  :to_s  0
004 OP_GETCONST  R1  :String
007 OP_CHECK     R0  (R1)     ; 新規命令
009 OP_RETURN    R0

でも String 定数を入れ替えられたらすぐに無限ループするし、代わりに enum mrb_vtype を使うと mruby/c のような第三者実装系で問題になりそうだしどうしたもんだか……。専用命令にするしかなさそう?

#const_missing メソッドにもリファインメントを適用しよう (課題・放置中)

mruby 仮想機械の中では定数を取得する時に OP_GETCONST あるいは OP_GETMCNST という命令が使われます。この命令は mrb_vm_const_get() または mrb_const_get() という関数を呼び出すだけです。その関数の中で定数が見当たらなければ、mrb_funcall() を通して #const_missing メソッドが呼び出されます。

これは別のブランチで mrb_vm_exec() 関数の再呼び出しを避ける目的で書き換え作業中となっています。機能的には問題がない状態ですが、全体的なパフォーマンスが5%〜10%落ちるという問題があって、現在絶賛放置中です…………。

<閑話>

mrb_vm_exec() 関数の再呼び出しを避ける関連でもう一つ。

case obj; when *ary; ...; end みたいなコードがある時、when *ary の部分は #__case_eqq メソッドが使われます。

OP_SEND  R2  :__case_eqq  n=1 (0x01)

この #__case_eqq メソッドの実体である src/kernel.c:mrb_obj_ceqq() を見てみると、内部で mrb_funcall() を経由した mrb_vm_exec() への再呼び出しが確認できます。これを専用命令 OP_CASE_EQQ (仮) で仮想機械上のメソッド呼び出しに置き換えてやれば、mrb_vm_exec() への再呼び出しを抑制できるかもしれません。というか、これを書いていたら、ここもリファインメントが適用されてしまうことに気が付きました。

リファインメントの効かない特殊な OP_SEND が必要となるかも……。

</閑話>

その他の課題を確認しよう

まだ全く手を付けていないものもまだあります。

  • #method メソッド
  • #methods メソッド
  • #respond_to? メソッド

他にもあるけど、それは追々 @pink_bangbi さんのブログを確認しながらにします。

それと公式によるリファインメントの仕様書も改めて確認する必要があります。
https://bugs.ruby-lang.org/projects/ruby-trunk/wiki/RefinementsSpec

一番難しい課題はテストを書くことですね。

で、いつ来るの?

この先1年の間にはプルリクエストを送れるようにしたいなあ……。

0
0
0

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
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?