Ruby コード実行過程を雰囲気で理解する

調べようと思った背景

RubyKaigi2024 に際して現地でより多くの収穫を得るために、言語処理 (CRuby) の前提知識を整理しておきたい。まずは Ruby コードがどのような流れを経て実行されるのか確認する。

参考資料:Rubyのしくみ -Ruby Under a Microscope-

概観

下記資料にて綺麗にまとまっていたので引用する。上段の「CRuby 実行環境」の流れを追っていく。

コード実行の流れ

  1. Ruby コード
  2. 字句解析 (生成物: トークン列)
  3. 構文解析 (生成物: AST ノード)
  4. コンパイル (生成物: バイトコード)
  5. YARV 命令実行

字句解析

Ruby コードが実行されて最初に行われるのが字句解析。ただの文字列のかたまりであるコードに意味を与える。字句解析ではトークン列と呼ばれる「理解可能な単語の列」へと文字列を変換する。

構文解析

次に構文解析トークン列から AST ノードを生成する。このステップでは、パーサコードがトークン列を解析し、Ruby が理解できる文やフレーズにグルーピングする。

コンパイル

構文解析で生成された AST ノードをバイトコードに変換する。バイトコードとは、YARV が解釈できる低級な命令列のこと。

YARV 命令実行

コンパイラが生成したバイトコードYARV によって実行される。

サンプルコードで見てみる

字句解析、構文解析コンパイルまでの流れを実際のコードで見てみる。

字句解析

Ripper.lexRuby コードをトークン列に分割し、そのリストを出力する。

require 'ripper'
require 'pp'
code = <<STR
10.times do |n|
  puts n
end
STR
puts code
pp Ripper.lex(code)
❯ ruby lex.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]]

10 . times のように文字列を分解し、数値を示す on_int やメソッドや変数名を示す on_ident などの種別を付与していることが確認できる。[n, m]トークンが現れるコード位置を示している。

まとめ: 字句解析は、文字列を意味のある単位に分解し、それぞれに種別をラベリングしている。

構文解析

Ripper は字句解析だけでなく構文解析の結果も出力できる。Ripper.sexpRuby コードを S 式のツリーにして出力する。S 式は木構造データ形式のことのようだ。余談だが、Ruby もアイデアを継承している Lisp では S 式をソースコードの表現としても使うらしい。

require 'ripper'
require 'pp'
code = <<STR
10.times do |n|
  puts n
end
STR
puts code
pp Ripper.sexp(code)
❯ ruby sexp.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]]]]]

全て見ると大変なので一部を見てみる。

[[:command, [:@ident, "puts", [2, 2]], [:args_add_block, [[:var_ref, [:@ident, "n", [2, 7]]]], false]]]

[:@ident, "puts", [2, 2]] のような配列が構文木の1つのノードを示している。args_add_blockputs の引数とブロックを受け付けるためのノードである。さらに変数 n を参照するためのノード var_ref をネストしている。

まとめ: 構文解析では、与えられたトークン列から構文木を生成しており、構文木は意味のあるまとまり(ノード)を作ってネストして連結することで表現される。

コンパイル

RubyVM::InstructionSequence#disasmRuby コードを YARV 命令列に変換した結果を見れる。

code = <<STR
10.times do |n|
  puts n
end
STR
puts RubyVM::InstructionSequence.compile(code).disasm
❯ ruby disasm.rb
== disasm: #<ISeq:<compiled>@<compiled>:1 (1,0)-(3,3)>
0000 putobject                              10                        (   1)[Li]
0002 send                                   <calldata!mid:times, argc:0>, block in <compiled>
0005 leave

== disasm: #<ISeq:block in <compiled>@<compiled>:1 (1,9)-(3,3)>
local table (size: 1, argc: 1 [opts: 0, rest: -1, post: 0, block: -1, kw: -1@-1, kwrest: -1])
[ 1] n@0<Arg>
0000 putself                                                          (   2)[LiBc]
0001 getlocal_WC_0                          n@0
0003 opt_send_without_block                 <calldata!mid:puts, argc:1, FCALL|ARGS_SIMPLE>
0005 leave                                                            (   3)[Br]

上部がトップレベルで呼び出す 10.times の命令列、下部がブロックで渡される puts n の命令列になっている。異なるスコープは別々の命令列として実行されるらしい。どちらの命令列でもメッセージを送るオブジェクト(レシーバ)が初めに設定されていることが分かる。そして send で オブジェクトに対してメッセージを送っている様子が見て取れる。最後の leave は return 文を示している。

まとめ: コンパイルでは、YARV 命令列が生成される。レシーバの設定やメソッド呼び出しが命令として順番に並べられる。

一言

サンプルコードで処理過程を追うことで雰囲気を理解できた。Ruby 完全理解した。