Ruby(とC)でRubyを実装してみた(builtinで遊んでみた)

はじめに

タイトルにもあるように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はハッシュ自体を引数に受け取り、第二引数のkeyHash#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

これで最新のRubyworkdir/installにインストールされています。

ちなみに、本当にインストールされているか気になる方は../install/bin/ruby -vを実行してみましょう。ruby 2.8.0devRubyのバージョンが表示されていれば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.rbincbuiltin.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.rbincmake実行時に自動的に生成されるファイルで、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_methodRubyでいうところのメソッドの定義と同じと考えてください。第一引数にメソッドを定義するクラスの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の作成

それではRubyHash#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拡張がより書きやすくなるのではないかと思うので、今後の展望が非常に楽しみですね。

参考

Write a Ruby interpreter in Ruby for Ruby 3

Rubyソースコード完全解説

Rubyのしくみ