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スクリプトを読み込んでから実行するまでの流れを大まかにまとめたものが下図です。
f:id:suzuhoka:20151105003959p:plain

  1. スクリプトの文字列を1文字ずつ読み、意味のある語句毎にトークンとしてまとめる。
  2. そのトークンの並びを見て文の構造がどうなっているか解析し、木構造(構文木)にする。
  3. 構文木からYARVという仮想マシンのための命令列を生成する。
  4. 続く

という流れになっています。
構文木というのは、例えば__FILE__==$0という部分の構文木
f:id:suzuhoka:20151105004013p:plain

というふうになります。
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.cparser_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から生成される構文木と全く同じもの(下図)にすると良いでしょう。
f:id:suzuhoka:20151105004013p:plain

そのためにまず、__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.ykeyword__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__は実行中に値が変わることのない定数で、その値はtruefalseです。 したがって、予約語truefalseと同じように扱い、truefalseが実際に役割を果たす段階で、場合に応じてどちらかと同じ役割を果たすようにすればいいでしょう。

さて、truefalseが役割を果たす場所ですが、前述のtool/parse.rbを使うとYARV命令列の時点で既にtruefalseの違いに応じた命令列になっていることが分かります。
つまり__MAIN__YARV命令列へのコンパイル時に処理すればいいということです。その過程を担うのは主にcompile.cです。

次に、trueになるかfalseになるかを決めるには__MAIN__が書かれているファイルと実行時にコマンドライン引数で指定したファイルが同一かどうか調べる必要が有ります。

__MAIN__が書かれているファイル名は__FILE__にあたりますが、__FILE__構文木になった時点で既にファイル名の文字列に置き換わっていました。
f:id:suzuhoka:20151105004013p:plain

この部分のノードはparse.y中で以下のような関数で作られていました。

NEW_STR(rb_str_dup(ruby_sourcefile_string))

このruby_sourcefile_stringにファイル名の文字列が入っているわけですが、これを探ってみると、パーサーが呼び出されるときの引数が元になっているようです。
つまり、パースが終わった後のコンパイル時にruby_sourcefile_stringを見ることができないので、ノードに文字列としてファイル名の情報を持たせていたわけです。

これに習って__MAIN__のノードにもファイル名の情報を持たせることにしましょう。
まず、__MAIN__専用のノードNODE_MAINを定義します。
f:id:suzuhoka:20151105004019p:plain

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.cvm_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_TRUENODE_FALSEが処理されている箇所にNODE_MAIN用の場合分けを追加し、 is_orig_progname関数を用いてtrueとfalseどちらになるべきか判断した後、それぞれに合う処理を行うようにしています。

さらにNODE_TRUEでプロジェクト内検索をするとparse.ynode.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の動作原理や書かれ方を一部とは言え知ることができ、私たちにとっては非常に内容の濃いものでした。
この演習の趣旨である「全容を把握できるわけがない程大きなソフトウェアをいかに扱い、必要な動作を理解し、変更するか」ということも少しは習得できたと思います。

記事の内容に関して、ご指摘等ありましたらコメントを残していただけると幸いです。

suzuhoka.hatenablog.com

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