「Rubyのしくみ Ruby Under a Microscope【委託】 - 達人出版会」という本の第1章を読んだので、内容と感想を書きます。
この本はいろいろな人がオススメされていたので、難しそうだなと思いつつ買いました。
🗿ではじまる行と🗿🗿🗿で囲まれた部分は自分で考えたことです。
はじめに
Rubyの構文はLispやSmalltalkといった高度な言語のアイデアを採用している。
🗿🗿🗿
- Lispって? https://news.mynavi.jp/techplus/article/programinglanguageoftheworld-18/
- Smalltalkって? https://ja.wikipedia.org/wiki/Smalltalk
🗿🗿🗿
プログラムを実行する前にRubyは3回コードを読み、変換している。
(図1)
まずRubyはコードを字句解析する。字句解析ではソースコードのテキストを読み込み、それらをRuby言語内で使われる単語の列、すなわちトークン列へ変換する。次にRubyはトークン列を構文解析する。構文解析では、トークン列をRubyの構文として意味のある単位にグループ化する。最後に、Rubyはその構文を仮想マシンを使って実行することができる低レベルの命令へとコンパイルする。
※第1章は、赤く囲んだ字句解析と構文解析についての内容です。
🗿YARV命令って??とか思ったけどあとの章に出てくるので放置
字句解析
# サンプルコード # times.rb 10.times do |n| puts n end
コマンドラインのパラメータを処理するといった一般的な初期処理をひとまず置いておくと、ruby times.rb
とコマンドを打ってEnterキーを押したあと、Rubyは最初にファイルを開いてソースコードからすべてのテキストを読み込む。図ではわかりやすく最初の1行のみを示している(図2-上段)
(図2)
Rubyはこれらの文字列に出会うと字句解析を行う。すなわち、ひとまとまりの理解可能な単語の列、トークン列へと変換する。文字の最初の位置からスキャンを開始する。 RubyのCコードには、文字を1文字ずつ読み込み、その内容に従って処理をするループ処理が含まれている。
話を単純にするため、ここでは字句解析を独立したプロセスとして説明することにする。しかし実際には、構文解析エンジンは新しいトークンが必要になるたびに、このCで書かれた字句解析コードを呼び出している。つまり字句解析は構文解析中に行われる独立した処理である。
times.rbの字句解析の流れ
Rubyは1
が数字のはじまりだと認識し、数字ではない文字が現れるまで文字の読み込みを繰り返す。1
の次は0
を見つける。そのまま読み込みを続けてピリオド文字を検出する。
浮動小数点数値の一部である可能性もあるため、Rubyはピリオドを数値として解釈する。そして次の文字t
に進む。
数値でない文字を検出したので、Rubyはここで読み込みを停止する。ピリオドのあとに数値がこないことがわかったため、Rubyはピリオドを字句の区切りだとみなして1文字前の.
へ戻る。
最後に、Rubyは検出した数値文字をtINTEGER
と呼ばれる最初のトークンに変換する。
Rubyはソースコード中の文字を1つずつ進みながら、それらをトークン列に変換し必要に応じて文字をグループ化していく。2つめのトークンは.
になる。
次にRubyは文字times
に遭遇し、識別子トークン(tIDENTIFIER
)を作成する。識別子とは予約語ではないコード中の単語のことで、変数やメソッド、クラスなどの名前を示している。
Rubyは次にdo
と出会い、keyword_do
として表される予約語トークンを作成する。RubyのCコードは内部に予約語の定数テーブルを保持している。
(図2-下段: トークン列)
Ripperを使う
Ripperというツールを使うことでRubyがさまざまなソースコードに対して生成するトークン列を簡単に確認できる。Ruby 1.9以降に同梱されているRipperクラスを使うと、Rubyがソースコードのテキストを処理しているのと同じ字句解析器と構文解析器を呼び出すことができる。
# lex1.rb require 'ripper' code = <<STR 10.times do |n| puts n end STR puts code pp Ripper.lex(code)
Ripper.lexメソッドにコードを文字列として渡すことでRipperを呼び出している。
参考:https://docs.ruby-lang.org/ja/latest/method/Ripper/s/lex.html
出力は以下のようになる。
$ ruby lex1.rb 10.times do |n| puts n end [[[1, 0], :on_int, "10", END], [[1, 2], :on_period, ".", DOT], [[1, 3], :on_ident, "times", ARG], [[1, 8], :on_sp, " ", ARG], [[1, 9], :on_kw, "do", BEG], [[1, 11], :on_sp, " ", BEG], [[1, 12], :on_op, "|", BEG|LABEL], [[1, 13], :on_ident, "n", ARG], [[1, 14], :on_op, "|", BEG|LABEL], [[1, 15], :on_ignored_nl, "\n", BEG|LABEL], [[2, 0], :on_sp, " ", BEG|LABEL], [[2, 2], :on_ident, "puts", CMDARG], [[2, 6], :on_sp, " ", CMDARG], [[2, 7], :on_ident, "n", END|LABEL], [[2, 8], :on_nl, "\n", BEG], [[3, 0], :on_kw, "end", END], [[3, 3], :on_nl, "\n", BEG]]
それぞれの行はトークンと対応している。
例えば[[1, 0], :on_int, "10", END]
の行について。
[1, 0]
: 1は行番号(今回は3行しかないので1~3のどれか)、0は列番号
:on_int
: トークンの種類(先ほどのtINTEGER
とは異なる)
"10"
: トークン文字列
END
: トークンの状態を表すRipper::Lexer::State
のインスタンス
🗿本には最後のトークンの状態を表す要素は載ってないので追加されたみたい
次はn/4+6
を出力する例。
$ ruby lex2.rb 10.times do |n| puts n/4+6 end [[[1, 0], :on_int, "10", END], [[1, 2], :on_period, ".", DOT], [[1, 3], :on_ident, "times", ARG], [[1, 8], :on_sp, " ", ARG], [[1, 9], :on_kw, "do", BEG], [[1, 11], :on_sp, " ", BEG], [[1, 12], :on_op, "|", BEG|LABEL], [[1, 13], :on_ident, "n", ARG], [[1, 14], :on_op, "|", BEG|LABEL], [[1, 15], :on_ignored_nl, "\n", BEG|LABEL], [[2, 0], :on_sp, " ", BEG|LABEL], [[2, 2], :on_ident, "puts", CMDARG], [[2, 6], :on_sp, " ", CMDARG], [[2, 7], :on_ident, "n", END|LABEL], [[2, 8], :on_op, "/", BEG], [[2, 9], :on_int, "4", END], [[2, 10], :on_op, "+", BEG], [[2, 11], :on_int, "6", END], [[2, 12], :on_nl, "\n", BEG], [[3, 0], :on_kw, "end", END], [[3, 3], :on_nl, "\n", BEG]]
次はもう少し複雑な例。
$ ruby lex3.rb array = [] 10.times do |n| array << n if n < 5 end p array [[[1, 0], :on_ident, "array", CMDARG], : : [[3, 2], :on_ident, "array", END|LABEL], [[3, 7], :on_sp, " ", END|LABEL], [[3, 8], :on_op, "<<", BEG], [[3, 10], :on_sp, " ", BEG], [[3, 11], :on_ident, "n", END|LABEL], [[3, 12], :on_sp, " ", END|LABEL], [[3, 13], :on_kw, "if", BEG|LABEL], [[3, 15], :on_sp, " ", BEG|LABEL], [[3, 16], :on_ident, "n", END|LABEL], [[3, 17], :on_sp, " ", END|LABEL], [[3, 18], :on_op, "<", BEG], [[3, 19], :on_sp, " ", BEG], [[3, 20], :on_int, "5", END], [[3, 21], :on_nl, "\n", BEG], [[4, 0], :on_kw, "end", END], [[4, 3], :on_nl, "\n", BEG], [[5, 0], :on_ident, "p", CMDARG], [[5, 1], :on_sp, " ", CMDARG], [[5, 2], :on_ident, "array", END|LABEL], [[5, 7], :on_nl, "\n", BEG]]
<<
と<
を区別している。
最後はシンタックスエラーの例。
$ ruby lex4.rb 10.times do |n puts n end [[[1, 0], :on_int, "10", END], [[1, 2], :on_period, ".", DOT], [[1, 3], :on_ident, "times", ARG], [[1, 8], :on_sp, " ", ARG], [[1, 9], :on_kw, "do", BEG], [[1, 11], :on_sp, " ", BEG], [[1, 12], :on_op, "|", BEG|LABEL], [[1, 13], :on_ident, "n", ARG], [[1, 14], :on_nl, "\n", BEG], [[2, 0], :on_sp, " ", BEG], [[2, 2], :on_ident, "puts", CMDARG], [[2, 6], :on_sp, " ", CMDARG], [[2, 7], :on_ident, "n", END|LABEL], [[2, 8], :on_nl, "\n", BEG], [[3, 0], :on_kw, "end", END], [[3, 3], :on_ignored_nl, "\n", BEG]]
Ripperは文句を言わずに字句解析する。シンタックスをチェックするのは構文解析器の仕事だからだ。
構文解析
コードを一連のトークン列へ変換したあとのステップは構文解析である。構文解析ではトークン列をRubyがわかる文やフレーズにグループ化する。構文解析を行う際、Rubyは命令やメソッド、ブロックなどのコード構造の順序を考慮する。
多くのプログラミング言語と同じように、Rubyはパーサジェネレータを使ってコードに書かれたやりたいことを理解する。Rubyがトークン列を処理するために使用するパーサは、パーサジェネレータによって生成される。パーサジェネレータは一連の文法規則を入力にとる。文法規則には、期待されるトークン列の出現順序やパターンが記述されている。
🗿🗿🗿
ここ読んでて混乱した。パーサは構文解析を行うためのプログラムの総称。パーサジェネレータは構文解析器を作成するためのプラグラム。パーサ=構文解析器でパーサジェネレータはパーサを作成するためのプログラムってことか。
http://tatamo.81.la/blog/2016/12/22/lr-parser-generator-implementation/
この記事のパーサジェネレータのところを読むとわかった気がする。
パーサジェネレータは、構文規則を読み取ることでこの構文解析表をつくり上げるという処理を主に行います。 パーサジェネレータも「構文解析表の構築後、それをもとにして構文解析を行う」ような機能がついていればそれはパーサであるとも言えます。
🗿🗿🗿
最も広く使われているパーサジェネレータはYacc(Yet Another Compiler Compiler)だが、RubyはBisonというYaccの新しいバージョンを使用している。parse.y
ファイルにはRubyコードを書く際に使う構文と文法が定義されている。.y
拡張子は文法規則ファイルであることを表している。
parse.yのソース: https://github.com/ruby/ruby/blob/master/parse.y
Rubyは、Ruby自体をビルドする過程でBisonを使いパーサコードを生成する。 構文解析の処理には、実際には2つの独立したステップが存在している(図3)。
(図3)
🗿🗿🗿
この図のパーサコードのファイル名が"parse.y"になってたけど"parse.c"の間違いっぽい
ここらで言葉の意味がよくわからなくなってきたので整理しよう...
<簡単なまとめ> parse.y: 文法規則 パーサ: 構文解析器、構文解析を行う パーサジェネレータ: 構文解析表をつくる(Yacc, Bison) LALR: パーサが使うアルゴリズム
パーサコード(parse.c)=パーサじゃないかもだけど≒パーサなのでは??
🗿🗿🗿
Rubyプログラムの実行に先立って、RubyはRuby自体をビルドする過程でBisonを使って文法規則ルール(parse.y)からパーサコード(parse.c)を生成する。その後、プログラムの実行時にこの生成されたパーサコードは字句解析器によって返されたトークン列を解析する。
parse.yとparse.cは字句解析コードも含んでいるため図3は左下側の字句解析処理に向けて矢印が引かれている。
LALR構文解析アルゴリズム
構文解析器はLALR(Loo-Ahead, Left to Right, Rightmost derivation)というアルゴリズムを使ってトークン列を左から右に処理していく。その処理の中でトークンの並びがparse.yにある文法規則の1つ以上のパターンと一致するかを見ていく。また、構文解析器はどの文法規則に一致するかを判断するために必要に応じてトークン列を先読みする。
例1
例えば、
Me gusta el Ruby.
というスペイン語のフレーズを
I like Ruby.
という英語に翻訳する場合を考える。
これを翻訳するためにBisonとYaccの文法規則を使って次のような単純な文法を書くことができる。左側のSpanishPhrase
が文法名で右側のMe gusta el Ruby
は文法規則にマッチするトークン列である。
# スペイン語のフレーズにマッチするシンプルな文法規則 SpanishPhrase : Me gusta el ruby { printf("I like Ruby\n"); }
この文法規則が表している内容は、もしトークン列のストリームがme, gusta, el, rubyと順番も含めて等しいならマッチする。もしマッチしたなら、Bisonが生成したパーサはCコードを実行し、翻訳された英文を出力する。図4は構文解析処理の内容を示している。
(図4)
この場合、トークンがSpanishPhrase規則にマッチするのは明白だ。
例2
例1を改良して、フレーズ1とフレーズ2にマッチするようパーサを強化することとする。
[フレーズ1]Me gusta el Ruby. [フレーズ2]Le gusta el Ruby.
改良した文法ファイルは以下の通り。
SpanishPhrase : VerbAndObject el ruby { printf("%s Ruby\n", $1); }; VerbAndObject: SheLikes | ILIKE { $$ = $1; }; SheLikes: le gusta { $$ = "She likes"; } ILikes: me gusta { $$ = "I like"; }
🗿これC言語の書き方かな??なんで;
ある行とない行があるんだろ...まいっか
1つだった文法規則が4つに増えている。また、子の文法規則から親に値を返すためにBisonの$$ディレクティブを使っており、親から子の値を参照するために$1ディレクティブを使っている。
elとrubyという字句がSpanishPhraseにマッチすることはわかるが、leとgustaはマッチしない。最終的にはVerbAndObjectがマッチするが、4つある文法規則のうちどの規則がマッチするかを試すべき規則なのかはパーサはどうやって判断するのだろうか。
ここでLALRパーサの出番になる。LALRのLRの意味を先に説明する。
- L(left): トークンのストリームを処理している間、パーサが左から右に動いていくことを意味する
- R(rightmost derivation): パーサが一致する文法規則を見つけるために、シフト/還元技法を使ってボトムアップ戦略をとることを意味する
フレーズ2でこのアルゴリズムがどう動くかを見ていく。まずパーサは入力トークンのストリームをとる。(図5-1番上) 次に、パーサはトークン列を左にシフトして、図5-上から2番目に示すように文法規則スタックと呼ばれるものを作成する。パーサはleトークンだけ処理したいので、さしあたってはこのトークンはスタックに置いたままにする。
(図5)
続いて、次のトークンを左にシフトする。(図5-上から3番目) ここでパーサはマッチする文法規則を探すのを止める。SheLikes規則に一致したためだ。(図5-1番下) このパーサがトークンのペアを1つの一致した規則に置き換える操作を還元(reduce)と呼ぶ。
ここでパーサは再び還元する。今の状態は別の文法規則であるVerbAndObject
にマッチするからだ。
パーサがどうやって還元できると判断してトークン列のシフトを止めたのだろう。一致する文法規則は複数ある。 ここでLALRのlook aheadの出番となる。一致する正しい規則を見つけるために、パーサは次のトークンを先読みする。加えて、パーサは次のトークンに依存して起こりうることやどの文法規則が解析されたかといった状態テーブルを保持している。 この例では、状態テーブルには「もし次のトークンがelである場合、パーサは新しいトークンをシフトする前にまずSHeLikes規則によってまとめるべきである」ということを示すエントリが含まれている。
Ruby用の実際のLALR状態テーブルは、生成されたparse.c内で見ることができる。
フレーズ2のシフト/還元操作を続けると、最終的には親の文法規則SpanishPhrase
に一致する。
スペイン語から英語へ翻訳する例と同じ方法でRubyはプログラムを解析している。parse.y内ではRubyの構造と構文が定義された数百の規則をみることができる。これらには親と子の規則があり、子の規則が親の規則に返した値は$$や$1といったシンボルを使って参照できる。唯一の違いは、SpanishPhrase文法の例は実に簡単だが、Rubyの文法は非常に複雑である。複雑ではあるものの、それは単にparse.c内に生成された状態テーブルが巨大というだけだ。
Rubyの状態テーブルがどう複雑かの雰囲気をつかむにはRubyの-yオプションを試すといい。
🗿🗿🗿
ruby -y
とは?
https://docs.ruby-lang.org/ja/latest/doc/spec=2frubycmd.html#ruby
コンパイラデバッグモード。スクリプトを内部表現にコンパイルする時の構文解析の過程を表示します。この表示は非常に冗長なので, コンパイラそのものをデバッグする人以外には必要ないと思います。
🗿🗿🗿
10.times
でputs n
する例で実行してみると、とても長い。
$ ruby -y lex1.rb Starting parse Entering state 0 Reducing stack by rule 1 (line 1295): lex_state: NONE -> BEG at line 1296 vtable_alloc:12392: 0x0000600001b55bc0 vtable_alloc:12393: 0x0000600001b55be0 cmdarg_stack(push): 0 at line 12406 cond_stack(push): 0 at line 12407 -> $$ = nterm $@1 (1.0-1.0: ) Stack now 0 Entering state 2 Reading a token: lex_state: BEG -> CMDARG at line 9049 Next token is token "local variable or method" (1.0-1.7: require) Shifting token "local variable or method" (1.0-1.7: require) Entering state 35 Reading a token: Next token is token "string literal" (1.8-1.9: ) Reducing stack by rule 742 (line 5567): $1 = token "local variable or method" (1.0-1.7: require) -> $$ = nterm operation (1.0-1.7: ) Stack now 0 2 Entering state 126 Reducing stack by rule 78 (line 1794): $1 = nterm operation (1.0-1.7: ) -> $$ = nterm fcall (1.0-1.7: ) Stack now 0 2 Entering state 80 Next token is token "string literal" (1.8-1.9: ) Reducing stack by rule 292 (line 2723): cmdarg_stack(push): 1 at line 2737 -> $$ = nterm $@16 (1.7-1.7: ) Stack now 0 2 80 Entering state 235 Next token is token "string literal" (1.8-1.9: ) Shifting token "string literal" (1.8-1.9: ) Entering state 216 Reducing stack by rule 607 (line 4706): -> $$ = nterm string_contents (1.9-1.9: ) Stack now 0 2 80 235 216 Entering state 437 Reading a token: Next token is token "literal content" (1.9-1.15: "ripper") Shifting token "literal content" (1.9-1.15: "ripper") Entering state 503 Reducing stack by rule 613 (line 4802): $1 = token "literal content" (1.9-1.15: "ripper") -> $$ = nterm string_content (1.9-1.15: ) Stack now 0 2 80 235 216 437 Entering state 507 Reducing stack by rule 608 (line 4716): $1 = nterm string_contents (1.9-1.9: ) $2 = nterm string_content (1.9-1.15: ) -> $$ = nterm string_contents (1.9-1.15: ) Stack now 0 2 80 235 216 Entering state 437 (省略)
Rubyの文法規則
parse.yの中の実際のRubyの文法規則をみてみよう。
最初の規則top_compstmt
はすべてRubyプログラムで全体に対して一致するルート文法規則を意味している。
# parse.y(一部抜粋) program : { SET_LEX_STATE(EXPR_BEG); local_push(p, ifndef_ripper(1)+0); } top_compstmt { /*%%%*/ if ($2 && !compile_for_eval) { NODE *node = $2; /* last expression should not be void */ if (nd_type_p(node, NODE_BLOCK)) { while (node->nd_next) { node = node->nd_next; } node = node->nd_head; } node = remove_begin(node); void_expr(p, node); } p->eval_tree = NEW_SCOPE(0, block_append(p, p->eval_tree, $2), &@$); /*% %*/ /*% ripper[final]: program!($2) %*/ local_pop(p); } ; top_compstmt : top_stmts opt_terms { $$ = void_stmts(p, $1); } ; top_stmts : none { /*%%%*/ $$ = NEW_BEGIN(0, &@$); /*% %*/ /*% ripper: stmts_add!(stmts_new!, void_stmt!) %*/ } | top_stmt { /*%%%*/ $$ = newline_node($1); /*% %*/ /*% ripper: stmts_add!(stmts_new!, $1) %*/ } : : primary : literal | strings | xstring | regexp | words | qwords | symbols | qsymbols | var_ref | backref | tFID { /*%%%*/ $$ = NEW_FCALL($1, 0, &@$); /*% %*/ (省略) | method_call | method_call brace_block { /*%%%*/ block_dup_check(p, $1->nd_args, $2); $$ = method_add_block(p, $1, $2, &@$); /*% %*/ /*% ripper: method_add_block!($1, $2) %*/ } | lambda | k_if expr_value then compstmt if_tail k_end : :
次のリストに移るとRubyスクリプト全体に一致する複雑な子規則がある。Rubyの解析が第1の文法規則(primary)へ到達すると、method_call
とbrace_block
という2つの一致する子規則に遭遇する。
method_call
規則はFixnumオブジェクト10のtimesメソッドを呼び出している10.times
の部分に一致する。
文法規則(簡素版) method_call: ... | primary_value '.' operation2 | ...
method_call
規則がピリオドとoperation2
規則が続く別のprimaryの値に一致していることがわかる。
文法規則(簡素版) primary_value: primary primary: literal | ...
operation2
文法規則はtimesメソッドに一致する。
文法規則(簡素版) operation2: identifier | ...
timesメソッドに渡されたdo ... puts ... end
ブロックの内容をどう解析しているのかというと、brace_block
規則が使われる。
文法規則(簡素版) brace_block: ... | keyword_do opt_block_param compstmt keyword_end | ...
それぞれ以下のように対応する。
keyword_do
: doopt_block_param
: |n|compstmt
: puts nkeyword_end
: end
Ripperを使う その2
ここではRubyがコードをどう解析するかを示す情報をRipperを使って表示する方法を学ぶ。
# lex1.rb require 'ripper' code = <<STR 10.times do |n| puts n end STR puts code pp Ripper.sexp(code)
先ほどの例との違いはRipper.lex
の代わりにRipper.sexp
を呼び出していることだ。
参考: https://docs.ruby-lang.org/ja/latest/method/Ripper/s/sexp.html
実行結果は以下のようになる。
$ ruby lex1.rb 10.times do |n| puts n end [:program, [[:method_add_block, [:call, [:@int, "10", [1, 0]], [:@period, ".", [1, 2]], [:@ident, "times", [1, 3]]], [:do_block, [:block_var, [:params, [[:@ident, "n", [1, 13]]], nil, nil, nil, nil, nil, nil], false], [:bodystmt, [[:command, [:@ident, "puts", [2, 2]], [:args_add_block, [[:var_ref, [:@ident, "n", [2, 7]]]], false]]], nil, nil, nil]]]]]
コード内のトークンを抽象構文木(AST)と呼ばれる複雑な内部データ構造に変換して返している。
まずは参考に2 + 2
をRipperに渡したときの例を示す。
[:program, [[:binary, [:@int, "2", [1, 0]], :+, [:@int, "2", [1, 4]]]]]
(図6)
puts n
に該当する処理をみてみる。
(図7)
Ripperの出力では以下の部分に該当する。
[[:command, [:@ident, "puts", [2, 2]], [:args_add_block, [[:var_ref, [:@ident, "n", [2, 7]]]], false]]],
[2, 2]
はRipperがputsの呼び出しをソースの2行目2列目に見つけたことを示している。また、[:@ident, "puts", [2, 2]]
のように、RipperがAST内の各ノードの配列を出力していることがわかる。
ここまできてRubyのプログラムはやっとRubyにとって意味を成しはじめる。Rubyは今や、あらゆる意味を成す可能性がある単純なトークン列のストリームではなく、puts nと書いた際に意図していたことの詳細を手にしている。
サンプルコードのAST全体を図7に表す。
(図8)
method add block
はブロック引数付きのメソッド呼び出し10.times do
を意味している。callツリーノードは実際のメソッド呼び出し10.times
を示している。
おわりに
理解しやすくするためにまとめたつもりが本を読んだほうがわかりやすいのでは...という結果になりました。
この先何章まで読み進められるかわかりませんが、内容は興味深いものでした〜