この記事はISer Advent Calendar 2018の9日目です。
はじめに
言わずと知れたプログラミング言語、Ruby。WebアプリケーションフレームワークであるRuby on Raisなどを中心に、幅広く世界中で使われています。
かくいう私も、普段Rubyを使ってアルバイトをしています。仕事で使う言語であり、僕がプログラムを書くのを好きになったきっかけの言語でもあるRubyが、私は大好きです。
そんなRubyに何らかの形で貢献できたらと、私はずっと思ってきました。そのもっともわかりやすい形が、自らの手でRubyを改善するコードを書くことです。
しかし、当然ながらRubyを作るためにはRuby以外の言語が必要です。実際、Rubyのソースコードの多くはCで書かれています。私はRuby以外での実務経験がなく、Cの敷居を非常に高く感じてしまい、Rubyを改善したいという思いを行動に移すことができませんでした。
しかし先日、ひょんなことからRubyのパッチを書いて送ってみると、何とRubyに取り込んでいただくことができました。Rubyの開発に関わることへの敷居を少しでも低く感じていただけるように、この記事を書こうと思います。
きっかけ
夏休みに、私はCookpad Ruby Hack Challengeというイベントを見つけました。
Cookpad Ruby Hack Challenge #5 [二日間開催]
聞けば、「みんなでRubyのインタプリタを開発してみよう!」というイベントということでした。前々からの願望が、ここで叶えられるかもしれない。そんな期待を持って、私は参加を決めました。
実は、私は前々からRubyの仕様について疑問に思っていたことがありました。Hash
クラスに、二つのハッシュを統合するmerge
というメソッドがあります。
hash1 = {a: 1, b: 2}
hash2 = {c: 3, d: 4}
hash1.merge(hash2)
# => {a: 1, b: 2, c: 3, d: 4}
ところが、このメソッドは一つしか引数を取れないので、3つ以上のハッシュを同時に統合することができません。
hash3 = {e: 5, f: 6}
hash1.merge(hash2, hash3)
# => ArgumentError (wrong number of arguments (given 2, expected 1))
これは少し不便です。せっかくの機会なので、 ここを変更できるように頑張ってみることにしました。
機能の実装
まず、僕が実際にGitHub上で作ったプルリクエストを載せておきます。
Make the number of arguments of Hash#merge
variable
ここから、どのようにして開発を進めたかを書いていきます。
まず、Rubyを開発するために、各種ソースコードをダウンロードしたり、開発環境を整えたりしました。この辺は、Ruby Hack Challengeの参考資料でもある以下のページに詳しくまとまっているので、詳細は割愛させていただきます。
とにかく、以下のようなディレクトリ構造で、 workdir/ruby
にrubyリポジトリがクローンできている状態であれば、最初の準備としては十分だと思います。
workdir/
├ ruby/
├ build/
└ install/
ここから、Rubyのソースコードである workdir/ruby
を編集していきます。今回編集するのは、Hash
クラスに関するコードが集まったruby/hash.c
です。
ruby/hash.c
↑僕が編集する前は上のような状態でした。
行数は4857。長い。パッとみて心が折れそうになります。でも、編集するのはほんの一部。心配はいりません。順を追ってみていきましょう。
コードの一番下側に rb_define_method
という関数がいくつも呼び出されているのがわかります。
rb_define_method(rb_cHash, "initialize", rb_hash_initialize, -1);
rb_define_method(rb_cHash, "initialize_copy", rb_hash_initialize_copy, 1);
rb_define_method(rb_cHash, "rehash", rb_hash_rehash, 0);
rb_define_method(rb_cHash, "to_hash", rb_hash_to_hash, 0);
rb_define_method(rb_cHash, "to_h", rb_hash_to_h, 0);
rb_define_method(rb_cHash, "to_a", rb_hash_to_a, 0);
// ・
// ・
// ・
ここでは、第2引数で渡されたRubyのメソッドの処理を、第3引数で渡されたCの関数で行うような対応づけを行なっています。
ひとまず、この中からmerge
を探してみると、下の方にありました。
rb_define_method(rb_cHash, "merge!", rb_hash_update, 1);
rb_define_method(rb_cHash, "merge", rb_hash_merge, 1);
merge!
はmerge
の破壊的メソッド(レシーバー自身を変更するメソッド)なので、今回の実装ではmerge
と共に変更が必要です。rb_define_method
の記述から、今回の目的を達成するためにはrb_hash_update
とrb_hash_merge
という二つのC関数に変更を加える必要があることがわかります。
その前に、注意して見ないといけないのはrb_define_method
の最後の引数です。これはRubyのメソッドが受け取れる引数の数を示しています。可変長の場合は-1になります。今回の変更は、取れる変数の数を可変長にする、という実装なので、当然この二つも-1に変える必要があります。
rb_define_method(rb_cHash, "merge!", rb_hash_update, -1);
rb_define_method(rb_cHash, "merge", rb_hash_merge, -1);
ここから、Hash#merge
の実際の処理を見ていきます。
まず、rb_hash_merge
の方を見てみましょう。
static VALUE
rb_hash_merge(VALUE hash1, VALUE hash2)
{
return rb_hash_update(rb_hash_dup(hash1), hash2);
}
ここで書かれている VALUE
というのはRubyのオブジェクトのCコード内での表現です。ここから、この関数は何らかのRubyのオブジェクトを返す関数であるようです。なんとなくコードを読む限り、hash1
の複製を作ってそれにrb_hash_update
(Rubyのmerge!
と対応するC関数)をかけたものを返しているような気がします。
とりあえずこの関数が可変長引数を取れるようにしたいのですが、どうすれば良いか見当がつきませんでした。そこで、とりあえず他の可変長引数をとるメソッドの実装をみてみることにしました。
static VALUE
rb_hash_flatten(int argc, VALUE *argv, VALUE hash)
{
// ・
// ・
// ・
}
レシーバー自身を最後の引数のhash
、引数の数を一つ目のargc
, 引数の配列を二つ目のargv
として受け取るようにすれば、可変長引数が実現できそうです。
とりあえず、rb_hash_merge
の引数をこの形式にして、内部でのrb_hash_update
の呼び出しもこの実装に沿うようにします。
static VALUE
rb_hash_merge(int argc, VALUE *argv, VALUE self)
{
return rb_hash_update(argc, argv, rb_hash_dup(self));
}
あとはrb_hash_update
を修正するのみです。
static VALUE
rb_hash_update(VALUE hash1, VALUE hash2)
{
rb_hash_modify(hash1);
hash2 = to_hash(hash2);
if (rb_block_given_p()) {
rb_hash_foreach(hash2, rb_hash_update_block_i, hash1);
}
else {
rb_hash_foreach(hash2, rb_hash_update_i, hash1);
}
return hash1;
}
メソッドのレシーバー側のハッシュとマージされる側のハッシュにそれぞれ別の処理をしてから、メソッドにブロックが渡されているかいないかで別の処理をしているように見えます。
今回の変更は、元々のmerge
の処理を複数回一度に行えるようにするだけなので、この処理をfor文で何度も呼び出せば良さそうです。
Cのfor文の書き方を調べて、書き足してみました。
static VALUE
rb_hash_update(int argc, VALUE *argv, VALUE self)
{
rb_hash_modify(self);
for(int i = 0; i < argc; i++){
VALUE hash = to_hash(argv[i]);
if (rb_block_given_p()) {
rb_hash_foreach(hash, rb_hash_update_block_i, self);
}
else {
rb_hash_foreach(hash, rb_hash_update_i, self);
}
}
return self;
}
本当にfor文を足しただけですが、これでちゃんと動作するはずです。
テストと微調整
workdir/ruby/test.rb
を作成して、今回自分が実装した機能を使うRubyスクリプトを書いてみましょう。
hash1 = {a: 1, b: 2}
hash2 = {c: 3, d: 4}
hash3 = {e: 5, f: 6}
puts hash1.merge(hash2, hash3)
「機能の実装」の最初に載せておいた資料の通りに環境が整えてあれば、workdir/build
でmake run
コマンドを打つと、そのRubyスクリプトが__自分がたった今編集したRubyで実行されます__。(拡張ライブラリが使えなかったりと、一部機能に制限はあります)
$ make run
compiling ../ruby/hash.c
linking miniruby
../ruby/tool/ifchange "--timestamp=.rbconfig.time" rbconfig.rb rbconfig.tmp
rbconfig.rb unchanged
creating verconf.h
verconf.h updated
compiling ../ruby/loadpath.c
linking static-library libruby.2.6-static.a
linking shared-library libruby.2.6.dylib
linking ruby
./miniruby -I../ruby/lib -I. -I.ext/common ../ruby/tool/runruby.rb --extout=.ext -- --disable-gems ../ruby/test.rb
{a: 1, b: 2, c: 3, d: 4, e: 5, f: 6}
ちゃんと意図した通り、三つのハッシュが統合されたものが出力されています!ひとまず実装は成功と言えそうです。
ここからは、この機能が正しく動作することを確認するテストコードを書いていきます。workdir/ruby/test/ruby/test_hash.rb
にhash.c
用のテストコードがあるので、それを編集していきます。Rubyの中でもメジャーなテストフレームワーク、minitest
の形式で書かれているので、普段Rubyでの開発に慣れている人にとってはそこまで難しくないはずです。
def test_merge
h1 = @cls[1=>2, 3=>4]
h2 = {1=>3, 5=>7}
h3 = {1=>1, 2=>4}
assert_equal({1=>3, 3=>4, 5=>7}, h1.merge(h2))
assert_equal({1=>6, 3=>4, 5=>7}, h1.merge(h2) {|k, v1, v2| k + v1 + v2 })
assert_equal({1=>1, 2=>4, 3=>4, 5=>7}, h1.merge(h2, h3))
assert_equal({1=>8, 2=>4, 3=>4, 5=>7}, h1.merge(h2, h3) {|k, v1, v2| k + v1 + v2 })
end
上のテストコードでは、Hash#merge
の引数が一つ、二つの場合、またブロックを渡された場合、渡されていない場合の四通りでこのメソッドの動作を確認しています。ちなみに、@cls
を使うことで、Hash
オブジェクトとその子クラスのオブジェクトの両方について動作が確認できるようになっています。
テストを実行してみましょう。workdir/build
でmake test-all
コマンドを実行すると、全てのテストコードを実行できます。
$ make test-all
・
・
・
optparse.rb:810:in `update':can't modify frozen -702099568038204768 (FrozenError)
make: *** [verconf.h] Error 1
あれ?エラーが出てしまいました。
よく調べると、update
というメソッドからもrb_hash_update
が呼び出されていました。
rb_define_method(rb_cHash, "update", rb_hash_update, 1);
一番うしろの引数が1なので、update
が呼ばれた時は引数が一つのときのようにrb_hash_update
が呼び出されてしまいます。しかし、すでにrb_hash_update
の定義で引数を可変長の時のものに変えているので、update
を呼び出すたびにエラーが出てしまうのです。
コマンドライン引数を管理するoptparse
ライブラリでこのメソッドが使われていました。そのため、うまくコマンドが呼び出せずエラーになったようです。
update
のから呼び出した時も可変長引数を取れるように、rb_define_method
の行を修正します。
rb_define_method(rb_cHash, "update", rb_hash_update, -1);
update
についてもminitestを書き足します。
def test_update2
h1 = @cls[1=>2, 3=>4]
h2 = {1=>3, 5=>7}
h3 = {1=>1, 2=>4}
h1.update(h2, h3) {|k, v1, v2| k + v1 + v2 }
assert_equal({1=>8, 2=>4, 3=>4, 5=>7}, h1)
end
$ make test-all
・
・
・
Finished tests in 979.934312s, 19.6534 tests/s, 2362.7614 assertions/s.
19259 tests, 2315351 assertions, 0 failures, 0 errors, 51 skips
これでテストが通りました。
マージまで
ここからは、上の変更がRuby自体のリポジトリにマージされるまでについて書こうと思います。
もう一度以下のドキュメントを引用させていただきながら、ひとまずRubyのパッチが取り込まれるまでの流れを説明します。
ko1/rubyhackchallenge
RubyはGitではなく、Subversionでバージョン管理されています。そのため、GitHubにプルリクエストを出すだけでは変更を取り込んでもらうことはできません。
プロジェクト管理のためのOSS、Redmine上で、Rubyについての議論が日々行われているので、変更を加えたい場合はまずそこで自分の加えたい変更の内容を発表しましょう(これをチケットと言います)。
Ruby Issue Tracking System
チケットは、「機能追加要求(Feature request)」と「バグ報告(Bug report)」に分かれています。今回の変更は前者に分類されます。この場合、チケットは次のような内容を含んでいた方が良いようです。
- Abstract(提案の短いまとめ)
- Background(背景:現在、何が問題なのか、実際に困っているのは何であるか、実際のユースケースは何か)
- Proposal(提案)
- Implementation (実装:実装があれば、その提案が実現可能であるかを判断する強い証拠になります)
- Evaluation(評価:提案によって、何がどのように良くなったのか、実装があれば、その性能は十分であるか、など)
- Discussion (議論:検討するべき内容、他のアプローチとの比較など)
- Summary(まとめ)
中でも、Feature requestについてはその機能を使う人が実際にいるかが重視されるということで、Backgroundの項に具体的なユースケースなどを書ければ取り込まれる可能性が高まるらしいです。
この内容がよければ、Subversionへのcommit権限を持つ方々(言わずと知れたRuby Committerです!)がRubyのSubversionに変更を取り込んでくれます。
私が今回作ったチケットはこれです。
Make the number of arguments of Hash#merge
variable
Implementionについては、GitHubのプルリクエストを別に作り、そのリンクを添付しました、BackgroundについてもHash#merge
で3つのハッシュがマージできないことによって煩わしいコードを書かなくてはならない例をstack overflow や Qiita から探してきて貼りました。
ここまで作って、Ruby Hack Challengeは終了しました。最後に自分の実装をRuby Committerの皆さんの前で発表する機会をいただいたのですが、そこで皆さんから好意的な反応が返ってきて、その場でRubyのリポジトリにこのコードが取り込まれることが決まりました。あの時は本当に嬉しかったなあ・・・
その後は、GitHubのプルリクエスト上で、記法の間違いやインデントの不揃い、ドキュメントの不備など、様々な方が指摘してくださった部分を変更していきました。(時には皆さんが変更を自ら加えてくださったこともありました・・・ありがとうございます)
そんなことを一週間繰り返し・・・ついに・・・
自分の書いたコードがRubyに取り込まれました!本当に嬉しかったです。
最後に
今回、色々なことが重なって大好きなRubyに貢献することができて本当に嬉しかったです。Ruby Hack Challenge の開催に関わってくださった方々や、受け入れてくれたRuby Commiterの皆さん、そしてコードレビューをくださった様々な方々のおかげです。本当にありがとうございました。
この記事が、色々な方がRubyに貢献するハードルを下げることになれば嬉しいです。まだ尻込みしているそこのあなた。メジャーな言語で、こんなに強いコア開発者のコミュニティが日本にあるのはRubyくらいだと思います。活かさないのは勿体無いですよ!