Rubyに新しい予約語を追加してみた その1

はじめに

大学の演習「大規模ソフトウェアを手探る」の成果と過程をまとめます。
この演習の趣旨は「全容を把握できるわけがない程大きなソフトウェアをいかに扱い、必要な動作を理解し、変更するか」 というものです。
私たちの班は題材としてRubyを選びましたが、その動作原理についてはほとんど何も知りませんでした。
故にその過程は紆余曲折が多く、また私たちの結論が間違っているということもあると思います。
もし、ご指摘等ございましたらコメントを残して頂けると幸いです。

やったこと

今回は一貫して予約語まわりの動作について探っています。
1. if文のthen~endの代わりに{}を使えるように→できなかった
2. elsifの代わりにelseifも使えるように→できた
3. 他のファイルから呼ばれた時にfalse、それ以外のときにtrueになる予約語を追加→できた

その1:1.を目標として試行錯誤するも挫折し、副産物として2.を実現します。
その2:目標を変更し、第一回に得た知識を活用して3.を実現します。

準備

ダウンロード

今回扱うRubyのバージョンは本記事執筆時点での安定版である2.2.3です。
特に1.8以前や派生版であるMacRubyなどは内部処理系が大きく異なりますのでご注意ください。
Ruby公式:https://www.ruby-lang.org/ja/downloads/
今回はソースコードを変更するのでインストーラではなくソースコードを入手します。

コンパイル

-O0で最適化レベルを0にし、-gオプションでデバッグ情報を追加してコンパイルします。 インストール先のフォルダ等は適当に読み替えてください。

$ CFLAGS="-O0 -g" ./configure --prefix=$HOME/ruby_install
$ make
$ make install

実行可能ファイルは$HOME/ruby_install/bin/の中に生成されます。 テスト時にはrubyirbなど使い分けると良いでしょう。
また、ソースコード変更後に改めてビルドする場合は以下のようにします。

$ make clean
$ make
$ make install

第一目標

まず、第一段階として簡単な目標を設定して実際に変更してみます。
どんなに大きな冒険もまずは目の前の一歩から、Hello,world!の精神です。
私たちは「if文のthen~endの代わりにブレース{}を使えるようにしよう」 という目標を掲げました。
Rubyの中で何が起こっているのか全く知らなかった私たちは、言わば足元も見えない状態。 そこに深い崖があるとも知らずに一歩を踏み出してしまいます。

デバッガで手探る

何はともあれ、膨大なソースコードの中のどこでif文の処理が行われているか見つけなければ 始まりません。
そこで以下のようなRubyスクリプトを用意し、これをRubyが実行する過程をデバッガで 追ってみることにしました。

#test1.rb
if true then
    puts "a"
end

デバッガの使い方

デバッグにはEmacsのgud-gdbを使用します。Emacsを起動したら
* M-x gud-gdb
M-xとはメタキー(通常はEsc)を押してからxを押すという意味です。
* Run gud-gdb (like this): gdb --fullname $HOME/ruby_install/bin/ruby
rubyの実行可能ファイルを指定してgdbを起動します。
* (gdb) b main
main関数にbreakpointを設定します。
* (gdb) r test1.rb
test1.rbをrubyに読みこませ実行します。

するとmain関数に入ったところで止まるので、n(ext)で1行ずつ実行していき、 関係がありそうな関数を見つけたらs(tep)で中に入っていきます。
その行の別の関数に入りたかった時は一度finで抜けて再度s(tep)すると入れます。
"a"と表示されてしまったらその時点ではもうif文の処理は終わっているということなので、 再びr(un)してその直前を調べます。
適宜新たにbreakpointを追加してc(ontinue)で一気に進むこともできますが breakpointの場所によってはRubyが正常に動作しない場合もあるようなので注意が必要です。
これを繰り返してif文の処理が行われている場所を見つけます。
gdbの詳しい使い方は以下のサイト等を参照すると良いでしょう。
* gdb の使い方・デバッグ方法まとめ
http://uguisu.skr.jp/Windows/gdb.html

構文解析をしている場所を見つける

どうやらifではなくputsの中に迷い込んでしまった私たちはtest1.rbを以下のように改良しました。

#test1.rb
puts "a"
if true then
    puts "b"
end
puts "c"

これで"a"と表示されてから"b"と表示されるまでの間にifがあるはずです。
しかしどうもよく分かりません。
それもそのはず、Rubyではスクリプトを読み込んだら構文解析をして仮想マシンの命令に置き換えて なんやかんやしてやっと実行されるのです。(これに気づくのはまだ先のこと)
今回は構文解析の所に興味があるわけですから、 "a"や"b"が出力されるところだけを見ても仕方ありません。

行き詰まった私たちは少しアプローチを変え、以下のようなスクリプトRubyに読ませることにしました。

#test2.rb
puts "a"
if true {
    puts "b"
}
puts "c"

つまり、意図的にエラーを吐かせるわけです。
今私たちがしようとしていることは、上のスクリプトを読んだ時にエラーが出ないように することとも言えるわけですから、エラーを出している場所を見つけようという作戦です。

これをRubyに読ませて再びデバッグを進めていき、エラーが出力される直前にどのファイルのどんな関数で処理が行われているのか探っていきます。
すると、どうやらparse.cというファイルの中でRubyスクリプトを解析しているらしいということが分かりました。

文字列検索で手探る

文書内検索

さて、このparse.c、1万7000行以上あります。 この中から手っ取り早くifに関わる部分だけ見つけ出すにはどうしたらいいでしょうか。

幸い、C言語の文法にはthenがありません。 ですから、parse.cの中にthenというキーワードが登場したならばそれはRubyのthenのことである可能性が高いと考えられます。
エディタの検索機能を使って実際に検索してみると、コメントを除き以下のような箇所が抽出されました。

//parse.c 818行目から
enum yytokentype {
//・・・
  keyword_then = 268,
//・・・
//parse.c 1587行目から
static const char *const yytname[] =
{
//・・・
  "keyword_unless", "keyword_then", "keyword_elsif", "keyword_else",
//・・・
  "then", "do", "if_tail", "opt_else", "for_var", "f_marg", "f_marg_list",
//・・・
//parse.c 17464行目から
} keyword_to_name[] = {
//・・・
    {keyword_then,  "then"},
//・・・

結論から言うと、これらを変更するのは完全に無駄足でした。
というのも、最も関係がありそうな最後の部分もRipperツールというものでパース結果を文字列として表示するための対応表でしかなかったし、 さらに言えばparse.cはそもそもparse.yというファイルから自動生成されるものでした。
.yというのは、構文解析器を生成するパーサジェネレータの一種であるBisonのファイルらしく、 コンパイラを開発する際はこのような補助ツールを使用するのが一般的らしいです。
コンパイラ開発の知識があればすぐに気付けたのでしょうが...

開発に関係あるファイルを見分ける

ところで、ソースコードの中に自動生成されるものが混ざっているというのは厄介です。
書き換える意味のあるファイルとないファイルをどうにかして見分けることはできないでしょうか。
実は良い方法があります。.gitignoreを除けば良いのです。
.gitignoreという隠しファイルにはgitで無視するファイルが記述されています。 つまり、ここに記述されているファイルは開発時に変更する必要がないということです。
GitHub製のテキストエディタであるAtomでは、.gitignoreに追加されているファイル名はツリービューで表示した際に薄く表示されるので一目瞭然ですね。

プロジェクト内検索

さて、parse.cの変更は失敗に終わりましたが、重要な手掛かりを見つけました。
keyword_thenというのは明らかにRubythenに対応するものでしょう。 これを対応付けている箇所がどこかにあるはずです。
エディタのプロジェクト内検索を使ってkeyword_thenを検索してみました。grepコマンドなどを利用してもいいでしょう。
すると、検索結果のうち.gitignoreに記述されていないファイルは以下の5つでした。
* defs/keywords
* defs/lex.c.src
* ext/ripper/eventids2.c
* lex.c.blt
* parse.y

一番上のdefs/keywords、怪しいですね。いかにもキーワードを定義してそうな名前をしています。
これを覗いてみるとやはり、予約語とkeyword_なんとかの対応表のようでした。

//defs/keywords
//・・・
elsif, {keyword_elsif, keyword_elsif}, EXPR_VALUE
end, {keyword_end, keyword_end}, EXPR_END
ensure, {keyword_ensure, keyword_ensure}, EXPR_BEG
//・・・
super, {keyword_super, keyword_super}, EXPR_ARG
then, {keyword_then, keyword_then}, EXPR_BEG
true, {keyword_true, keyword_true}, EXPR_END
//・・・

しかし、よく考えるとブレース{}をそのまま予約語として定義してkeyword_thenkeyword_endに対応させるわけにもいきません。
ブレースはブロックやハッシュリテラルなどとして既にRubyの文法に組み込まれているからです。
Ruby本体には、標準ライブラリや他にもRubyで書かれている箇所があります。
文法を書き換えてビルドする場合、Ruby本体に含まれるRubyスクリプトは新しい文法に従ってコンパイルされるわけですが、 ここでエラーが出てしまうとビルドに失敗してしまいます。

elseifの実装

そこで、とりあえずelseifという予約語を新しく定義してelsifと同じ働きをさせてみることにしました。
言語によってelsifだったりelifだったりelse ifだったり紛らわしいですよね。
defs/keywordsに1行追加します。 keywordなんとかが2つ書かれている理由やEXPRなんとかの意味するところはわかりませんが、 今はelsifと全く同じ働きをして欲しいのでコピーすればいいでしょう。

//defs/keywords 変更点
//・・・
elsif, {keyword_elsif, keyword_elsif}, EXPR_VALUE
elseif, {keyword_elsif, keyword_elsif}, EXPR_VALUE
//・・・

変更後、再びビルドしてテストしてみると見事、elsifの代わりにelseifも使えるようになっていました。
つまり、
* (既存の文法と競合しない単語を)新しく予約語として定義し、既存の予約語と同じ役割を持たせる

ことができるようになったと言えます。
厳密には、既存の文法ではelseifという変数名やクラス名が許されていたので完全に競合しないわけではないですが、 この場合は影響は非常に小さいと見ていいでしょう。

parse.yの探索

一方、ブレースの方は構文解析の方をいじらないといけないようです。
構文解析器を生成する元となるparse.yですね。
この中でthenと検索すると、文法を規定しているらしき箇所がいくつか出てきます。

//parse.y 2820行目から
primary     : literal
//・・・
    | k_if expr_value then
      compstmt
      if_tail
      k_end
        {
        /*%%%*/
        $$ = NEW_IF(cond($2), $4, $5);
        fixpos($$, $2);
        /*%
        $$ = dispatch3(if, $2, $4, escape_Qundef($5));
        %*/
        }
//・・・
//parse.y 3180行目から
then        : term
            /*%c%*/
            /*%c
            { $$ = Qnil; }
            %*/
        | keyword_then
        | term keyword_then
            /*%c%*/
            /*%c
            { $$ = $2; }
            %*/
        ;

Bisonの書き方を理解する必要がありそうです。
k_ifexpr_valueといったものはトークンと言います。 大枠としてはBNFに近く、コロン:の左側のトークンは右側に記述されたトークンのパターンで定義されるという意味です。 複数のパターンがある場合は縦棒|で区切ります。
実際の構文解析では、右側のパターンに合致するトークン列が左側のトークンとしてまとめられるという流れになるでしょう。
例えば、上記の2番目の例では
* term
* keyword_then
* term keyword_then

のいずれかのパターンに合致するトークン列があれば、それをthenというトークンにまとめます。
Bisonの詳しい文法については以下を参照してください。
* Bison入門 http://www.mi.s.osakafu-u.ac.jp/~kada/course-kitami/j3_03/bison.pdf
* bisonを使ってみる http://www7b.biglobe.ne.jp/~robe/pf/pf012.html

さて、if文は
* k_if expr_value then compstmt if_tail k_end

という構造になっているようです。
k_ifthenk_endはそれぞれRubyのif、then、endに対応しているだろうことは容易にわかります。
expr_valueというのはifとthenに挟まれていることを考えると条件文でしょうか。
するとthenとendに挟まれている部分ですが、 if_tailというのは、この定義部分を文書内検索で探して見るとわかりますがelsifとその中身などに対応する部分です。
そしてcompstmtはif文の中身でしょう。

これを見ると、{thenに、}k_endになってくれれば良さそうな気がします。
thenの定義は上で見たように
* term
* keyword_then
* term keyword_then

のいずれか、となっています。
keyword_thenというのはdefs/keywordsで定義されていたとおりRubyのthenにそのまま対応するものでしょう。
次に、termが定義されている場所を探します。

//parse.y 5155行目から
term        : ';' {yyerrok;}
        | '\n'
        ;

このように、Rubyに登場する1文字をそのままトークンとするときはクォート'で囲みます。
termの正体はセミコロン;もしくは改行\nのようですね。
themがそのままthenになり得るということは

if true ; puts "hoge" end

と書いても正常に動くというわけです。

さて、単純にthenk_endの定義に{}を追加してみてはどうでしょうか。
つまり、それぞれの定義部分を探して以下のように変更します。

//parse.y 3180行目から
then        : term
            /*%c%*/
            /*%c
            { $$ = Qnil; }
            %*/
        | keyword_then
        | term keyword_then
            /*%c%*/
            /*%c
            { $$ = $2; }
            %*/
        | '{'   //追加
        ;
//parse.y 3174行目から
k_end       : keyword_end
            {
            token_info_pop("end");
            }
        | '}'   //追加
        ;

試しにビルドしてみると、残念ながらエラーがいくつも出てきます。
そのうち一つを見てみると、

lib/fileutils.rb:1443: syntax error, unexpected '{', expecting keyword_end (SyntaxError)

lib/fileutils.rbの1443行目付近を見てみましょう。

#lib/fileutils.rb 1442行目から
def remove_dir1
  platform_support {
    Dir.rmdir path().chomp(?/)
  }
end

なるほど、メソッドの定義部分でブロック付きメソッドが使われていますね。
変更前ならば{がブロックの始まりとしてちゃんと認識されていたのが、thenの定義に{を加えた結果、区別できなくなったということでしょうか。
しかし探してみると、これ以前にも816行目や1156行目などメソッドの定義内で同様にブロック付きメソッドを使用している箇所はあるのですが、そこではエラーは出ていません。

ここから様々な試行錯誤が続くのですが、どれもたいした成果は無く、とても書ききれませんので詳細は省きます。

結局、10日間の演習期間の終わりが近づいているのに殆ど成果を上げられていない、という焦りから途中で目標を変更してしまいます。

続きはその2

suzuhoka.hatenablog.com