Rubyに新しい予約語を追加してみた その2
大学の演習「大規模ソフトウェアを手探る」の成果と過程をまとめます。
その1では、if文のthen~endの代わりにブレース{~}を使えるようにしようという目標を掲げましたが達成できず、その過程で
* 新しく予約語を定義し、既存の予約語と同じ役割を持たせる
ことができるようになり、elsif
と同じ役割を持つelseif
という予約語を実装しました。
第二目標
私たちは次に、「Rubyには所謂main関数が無い」という点に着目しました。
例えばJavaではmain関数はそのファイルを直接実行した時にしか呼ばれませんので、ライブラリのテストコードを記述する時などに使われます。
Rubyで同様のことを実現しようとした場合、以下のように書くそうです。
#既存の書き方 if __FILE__ == $0 #test code end
__FILE__
はそれが書かれているファイル名が入る予約語、
$0
は実行時にコマンドライン引数で指定したファイル名が入る特殊変数です。
別のプログラムからrequireされた場合は両者は一致しませんから、上記のif文の中身は実行されません。
さてこの書き方、"おまじない"と言われることもあり初見では意味が分かりづらいように感じます。
できるならいいじゃん、と思うかもしれませんが、もっと一目瞭然に
例えば下記のように記述できれば楽だし分かりやすいと私たちは考えました。
#目標 if __MAIN__ #test code end
つまり、__MAIN__
という予約語を新しく定義し、
* 実行時にrequireされたファイルの中ではfalseに
* それ以外ではtrueに
なるような機能を持たせよう、というのが私たちの新しい目標です。
ちなみにifを残した形にしたのは、中身が実行される場合とされない場合があるということが一目で分かるようにするためです。
Rubyが何をしているか
これは私たちが上述の目標に取り組む過程で分かってきたことなのですが、説明の都合上順番を前後させていただきます。
Rubyがスクリプトを読み込んでから実行するまでの流れを大まかにまとめたものが下図です。
- スクリプトの文字列を1文字ずつ読み、意味のある語句毎にトークンとしてまとめる。
- そのトークンの並びを見て文の構造がどうなっているか解析し、木構造(構文木)にする。
- 構文木からYARVという仮想マシンのための命令列を生成する。
- 続く
という流れになっています。
構文木というのは、例えば__FILE__==$0
という部分の構文木は
というふうになります。
ruby hoge.rb --dump parsetree
のようにオプションをつけて実行するとそのスクリプトの構文木を見ることができます。
また、YARV命令列はソースコードに含まれるtool/parse.rb
を使うと見ることができて、
ruby /rubyソースフォルダのパス/tool/parse.rb hoge.rb
とすると例えば
# ---------------------------------------------------------------------- # target program: # ---------------------------------------------------------------------- if __FILE__ == $0 puts "a" end # ---------------------------------------------------------------------- # disasm result: # ---------------------------------------------------------------------- == disasm: <RubyVM::InstructionSequence:<main>@hoge.rb>================= 0000 trace 1 ( 1) 0002 putstring "hoge.rb" 0004 getglobal $0 0006 opt_eq <ic:2> 0008 branchunless 23 0010 trace 1 ( 2) 0012 putself 0013 putstring "a" 0015 send :puts, 1, nil, 8, <ic:1> 0021 leave ( 1) 0022 pop 0023 putnil ( 2) 0024 leave # ----------------------------------------------------------------------
のようになります。
MAIN を実現する3つの方法
さて、私たちは第一目標でparse.y
の構文解析の部分を探っていたので、最初は構文解析器に処理を追加して__MAIN__
を実現しようとしていました。
しかしどうも上手くいかないので、まずは第一歩として最も単純な方法で実装することにします。
1.字句解析時に処理する
そもそも__MAIN__
とは、__FILE__==$0
の書き換えです。
ですから、スクリプトを1文字ずつ読んでいく字句解析で__MAIN__
という文字列を発見したらその時点で
__FILE__==$0
という文字列に置き換えて処理してしまおう、というのがこの方法です。
まずは字句解析をしている部分を見つけなければなりませんが、それにはデバッガが役に立つでしょう。
デバッガでRubyの動きを追っていくと、parse.c
のparser_yylex
という関数が何度も呼ばれているようです。
文字列検索するとこれはparse.y
の7877行目にあり、その中の7917行目から8535行目にかけてのswitch文で1文字ずつ処理しているようでした。
その終盤である8512行目に以下のような部分を見つけました。
//parse.y 8512行目から case '_': if (was_bol() && whole_match_p("__END__", 7, 0)) { ruby__end__seen = 1; parser->eofp = Qtrue; #ifndef RIPPER return -1; #else lex_goto_eol(parser); ripper_dispatch_scan_event(parser, k__END__); return 0; #endif } newtok(); break;
__END__
というのはRubyスクリプトの終わりを表し、これ以降は無視されます。
__MAIN__
の置き換えの処理もここに書いてしまいましょう。
さて、元の文字列がどこに保管されているのかを探します。
__END__
の比較に使われていたwhole_match_p
という関数名で検索すると、どうやらparser_paramsという構造体が怪しそうです。
これを検索すると
//parse.y 209行目から /* Structure of Lexer Buffer: lex_pbeg tokp lex_p lex_pend | | | | |-----------+--------------+------------| |<------------>| token */ struct parser_params { //・・・ const char *parser_lex_pbeg; const char *parser_lex_p; const char *parser_lex_pend; //・・・ };
ご丁寧にコメントで図まで描いてありました。
これをみると、lex_pbeg
が現在解析中の行の先頭、lex_pend
が行の末尾、lex_p
が見ている文字の場所、を指すポインタでしょうか。
parser_yylex
にはparser_yylex(struct parser_params *parser)
として渡されていたので、これを参考に以下のように処理を追加します。
//parse.y 8512行目から 変更後 case '_': /*追加ここから*/ if (strncmp(parser->parser_lex_p, "_MAIN__", 7) == 0) { memmove(parser->parser_lex_p + 11, parser->parser_lex_p + 7, strlen(parser->parser_lex_p + 7) + 1); parser->parser_lex_pend = parser->parser_lex_pend + 4; const char *src = "_FILE__==$0"; strncpy(parser->parser_lex_p, src, 11); goto retry2; } /*追加ここまで*/ if (was_bol() && whole_match_p("__END__", 7, 0)) { //・・・
parser_lex_p
はこの時点では現在見ている文字の次の文字を指していたので上のようになっています。
* __MAIN__
より__FILE__==$0
の方が4文字分大きいのでmemmove
で__MAIN__
より後ろの文字を4文字ずらします。
* 行の末尾を指していたparser_lex_pend
も4文字後ろを指すように貼り替えます。
* strncpy
で__MAIN__
を__FILE__==$0
に置き換えます。
* 置き換え後の文字列を再び字句解析器にかけるため、ラベルretry2
まで飛ばします。
ラベルretry2
はswitch文の直前に貼りました。
元々retry
というラベルがあったのですが、ここに飛ぶと次の文字から字句解析してしまうので新しく設けています。
//parse.y 7915行目から 変更後 //・・・ retry: last_state = lex_state; c = nextc(); retry2://追加 switch (c) { //・・・
さて、これでちゃんと動くのですが、ぶっちゃけこれはルール違反です。
parser_lex_p
などにconstと付いていたように元の文字列を書き換えることは想定されていませんし、
同じようなことをしている箇所も見当たりません。
ではどのような実装が正解なのか、というのを考えていきます。
2.構文解析時に処理する
parse.y
の構文解析のところを読むと分かりますが、Rubyではfor文はeach文に読み替えられて構文木が作られています。
他にも三項演算子はif文へ、if修飾子はif文へ、など同様に読み替えられている文法はいくつもあり、
「他の文法で代用できる文法は構文解析で読み替える」という設計思想が見て取れます。
今、私たちが目標とする__MAIN__
は__FILE__==$0
に読み替えることが可能ですから、
他の例を真似て__MAIN__
から生成される構文木を__FILE__==$0
から生成される構文木と全く同じもの(下図)にすると良いでしょう。
そのためにまず、__FILE__==$0
が構文解析されて構文木になるまでの過程を調べます。
そこで活躍するのが、-y
オプションです。
ruby -y hoge.rb
とすると、そのスクリプトが構文解析されていく様子が表示されます。
例えば、
Reducing stack by rule 483 (line 4429): $1 = token keyword__FILE__ () -> $$ = nterm keyword_variable ()
と書いてあれば、parse.y
の4429行目のルールが適用されたことになります。
//parse.y 4429行目付近 keyword_variable: keyword_nil {ifndef_ripper($$ = keyword_nil);} | keyword_self {ifndef_ripper($$ = keyword_self);} | keyword_true {ifndef_ripper($$ = keyword_true);} | keyword_false {ifndef_ripper($$ = keyword_false);} | keyword__FILE__ {ifndef_ripper($$ = keyword__FILE__);} | keyword__LINE__ {ifndef_ripper($$ = keyword__LINE__);} | keyword__ENCODING__ {ifndef_ripper($$ = keyword__ENCODING__);} ;
これを追っていき、__FILE__==$0
がどのように推移して各所でどのような処理が行われたのかをまとめると下のようになりました。
__FILE__:keyword__FILE__→①→keyword_variable→②→var_ref→primary→arg == :tEQ $0 :tGVAR→user_variable→③→var_ref→primary→arg 全体 :arg tEQ arg→④→arg→expr→⑤→expr_value ①ifndef_ripper($$ = keyword__FILE__); ②if (!($$ = gettable($1))) $$ = NEW_BEGIN(0); ③if (!($$ = gettable($1))) $$ = NEW_BEGIN(0); ④$$ = call_bin_op($1, tEQ, $3); ⑤value_expr($1); $$ = $1; if (!$$) $$ = NEW_NIL();
ちょっとこれだけだと分からないので具体例を見てみましょう。
例えば④の処理は
//parse.y 2260行目付近 arg : //・・・ | arg tEQ arg { /*%%%*/ $$ = call_bin_op($1, tEQ, $3); /*% $$ = dispatch3(binary, $1, ID2SYM(idEq), $3); %*/ } //・・・
となっています。call_bin_op
という関数で木を作り、それを$$
に代入することで新しいarg
というトークンに持たせるイメージです。
$1
はコロン:
右側に書かれたパターンの一番目のトークンを指します。つまりこの場合はtEQ
の左に書いてあるarg
です。
ちなみに/*%%%*/前者/*%後者%*/
の2つの処理がありますが、前者がメインの構文解析用の処理で、
後者はripperツールという構文解析の結果を文字列として表示させるツールのための処理です。
$$
に代入される関数で木を作っているので、この関数の中身を読んで実際にどのようにして木が作られているのかを調べます。
そして、それらを使って先に示した図のような木を作ればいいのです。
(調べる過程は長いので省略します。)
結果、以下のようにすれば目的の木を作成できるということが分かりました。
NEW_CALL(
NEW_STR(rb_str_dup(ruby_sourcefile_string)),
tEQ,
NEW_ARRAY(NEW_GVAR(rb_intern("$0")))
);
図と見比べてもらうと構造が分かりやすいと思います。
殆どは実際に使われている関数の中身をそのまま真似るだけで良かったのですが、NEW_GVAR
の引数がID型というもので、
これを生成するためのrb_intern
という関数を発見するのに苦労しました。
結局、どうにも行き詰まってparse.y
の膨大なソースコードをぼーっと眺めていたら偶然見つけたので、どう解説したらいいものか...
さて、目的の木は作れたので実装していきます。
まずは__MAIN__
という新しい予約語を定義します。__FILE__
を真似るといいでしょう。
//defs/keywords 変更後 __FILE__, {keyword__FILE__, keyword__FILE__}, EXPR_END __MAIN__, {keyword__MAIN__, keyword__MAIN__}, EXPR_END //追加
次にkeyword__MAIN__
というトークンを新しく定義します。これもparse.y
でkeyword__FILE__
と検索して真似るだけです。
//parse.y 変更後 //797行目付近 keyword__FILE__ keyword__MAIN__ //追加 //1974行目付近 reswords : keyword__LINE__ | keyword__FILE__ | keyword__ENCODING__ | keyword__MAIN__ //追加 //10984行目付近 {keyword__FILE__, "__FILE__"}, {keyword__MAIN__, "__MAIN__"},
次に、keyword__MAIN__
用の文法規則を追加する場所を決めます。
基本的にはkeyword__FILE__
などと同じ扱いをすればいいはずなので、まず以下のようにします。
//parse.y 4429行目付近 変更後 keyword_variable: keyword_nil {ifndef_ripper($$ = keyword_nil);} | keyword_self {ifndef_ripper($$ = keyword_self);} | keyword_true {ifndef_ripper($$ = keyword_true);} | keyword_false {ifndef_ripper($$ = keyword_false);} | keyword__FILE__ {ifndef_ripper($$ = keyword__FILE__);} | keyword__MAIN__ {ifndef_ripper($$ = keyword__MAIN__);} //追加 | keyword__LINE__ {ifndef_ripper($$ = keyword__LINE__);} | keyword__ENCODING__ {ifndef_ripper($$ = keyword__ENCODING__);} ;
このkeyword_variable
というトークンは、先ほどまとめた②の推移でgettable
という関数に入れられるようです。
これを探っていくと8903行目にgettable_gen
という関数が定義されており、これが本体のようです。
ここで各予約語に対応する木を生成しているようなので処理を書き足します。
//parse.y 8902行目付近 変更後 static NODE* gettable_gen(struct parser_params *parser, ID id) { switch (id) { case keyword_self: return NEW_SELF(); case keyword_nil: return NEW_NIL(); case keyword_true: return NEW_TRUE(); case keyword_false: return NEW_FALSE(); case keyword__FILE__: return NEW_STR(rb_str_dup(ruby_sourcefile_string)); case keyword__LINE__: return NEW_LIT(INT2FIX(tokline)); case keyword__ENCODING__: return NEW_LIT(rb_enc_from_encoding(current_enc)); /* 追加ここから */ case keyword__MAIN__: return NEW_CALL(NEW_STR(rb_str_dup(ruby_sourcefile_string)), tEQ, NEW_ARRAY(NEW_GVAR(rb_intern("$0")))); /* 追加ここまで */ } //・・・
変更後、ビルドしてif __MAIN__ then hogehoge end
のようなスクリプトを実行したりrequireで呼んだりするとちゃんと思惑通りの動きをしてくれました。
--dump parsetree
オプションをつけて実行してみると実際に__FILE__==$0
と全く同じ構文木が出来上がっているのが分かります。
さて、これで一応"ルール違反ではない"実装はできたのですが、これは
* __MAIN__
は__FILE__==$0
の省略形である
と定義した場合の実装です。
また別の定義をした場合の実装方法を考えます。
3.YARV命令列へのコンパイル時に処理する
今度は
* __MAIN__
は他のファイルから呼ばれた時にfalse、それ以外はtrueとなる予約語である
と定義してみましょう。
つまり、仮にRubyに__FILE__
や$0
が無くても動くようにするということです。
__MAIN__
は実行中に値が変わることのない定数で、その値はtrue
かfalse
です。
したがって、予約語のtrue
やfalse
と同じように扱い、true
やfalse
が実際に役割を果たす段階で、場合に応じてどちらかと同じ役割を果たすようにすればいいでしょう。
さて、true
やfalse
が役割を果たす場所ですが、前述のtool/parse.rb
を使うとYARV命令列の時点で既にtrue
やfalse
の違いに応じた命令列になっていることが分かります。
つまり__MAIN__
もYARV命令列へのコンパイル時に処理すればいいということです。その過程を担うのは主にcompile.c
です。
次に、true
になるかfalse
になるかを決めるには__MAIN__
が書かれているファイルと実行時にコマンドライン引数で指定したファイルが同一かどうか調べる必要が有ります。
__MAIN__
が書かれているファイル名は__FILE__
にあたりますが、__FILE__
は構文木になった時点で既にファイル名の文字列に置き換わっていました。
この部分のノードはparse.y
中で以下のような関数で作られていました。
NEW_STR(rb_str_dup(ruby_sourcefile_string))
このruby_sourcefile_string
にファイル名の文字列が入っているわけですが、これを探ってみると、パーサーが呼び出されるときの引数が元になっているようです。
つまり、パースが終わった後のコンパイル時にruby_sourcefile_string
を見ることができないので、ノードに文字列としてファイル名の情報を持たせていたわけです。
これに習って__MAIN__
のノードにもファイル名の情報を持たせることにしましょう。
まず、__MAIN__
専用のノードNODE_MAIN
を定義します。
NODE_TRUE
などでプロジェクト内検索すると、hode.h
で定義されているようでした。
//node.h 22行目から 変更後 enum node_type { //・・・ NODE_TRUE, #define NODE_TRUE NODE_TRUE NODE_FALSE, #define NODE_FALSE NODE_FALSE NODE_MAIN, //追加 #define NODE_MAIN NODE_MAIN //追加 //・・・ //457行目付近 #define NEW_TRUE() NEW_NODE(NODE_TRUE,0,0,0) #define NEW_FALSE() NEW_NODE(NODE_FALSE,0,0,0) #define NEW_MAIN() NEW_NODE(NODE_MAIN,0,0,0) //追加 //・・・
ついでにNODE_MAIN
を作るマクロNEW_MAIN
も定義しました。
次に、構文解析で__MAIN__
があったらNODE_MAIN
を作って木に加えるように変更します。
予約語の定義方法は2.と同じなので省略します。
//parse.y 8902行目付近 変更後 case keyword__MAIN__: return NEW_MAIN(rb_str_dup(ruby_sourcefile_string));
これでcompile.c
で__MAIN__
が書かれたファイル名を知ることができるようになりました。
次は、実行時にコマンドライン引数で指定したファイル名を取得する方法を調べます。
これは$0
にあたるものなので、少し大変ですが$0
でプロジェクト内検索してみます。
すると、ruby.c
の中で以下のような箇所を見つけました。
//ruby.c 1919行目 rb_define_hooked_variable("$0", &rb_progname, 0, set_arg0);
どうやらrb_progname
が目的のファイル名のようです。
これは同じくruby.c
で以下のように定義されていました。
//ruby.c 1172行目 #define rb_progname (GET_VM()->progname)
GET-VM()
はvm_core.h
で定義されており、compile.c
はvm_core.h
をインクルードしているので同様の方法で目的のファイル名を取得することができます。
さて、これで準備が整いました。
早速compile.c
に追記していきます。
//compile.c 25行目から 変更後 /* 追加ここから */ #define rb_progname (GET_VM()->progname) static int is_orig_progname(NODE *node){ if(nd_type(node) != NODE_MAIN){ return 0; } if(strcmp(StringValuePtr(node->nd_lit), StringValuePtr(rb_progname)) == 0){ return 1; }else{ return 0; } } /* 追加ここまで */
まずは利便性のため、NODE_MAIN
を渡してそれが持つファイル名とrb_progname
のファイル名を比較する関数を定義します。
実は、これらのファイル名は通常の文字列ではなくVALUE型というデータになっています。
これは
* C言語は変数に型があり、データに型がない
* Rubyは変数に型がなく、データに型がある
というギャップを埋めるための型です。このVALUE型から文字列を取り出すための関数としてStringValuePtr()
が用意されています。
これはRubyのリファレンスマニュアルから見つけました。
次にNODE_TRUE
で文書内検索し、NODE_MAIN
用の処理を付け加えます。
//compile.c 1270行目付近 変更後 //関数の引数にあるときの処理 case NODE_TRUE: dv = Qtrue; break; case NODE_FALSE: dv = Qfalse; break; /* 追加ここから */ case NODE_MAIN: dv = (is_orig_progname(node)? Qtrue : Qfalse); break; /* 追加ここまで */
//compile.c 2380行目付近 変更後 //ifの条件文にあるときの処理 case NODE_TRUE: case NODE_STR: /* printf("useless condition eliminate (%s)\n", ruby_node_name(nd_type(cond))); */ ADD_INSNL(ret, nd_line(cond), jump, then_label); break; case NODE_FALSE: case NODE_NIL: /* printf("useless condition eliminate (%s)\n", ruby_node_name(nd_type(cond))); */ ADD_INSNL(ret, nd_line(cond), jump, else_label); break; /* 追加ここから */ case NODE_MAIN: if(is_orig_progname(cond)){ ADD_INSNL(ret, nd_line(cond), jump, then_label); }else{ ADD_INSNL(ret, nd_line(cond), jump, else_label); } break; /* 追加ここまで */
//compile.c 2929行目付近 変更後 //defined?演算子の後にあるときの処理 case NODE_TRUE: expr_type = DEFINED_TRUE; break; case NODE_FALSE: expr_type = DEFINED_FALSE; break; /* 追加ここから */ case NODE_MAIN: expr_type = (is_orig_progname(node)? DEFINED_TRUE : DEFINED_FALSE); break; /* 追加ここまで */
//compile.c 5334行目付近 //上記のどれにも該当しない場合の処理 case NODE_TRUE:{ if (!poped) { ADD_INSN1(ret, line, putobject, Qtrue); } break; } case NODE_FALSE:{ if (!poped) { ADD_INSN1(ret, line, putobject, Qfalse); } break; } /* 追加ここから */ case NODE_MAIN:{ if (!poped) { if(is_orig_progname(node)){ ADD_INSN1(ret, line, putobject, Qtrue); }else{ ADD_INSN1(ret, line, putobject, Qfalse); } } break; } /* 追加ここまで */
NODE_TRUE
やNODE_FALSE
が処理されている箇所にNODE_MAIN
用の場合分けを追加し、
is_orig_progname
関数を用いてtrueとfalseどちらになるべきか判断した後、それぞれに合う処理を行うようにしています。
さらにNODE_TRUE
でプロジェクト内検索をするとparse.y
とnode.c
でも扱われていることが分かります。
こちらも同様にNODE_MAIN
用の処理を追加していきます。
//parse.y 8648行目付近 変更後 //警告"unused literal ignored"を出す static NODE* block_append_gen(struct parser_params *parser, NODE *head, NODE *tail) { //・・・ case NODE_TRUE: case NODE_FALSE: case NODE_MAIN://追加
//parse.c 9407行目付近 変更後 //警告"possibly useless use of"を出す case NODE_TRUE: useless = "true"; break; case NODE_FALSE: useless = "false"; break; /* 追加ここから */ case NODE_MAIN: useless = "__MAIN__"; break; /* 追加ここまで */
//parse.c 9527行目付近 変更後 //ノードが静的か調べる関数? static int is_static_content(NODE *node) { //・・・ case NODE_TRUE: case NODE_FALSE: case NODE_MAIN://追加
//parse.y 9638行目付近 変更後 //条件式としての範囲式 static int literal_node(NODE *node) { //・・・ case NODE_TRUE: case NODE_FALSE: case NODE_NIL: case NODE_MAIN://追加
//node.c 766行目付近 変更後 //ノードの説明文? /* 追加ここから */ case NODE_MAIN: ANN("__MAIN__"); ANN("format: __MAIN__"); ANN("example: __MAIN__"); break; /* 追加ここまで */
//node.c 953行目付近 変更後 //trueやfalseでは何もしない VALUE rb_gc_mark_node(NODE *obj) { //・・・ case NODE_TRUE: case NODE_FALSE: case NODE_MAIN://追加
これでやっとNODE_MAIN
を追加したことによる辻褄合わせは済んだと思われます。
基本的にNODE_TRUE
を真似しただけなので不備があるかもしれません。
さて、ビルドしてテストしてみましょう。
以下のようなテストファイルを用意します。
#hoge.rb if __MAIN__ puts "test" end puts "a"
#require_hoge.rb require_relative "hoge"
実際に実行してみると以下のようになります。
$ $HOME/ruby_install/bin/ruby hoge.rb test a $ $HOME/ruby_install/bin/ruby require_hoge.rb a
ちゃんと動いてますね。
--dump parsetree
オプションをつけて実行してみると今度は2.と異なり、__MAIN__
の場所にはNODE_MAIN
という単一のノードが生成されていることが分かります。
__FILE__
や$0
に置き換えているわけではないので、仮にこれらがRubyに存在しなくても動くということになります。
まとめ
演習「大規模ソフトウェアを手探る」の過程と成果は以上です。
長々と書いてしまいましたが、ここまでで出来たことは
* elsifの代わりにelseifも使えるようにした
* 他のファイルから呼ばれた時にfalse、それ以外のときにtrueになる予約語を追加した
ということだけです。
しかし、その過程でRubyの動作原理や書かれ方を一部とは言え知ることができ、私たちにとっては非常に内容の濃いものでした。
この演習の趣旨である「全容を把握できるわけがない程大きなソフトウェアをいかに扱い、必要な動作を理解し、変更するか」ということも少しは習得できたと思います。
記事の内容に関して、ご指摘等ありましたらコメントを残していただけると幸いです。
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へ