Skip to content

Instantly share code, notes, and snippets.

@wanabe
Last active August 10, 2020 02:02
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save wanabe/b4baf0d4459b60a0c31d794cd9801f27 to your computer and use it in GitHub Desktop.
Save wanabe/b4baf0d4459b60a0c31d794cd9801f27 to your computer and use it in GitHub Desktop.
パターンマッチをわかりたい

この文書は

Ruby のパターンマッチングがどうやって実装されているのかわからないので実装を読んでわかったことを書いていく覚え書きです。

もともとは、パターンマッチング文法のコンパイル結果の命令列を見ていて、なんだかおかしいような気がしたけれども自信がないので、もうちょっと読んでおかしいかおかしくないか判断しよう、というのが動機です。

結論として「動きはするし顕在化とは思うが、微妙にまずそう」というところを見つけたのでそこまでの道筋を書きます。

パターンマッチングの書式

parse.y の中ではこんな感じで表現されていました。

		| k_case expr_value opt_terms
		  p_case_body
		  k_end

キーワード case で始まり、式が次に来て、改行がいくつか挟まって、p_case_body というパターンが挟まって、最後にキーワード end で終わり。 では p_case_body が何者かというと

p_case_body	: keyword_in
		    {
(中略)
		    }
		  p_top_expr then
		    {
(中略)
		    }
		  compstmt
		  p_cases
		    {
(中略)
		    }
		;

キーワード in のあとに p_top_expr があって、キーワード then; か改行が入って、文が終わったら p_cases パターン。p_cases

p_cases 	: opt_else
		| p_case_body
		;

こんな感じなので、case が続くか、else か、そこで終わりか、という感じです。 p_top_expr は種類がたくさんあるけどそこまで興味がわかないので割愛。あと arg keyword_in p_expr パターンもあるけれどこちらも割愛。

要するに case val; in pat1; stmt1 .... end のような形をしている、ようです。

NODE 表現

new_case3() 関数で作成される NODE_CASE3 で表現されるようです。 NODE_CASE3 の nd_head には case 直後の expr_value が入り、nd_body には NODE_IN なるノードが入っていました。 NODE_IN の nd_head にはパターンつまり上でいうところの p_top_expr が、nd_body には同じく上の compstmt が入っていました。 またその次の条件があるときにはさらに nd_next に NODE_IN が入り以下同様、else があるときには nd_next に else 以下の文そのものが入る形でした。

instruction 表現

ここからが今回一番知りたかったところでした。 上の NODE_CASE3 がどういう命令列になってどう実行されるのか。 また実行されるときにはスタックはどうなるのか。

NODE_CASE3 の解釈は compile.c の中の compile_case3() が担当しているようでした。 最初に head, body_seq, cond_seq という 3 つのアンカーが初期化されています。 (なんで head だけ head_seq じゃないんでしょう。慣習みたいなものかな)

NODE_CASE3 の nd_head 部分

まず最初にさらっと COMPILE(head, "case base", node->nd_head) されています。 なのでこの時点でスタックには expr_value の値が積まれます。スタックの状態は以下のような感じです。

sp   : 0 => 1
stack: [] => [expr_value]

次に以下のように書かれています。

    ADD_INSN(head, line, putnil); /* allocate stack for cached #deconstruct value */
    ADD_INSN(head, line, swap);
    ADD_SEQ(ret, head);	/* case VAL */

deconstruct のために領域を確保し、先ほどの expr_value と取り替え、またここまでのコンパイル結果を本来の位置につなげています。 スタックは以下のようになっています。

sp   : 1 => 2
stack: [expr_value] => [nil expr_value]

(しかしちょっとした疑問なんですが、先に expr_value を積んでから nil を積んで取り替えるくらいなら、なんで nil を積んでから expr_value を積むようになっていないんでしょう。 あと直接 ret を使えば head って必要なかったのでは?ちょっとこのあたり理解が出来なくて、誤読している可能性が高いです)

NODE_CASE3 の nd_body == NODE_IN の部分

NODE_IN の読み込みはざっくり以下のような while 文で構成されていました。

    node = node->nd_body;
    (中略line = nd_line(node);
    (中略while (type == NODE_IN) {
        (node->nd_body から body_seq 構築部分pattern = node->nd_head;
        (node->nd_head から cond_seq 構築部分node = node->nd_next;
        if (!node) {
            break;
        }
        type = nd_type(node);
        line = nd_line(node);
    }

実際には nd_body から先に処理されていますが、Ruby の文法的には nd_head つまり p_top_expr が先なので、まずこちらから見ていきます。

NODE_IN の nd_head 部分 (== p_top_expr)

ADD_INSN(cond_seq, pat_line, dup) してから iseq_compile_pattern_each(iseq, cond_seq, pattern, l1, next_pat, FALSE, 2) と関数呼び出しをし、その次の場所に対して ADD_LABEL(cond_seq, next_pat) していました。 iseq_compile_pattern_each がわからないとどうしようもなさそうです。ひとまずその直前のスタックの状態を示します。

sp   : 2 => 3
stack: [expr_value] => [nil expr_value expr_value]

iseq_compile_pattern_each()

この関数は switch(nd_type(node)) { ... } という大きな switch 文の形になっていました。 ここの node は前述の「NODE_IN の nd_head 部分」のことです。 分岐がものすごい数があってとても全部追いきれないので、まずは値がそのままパターンとして書かれているパターン、NODE_TRUE などの場合を見ていきます。

p_top_expr が NODE_TRUE 等の固定値のパターン

        CHECK(COMPILE(ret, "case in literal", node));
        ADD_INSN1(ret, line, checkmatch, INT2FIX(VM_CHECKMATCH_TYPE_CASE));
        ADD_INSNL(ret, line, branchif, matched);
        ADD_INSNL(ret, line, jump, unmatched);

true など単一の値を置いて、checkmatch -> branchif -> jump と繋がります。 checkmatch はスタックから 2 つ値を取り出して、パターンに一致したかどうかの真偽値をスタックに置く命令です。 branchif はスタックから 1 つ取り出して真ならジャンプする命令なので、スタックは以下のように動きます。

sp   : 3 => 4 => 3 => 2
stack: [nil expr_value expr_value] => [nil expr_value expr_value p_top_expr] => [nil expr_value match_or_unmatch] => [nil expr_value]

ちょうど上の「NODE_CASE3 の nd_body == NODE_IN の部分」のときと同じスタック状態になりました。

NODE_IN の nd_body 部分 (== compstmt)

        ADD_INSN(body_seq, line, pop);
        ADD_INSN(body_seq, line, pop); /* discard cached #deconstruct value */
        (中略、カバレッジ関連部分)
        CHECK(COMPILE_(body_seq, "in body", node->nd_body, popped));
        ADD_INSNL(body_seq, line, jump, endlabel);

上の branchif, matched から飛んでくる部分です。 スタックから 2 つ捨てて、body 部分を展開します。 そもそもの case 文が値を捨てる場合 = popped な場合には実行だけして値は捨てられます。値を返す場合 = popped でない場合にはスタックに値が置かれます。 その状態で endlabel にジャンプします。 そのため popped かそうでないかでスタックの動きが異なります。

popped の場合

sp   : 2 => 1 => 0
stack: [nil expr_value] => [nil] => []

popped でない場合

sp   : 2 => 1 => 0 => 1
stack: [nil expr_value] => [nil] => [] => [compstmt_value]

NODE_CASE3 の nd_next が NODE_IN でも NULL でもない場合 == else 節が存在する場合

else 節はこのように処理されます。

        ADD_LABEL(cond_seq, elselabel);
        ADD_INSN(cond_seq, line, pop);
        ADD_INSN(cond_seq, line, pop); /* discard cached #deconstruct value */
        (中略、カバレッジ関連部分)
        CHECK(COMPILE_(cond_seq, "else", node, popped));
        ADD_INSNL(cond_seq, line, jump, endlabel);

compstmt の場合とよく似た形をしています。そのため説明は省略します。

popped の場合

sp   : 2 => 1 => 0
stack: [nil expr_value] => [nil] => []

popped でない場合

sp   : 2 => 1 => 0 => 1
stack: [nil expr_value] => [nil] => [] => [opt_else_value]

さてそうすると、この部分にまずそうな箇所が存在します。 VM 実行時には endlabel に飛んでしまうので関係ないですが、iseq の構築時にこれだと sp がずれてしまいそうです。

唐突に CRuby VM の sp 計算の話

私はつい最近まで知らなかったんですが、CRuby VM の命令列は基本的に「ジャンプを考慮せずに頭から順に読んで、ある番地に来たときの正しい sp の値がわかるように出来ている」らしいです。 ちょっとややこしいんですが、つまりたとえば

 getlocal 1      # sp: 0 -> 1
 branchif L001   # sp: 1 -> 0
 putobject false # sp: 0 -> 1
 jump L002       # sp: 1
L001:
 putnil          # sp: ??? (ジャンプ元で考えると 0->1 だが、頭から読むと 1 -> 2)
 leave
L002:
 leave

のような命令列の並びにはならないはず、ということです。なので上の命令列を是正する場合

 getlocal 1      # sp: 0 -> 1
 branchif L001   # sp: 1 -> 0
 putobject false # sp: 0 -> 1
 jump L002       # sp: 1
 pop             # sp: 1 -> 0 (実際には到達し得ない dead code)
L001:
 putnil          # sp: 0 -> 1 (ジャンプ元から数えても頭から数えても同じ)
 leave           # sp: 1
L002:
 leave           # sp: 1(ジャンプ元から数えても頭から数えても同じ)

こんな感じで、pop/putnil なんかを置いて sp を調節することになります。

else 節の話に戻る

ここで元のパターンマッチングの else 節に戻ると、cond_seq の終わりの sp の値が 0 または 1 になっています。 命令の並びとしては cond_seq -> body_seq の順になるので、body_seq の始まりと sp が一致している必要があります。 body_seq は上の compstmt の部分ですので始まりは sp:2 でなければいけないので、2 または 1 ずれています。 このままでは sp 計算が正しく出来ないはずです。

fix_sp_depth()

ではなぜ正しく動いてくれるかというと、上で「基本的に」と書いたところがミソで、実は多少の sp のずれはうまいこと直したり無視したりしてくれる「ことがある」のでした。 fix_sp_depth() という関数がずれの修正を担当していて、あるラベルより前の番地にそのラベルへのジャンプ命令があった場合、そのラベルの sp の値をそこで固定してしまいます。つまり上の例で言えば

 getlocal 1      # sp: 0 -> 1
 branchif L001   # sp: 1 -> 0, ここで L001 の sp 決定
 putobject false # sp: 0 -> 1
 jump L002       # sp: 1, ここで L002 の sp 決定
L001:
 putnil          # sp: 0-> 1 (頭から読むと 1 -> 2 だが、ジャンプ命令の方の sp を優先して使う)
 leave
L002:
 leave

こういう感じです。 繰り返しになりますが「基本的には」頭から読んで sp が決定できるようになっているはずで、この fix_sp_depth() はやや強引な救済措置といえます。 最適化などでラベルが存在しなくなることは十分考えられることで、そのときに急に問題が顕在化しえます。

ということで

この部分、今はうまく動いているけれど潜在的にまずそうなので、直したほうがよさそうです。 他にも似たような sp ずれがありそうなので、探せるだけ探してまとめて Pull Request しようかなと考えています。どれだけ探せるかな。

各パターンの期待する sp 遷移

  • NODE_CASE3
    • 0 で始まる
    • 非 popped なら 1, popped なら 0 で終わる
    • NODE_CASE3 の nd_head
      • 0 で始まる
      • 2 で終わる
    • NODE_CASE3 の nd_body == NODE_IN
      • NODE_IN の nd_head
        • 3 で始まる
        • 2 で終わる
        • (ただし OR などで入れ子構造になっている子の場合は 3 で始まらないこともある。その場合、始まりの sp - 1 で終わる)
      • NODE_IN の nd_body
        • 2 で始まる
        • 非 popped なら 1, popped なら 0 で終わる
      • NODE_IN の nd_next で、かつ NODE_IN の場合(後続の in の場合)
        • 『NODE_IN の nd_head』と同様
        • 3 で始まる
        • 2 で終わる
      • NODE_IN の nd_next で、かつ NODE_IN でない場合(else の場合)
        • 2 で始まる
        • 非 popped なら 1, popped なら 0 で終わる
      • NODE_IN の nd_next が NULL、つまりelse 節が存在しない場合の暗黙の else 処理
        • 2 で始まる
        • 非 popped なら 1, popped なら 0 で終わる

この形に沿っている必要があります。 ここから、後続の in の処理をする前に sp + 1 しておく必要があること、iseq_compile_pattern_each() で構築される iseq はすべて呼び出し前と比較して sp - 1 する処理である必要があること、がわかります。

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment