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/
の中に生成されます。
テスト時にはrubyやirbなど使い分けると良いでしょう。
また、ソースコード変更後に改めてビルドする場合は以下のようにします。
$ 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
というのは明らかにRubyのthen
に対応するものでしょう。
これを対応付けている箇所がどこかにあるはずです。
エディタのプロジェクト内検索を使って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_then
やkeyword_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_if
やexpr_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_if
やthen
、k_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
と書いても正常に動くというわけです。
さて、単純にthen
とk_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へ