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_MODULE
と struct 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
メンバを経由して、ここに irep
と attached_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;
};
リファインメントを実現するためのデータ構造を整理・把握するために図を作りました。作業を投げ出した後で再開する時にとても有用です。
コールスタックの増加を抑えよう
有効化したリファインメント情報を保持する 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 でもモジュールに対してリファインメントを対応させました。
特に悩んだのは prepend
と super
を組み合わせた場合のメソッド探索です。作業をちょっとして数ヶ月投げ出してを何度か繰り返し、結局満足行く出来になるまで1年を越えることになりました。
根本的な原因は継承やミックスインの実現方法を分かった気になっていたことです。そのため、途中でどのようにポインタが継っているのかを mrb_prepend_module()
の動作から確認することにしました。
これでもまだ試行錯誤を繰り返しましたが、どうにか満足の行く結果を得ることが出来るようになりました。
そのあと忘れていた CRuby の挙動と確認してみたら……あれ、結果が違う?
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_config
に conf.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_SEND
を OP_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://secret-garden.hatenablog.com/entry/2018/12/03/231925
- https://secret-garden.hatenablog.com/entry/2018/12/25/235407
それと公式によるリファインメントの仕様書も改めて確認する必要があります。
https://bugs.ruby-lang.org/projects/ruby-trunk/wiki/RefinementsSpec
一番難しい課題はテストを書くことですね。
で、いつ来るの?
この先1年の間にはプルリクエストを送れるようにしたいなあ……。