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の動作原理や書かれ方を一部とは言え知ることができ、私たちにとっては非常に内容の濃いものでした。
この演習の趣旨である「全容を把握できるわけがない程大きなソフトウェアをいかに扱い、必要な動作を理解し、変更するか」ということも少しは習得できたと思います。
記事の内容に関して、ご指摘等ありましたらコメントを残していただけると幸いです。