はじめに
タイトルにもあるようにRubyのbuiltin(正式名称を知らないので呼び出し方法から拝借)というものを使ってRuby自体をRuby(とC)で実装してみた話です。
内容としてはRuby自体の実装に興味のある方向けの話になります。
builtinって?
builtinとはRuby(とC)でRuby自体を実装するというものです(正式な名前は今のところないみたい?)。以下のように__builtin_<Cで定義した関数名>
をRubyのコードから呼び出すことでRubyとCを使い、より簡単にRubyの実装を行うことができます。
たとえば、Hash#delete
はCで以下のように実装されています。
static VALUE
rb_hash_delete_m(VALUE hash, VALUE key)
{
VALUE val;
rb_hash_modify_check(hash);
val = rb_hash_delete_entry(hash, key);
if (val != Qundef) {
return val;
}
else {
if (rb_block_given_p()) {
return rb_yield(key);
}
else {
return Qnil;
}
}
}
第一引数のhash
はハッシュ自体を引数に受け取り、第二引数のkey
はHash#delete
で渡しているキーを受け取っています。ちなみに、Ruby側の変数などの値はVALUE
型で受け取り、Cの関数で処理されています。
rb_hash_modify_check(hash);
rb_hash_modify_check
関数は内部でrb_check_frozen
関数を実行し、ハッシュが凍結されているかを確認しています。
static void
rb_hash_modify_check(VALUE hash)
{
rb_check_frozen(hash); // オブジェクトが凍結されているか確認
}
val = rb_hash_delete_entry(hash, key);
では引数に受け取ったキーをもとに削除する値を取得し、同時に削除を行っています。キーと対になる値がない場合はQundef
というCで使用する未定義の値が入ります。
if (val != Qundef) {
return val;
}
else {
if (rb_block_given_p()) {
return rb_yield(key);
}
else {
return Qnil;
}
}
val
の値で処理を分岐させ、Qundef
ではない場合(つまりキーを使って値が取れ、削除できた場合)は削除された値を返します。
Qundef
だった場合はQnil
(Rubyでのnil
)を返します。ブロックが渡されている場合はrb_yield(key)
を実行し、その結果を返しています。
このように皆さんが普段使っているRubyは、Cを使って実装されています。
builtin機能を使うことで先ほどのコードが以下のようになります。
class Hash
def delete(key)
value = __builtin_rb_hash_delete_m(key)
if value.nil?
if block_given?
yield key
else
nil
end
else
value
end
end
end
static VALUE
rb_hash_delete_m(rb_execution_context_t *ec, VALUE hash, VALUE key)
{
VALUE val;
rb_hash_modify_check(hash);
val = rb_hash_delete_entry(hash, key);
if (val != Qundef) {
return val;
}
else {
return Qnil;
}
}
Ruby側でブロックの実行などを処理させているため。Cでの実装はよりシンプルで読みやすくなったと思います。
また
このようにbuiltin機能を使うことでRubyと少しのCのコードでRubyを実装することができます。
またCで実装するよりもRubyで実装した場合、パフォーマンスが向上するケースもあるようです。
より具体的な話は笹田さんがRubyKaigi 2019にて話されていますのでそちらを参照して頂ければと思います。
Write a Ruby interpreter in Ruby for Ruby 3
やってみた
builtinを使うことでRubyのコードを使い、メソッドを実装できることがわかったので実際にやってみました。
開発環境構築
まずはRubyの開発環境を作成するところからはじめました。僕の環境としてはWSL+Ubuntu 18.04を使い、開発環境を構築しました。
基本的な手順としてはRuby Hack Challengeの(2) MRI ソースコードの構造を参考に進めました。
まずは使用するライブラリなどをインストールしていきます。
sudo apt install git ruby autoconf bison gcc make zlib1g-dev libffi-dev libreadline-dev libgdbm-dev libssl-dev
次に、作業用のディレクトリを作成し、そこへ移動します。。
mkdir workdir
cd workdir
作業用のディレクトリに移動後、Rubyのソースコードをcloneします。結構時間がかかるのでこの間にコーヒーでも入れておくといいでしょう。
git clone https://github.com/ruby/ruby.git
ソースコードのcloneが終わったら、ruby
ディレクトリへと移動し、autoconf
を実行します。あとで実行するconfigure
スクリプトを生成するためですね。実行後、workdir
まで戻ります。
cd ruby
autoconf
cd ..
次に、ビルド用のディレクトリを作成し、そこへ移動します。
mkdir build
cd build
../ruby/configure --prefix=$PWD/../install --enable-shared
を実行してビルドするためのMakefileを作成します。また--prefix=$PWD/../install
ではRubyをインストールする先を指定しています
../ruby/configure --prefix=$PWD/../install --enable-shared
その後、make -j
を実行してビルドします。-j
は並列にコンパイルを実行するためのオプションです。特に急ぐわけでもない場合はmake
だけでも良いでしょう。
make -j
最後にmake install
を実行するとworkdir
ディレクトリ内にinstall
ディレクトリが作成され、Rubyがインストールされます。
make install
これで最新のRubyがworkdir/install
にインストールされています。
ちなみに、本当にインストールされているか気になる方は../install/bin/ruby -v
を実行してみましょう。ruby 2.8.0dev
とRubyのバージョンが表示されていればRubyは正しくインストールされています。
builtinでメソッドを再定義してみる
開発環境が整ったのでbuiltinを使い、メソッドを再定義していきます。先ほど例にも挙げたHash#delete
を再実装していきます。
common.mkの修正
まずは、ビルドの際にRubyのソースコードを使用するための諸設定をcommon.mk
に追加します。
common.mk
の1000行目辺りに、BUILTIN_RB_SRCS
という記述があります。このBUILTIN_RB_SRCS
で読み込むRubyのコードが記述されているファイルを追加します。
BUILTIN_RB_SRCS = \
$(srcdir)/ast.rb \
$(srcdir)/gc.rb \
$(srcdir)/io.rb \
$(srcdir)/pack.rb \
$(srcdir)/trace_point.rb \
$(srcdir)/warning.rb \
$(srcdir)/array.rb \
$(srcdir)/prelude.rb \
$(srcdir)/gem_prelude.rb \
$(empty)
BUILTIN_RB_INCS = $(BUILTIN_RB_SRCS:.rb=.rbinc)
今回は、Hashの実装を行うためhash.rb
を以下のように追加します。
BUILTIN_RB_SRCS = \
$(srcdir)/ast.rb \
$(srcdir)/gc.rb \
$(srcdir)/io.rb \
$(srcdir)/pack.rb \
$(srcdir)/trace_point.rb \
$(srcdir)/warning.rb \
$(srcdir)/array.rb \
$(srcdir)/prelude.rb \
$(srcdir)/gem_prelude.rb \
+ $(srcdir)/hash.rb \
$(empty)
BUILTIN_RB_INCS = $(BUILTIN_RB_SRCS:.rb=.rbinc)
次に、2520行目辺りにあるHashのビルドで読み込むファイルを指定している部分を修正します。
このようにhash.c
など読み込むファイルが指定されています。
hash.$(OBJEXT): {$(VPATH)}hash.c
hash.$(OBJEXT): {$(VPATH)}id.h
hash.$(OBJEXT): {$(VPATH)}id_table.h
hash.$(OBJEXT): {$(VPATH)}intern.h
hash.$(OBJEXT): {$(VPATH)}internal.h
hash.$(OBJEXT): {$(VPATH)}missing.h
ここに、hash.rbinc
とbuiltin.h
を追加します。
hash.$(OBJEXT): {$(VPATH)}hash.c
+hash.$(OBJEXT): {$(VPATH)}hash.rbinc
+hash.$(OBJEXT): {$(VPATH)}builtin.h
hash.$(OBJEXT): {$(VPATH)}id.h
hash.$(OBJEXT): {$(VPATH)}id_table.h
hash.$(OBJEXT): {$(VPATH)}intern.h
hash.$(OBJEXT): {$(VPATH)}internal.h
hash.$(OBJEXT): {$(VPATH)}missing.h
hash.rbinc
はmake
実行時に自動的に生成されるファイルで、hash.rb
内の__builtin_<呼び出すCの関数名>
をチェックした内容をもとに生成されています。またbuiltin.h
はbuiltinを使うための実装などがかかれたヘッダーファイルです。
これでcommon.mk
での修正は完了です。
inits.cの修正
次に、inits.c
を修正します。といっても非常に修正は簡単なものです。
#define BUILTIN(n) CALL(builtin_##n)
BUILTIN(gc);
BUILTIN(io);
BUILTIN(ast);
BUILTIN(trace_point);
BUILTIN(pack);
BUILTIN(warning);
BUILTIN(array);
Init_builtin_prelude();
}
inits.c
では上記のようにbuiltinを使用しているRubyのソースファイルを追加しています。ここに同じようにBUILTIN(hash);
を追加します。
#define BUILTIN(n) CALL(builtin_##n)
BUILTIN(gc);
BUILTIN(io);
BUILTIN(ast);
BUILTIN(trace_point);
BUILTIN(pack);
BUILTIN(warning);
BUILTIN(array);
+ BUILTIN(hash);
Init_builtin_prelude();
inits.c
の修正はこれでOKです。
hash.cの修正
いよいよ、hash.c
のコードを修正していきます。
builtin.hの読み込み
まずは、40行目辺りのヘッダー読み込み部分に#include "builtin.h"
を追加します。
#include "ruby/st.h"
#include "ruby/util.h"
#include "ruby_assert.h"
#include "symbol.h"
#include "transient_heap.h"
+ #include "builtin.h"
これでbuiltinに必要な構造体などをhash.c
で使用することができます。
Hash#deleteの定義を削除
次に、Hash#delete
を定義している部分を取り除きます。
hash.c
の下部にInit_Hash(void)
という関数が定義されていると思います。
void
Init_Hash(void)
{
/// Hashの実装コードなどが記述されています。
}
Rubyの各クラスのメソッドはこの関数内で以下のように定義されています。
rb_define_method(rb_cHash, "delete", rb_hash_delete_m, 1);
rb_define_method
はRubyでいうところのメソッドの定義と同じと考えてください。第一引数にメソッドを定義するクラスのVALUE
を渡し、第二引数がメソッド名となっています。
第三引数がCで定義された関数(メソッドで実行される処理)で、第四引数がメソッドが受け取る引数の数となっています。
builtinでRubyのメソッドを定義する場合はこの定義部分を削除する必要があります。今回はHash#delete
を再実装しますので、delete
が定義されている部分を削除します。
rb_define_method(rb_cHash, "shift", rb_hash_shift, 0);
- rb_define_method(rb_cHash, "delete", rb_hash_delete_m, 1);
rb_define_method(rb_cHash, "delete_if", rb_hash_delete_if, 0);
rb_hash_delete_mをbuiltinから使用できるように修正
先ほど削除したrb_define_method(rb_cHash, "delete", rb_hash_delete_m, 1);
で呼び出されているrb_hash_delete_m
をbuiltinで使用できるように修正します。
2380行辺りにrb_hash_delete_m
の実装があります。
static VALUE
rb_hash_delete_m(VALUE hash, VALUE key)
{
VALUE val;
rb_hash_modify_check(hash);
val = rb_hash_delete_entry(hash, key);
if (val != Qundef) {
return val;
}
else {
if (rb_block_given_p()) {
return rb_yield(key);
}
else {
return Qnil;
}
}
}
これを以下のように修正します。
static VALUE
rb_hash_delete_m(rb_execution_context_t *ec, VALUE hash, VALUE key)
{
VALUE val;
rb_hash_modify_check(hash);
val = rb_hash_delete_entry(hash, key);
if (val != Qundef)
{
return val;
}
else
{
return Qnil;
}
}
builtin対応のために第一借り引数にrb_execution_context_t *ec
を渡しているところが実装の肝ですね。
これでRubyからCで定義した関数を呼び出すことができるようになります。
hash.rbincの読み込み
最後に、自動生成されるhash.rbinc
を読み込むようにします。
#include "hash.rbinc"
をhash.c
の一番下に追加します。
#include "hash.rbinc"
これでCのコード側での修正は完了しました。
hash.rbの作成
それではRubyでHash#delete
を実装してみましょう。hash.c
と同じ階層にhash.rb
を作成します。
作成後、以下のようにコードを追加します。
class Hash
def delete(key)
puts "impl by Ruby(& C)!"
value = __builtin_rb_hash_delete_m(key)
if value.nil?
if block_given?
yield key
else
nil
end
else
value
end
end
end
先ほどbuiltinで呼び出せるようにした__builtin_rb_hash_delete_m
に受け取ってきた引数を渡し、その結果をvalue
に代入しています。
あとは、value
の値がnil
か同課で処理を分岐させています。nil
の場合かるブロックが渡されている場合はkey
を引数にブロックを実行しています。
puts "impl by Ruby(& C)!"
は実際に試す際に確認するためのメッセージになりますね。
これでbuiltinでの実装はすべて完了しました!
ビルドしてみる
それでは開発環境を構築した時同様にビルドしてみましょう。
make -j && make install
ビルドが成功すればOKです!もしビルドが失敗した場合はtypoなどが無いか確認してみましょう。
実際にirbで試してみる
それではirb
を使ってbuiltinで実装したHash#delete
を試してみましょう!
../install/bin/irb
後は以下のコードを貼り付けてみましょう!
hash = {:key => "value"}
hash.delete(:k)
hash.delete(:key)
以下のように結果が表示されていればbuiltinでの実装は完了です!
irb(main):001:0> hash = {:key => "value"}
irb(main):002:0> hash.delete(:k)
impl by Ruby(& C)!
=> nil
irb(main):003:0> hash.delete(:key)
impl by Ruby(& C)!
=> "value"
irb(main):004:0>
impl by Ruby(& C)!
と表示されているのでRubyで定義したHash#delete
が実行されていることが分かりますね。
これでRuby(とC)でRubyを実装できました!
終わりに
このようにbuiltinを使うことでRubyと(少しのC)のコードを使ってRuby自体を実装することができます。
そのため普段Rubyを書いている人でも気軽にメソッドの修正などのパッチを送ることができるようになるのではないかと思います。
あとやってみてRuby側で処理が書けたりするので意外と書きやすいというのはうれしいですね。
個人的にはExtensionなどでも使用できるようになるとC/C++でのRuby拡張がより書きやすくなるのではないかと思うので、今後の展望が非常に楽しみですね。