Zenn
Open18

Ruby Under a Microscope

dak2dak2

LALR Parser

LA

Look Ahead 先読み

LR

  • Left
    • トークンのストリーム処理をしている間、パーサが左から右へ動く
  • Right
    • パーサが一致する文法規則を見つけるために、シフト / 還元技法を使ってボトムアップ戦略を取る

還元 = スタックに積まれた文字が文法規則に一致するとマッチする文法規則の探索を Stop
文法規則スタックに積まれた文字が複数の文法規則に当てはまる場合、次のトークンを先読みした上で文法規則に当てはまるかを確認する

dak2dak2

2章: コンパイル

  • Ruby コードを実行する仮想マシン YARV
    • 初めにコードを仮想マシンが理解できる一連の低級な命令(YARV命令列)、バイトコードへとコンパイルする
  • YARV vs JVM
    • Ruby は独立したツールとしてコンパイラを提供しない。代わりに、内部的に自動でコードをバイトコード命令列へとコンパイル
    • Ruby は決してコードを機械語へとコンパイルしない。バイトコード命令列へと翻訳

YARV 命令列はローカルテーブルを持つ
例えば 10.times do |n| のようなブロックパラメータはローカルテーブルに格納され各YARV命令列にひもづく

dak2dak2
def add_two_keyword(a, b: 5)
   sum = a+b
end

で生成されるYARV命令列は key? と delete メソッドを呼び出している
=> keyword 引数は内部的にハッシュオブジェクトを使って実装されていることを示す(今はどうか不明)

ローカルテーブル

irb(main):001" code=<<END
irb(main):002" 10.times do |n|
irb(main):003"  puts n
irb(main):004" end
irb(main):005> END
=> "10.times do |n|\n puts n\nend\n"
irb(main):006> pp RubyVM::InstructionSequence.compile(code).disasm
"== disasm: #<ISeq:<compiled>@<compiled>:1 (1,0)-(3,3)>\n" +
"0000 putobject                              10                        (   1)[Li]\n" +
"0002 send                                   <calldata!mid:times, argc:0>, block in <compiled>\n" +
"0005 leave\n" +
"\n" +
"== disasm: #<ISeq:block in <compiled>@<compiled>:1 (1,9)-(3,3)>\n" +
"local table (size: 1, argc: 1 [opts: 0, rest: -1, post: 0, block: -1, kw: -1@-1, kwrest: -1])\n" +
"[ 1] n@0<AmbiguousArg>\n" +
"0000 putself                                                          (   2)[LiBc]\n" +
"0001 getlocal_WC_0                          n@0\n" +
"0003 opt_send_without_block                 <calldata!mid:puts, argc:1, FCALL|ARGS_SIMPLE>\n" +
"0005 leave                                                            (   3)[Br]\n"
=> "== disasm: #<ISeq:<compiled>@<compiled>:1 (1,0)-(3,3)>\n0000 putobject                              10                        (   1)[Li]\n0002 send                                   <calldata!mid:times, argc:0>, block in <compiled>\n0005 leave\n\n== disasm: #<ISeq:block in <compiled>@<compiled>:1 (1,9)-(3,3)>\nlocal table (size: 1, argc: 1 [opts: 0, rest: -1, post: 0, block: -1, kw: -1@-1, kwrest: -1])\n[ 1] n@0<AmbiguousArg>\n0000 putself                                                          (   2)[LiBc]\n0001 getlocal_WC_0                          n@0\n0003 opt_send_without_block                 <calldata!mid:puts, argc:1, FCALL|ARGS_SIMPLE>\n0005 leave                                                            (   3)[Br]\n"
dak2dak2

3章: Ruby はどのようにコードを実行するか

YARV はマイクロプロセッサのように機能する
途中の引数、返り値を把握するために内部的にスタックを使用
Ruby プログラムのコールスタックも把握し続ける
2重スタックマシン

YARV 命令のコード

insns.def => YARV 命令のCソースコード
Ruby は Ruby を使って insns.def を処理してvm.incというCソースコード生成 => それと合わせて miniruby というコマンドをビルドし、これを用いて大きな ruby コマンドを作る

ベンチマーク

YARV を使用すると直接ASTを辿るより速い
なぜ?
繰り返し数が少ない場合だとYARV命令列へのコンパイルが必要ないので、 Ruby1.8.7の方が速いが、数が多くなってくると Ruby2.0などの方が速くなる

Ruby変数のローカルアクセスと動的アクセス

Ruby は変数に保存した値全てを、YARV命令列の引数や返り値と一緒にYARVスタック上に格納

SP: スタックポインタ、内部スタックの1番上を指す
EP: メソッド内のローカル変数の場所を指す
メソッド内部のブロックでローカル変数を参照していても、EPがあることで辿れる
親やそのまた親のスコープなどのローカル変数にアクセスできる 動的変数アクセスの仕組み
EP のおかげでスコープごとに変数を探索できていたのか

dak2dak2

4章: 制御構造とメソッドディスパッチ

制御構造

return や break は YARV 上で throw 命令を実行する
YARV コード片はそれぞれ補足テーブルを持っていて、throw 命令を実行するとブレークポインタを含む補足テーブルがあるかどうかをチェックするために、Rubyのコールスタックを辿っていく

メソッドディスパッチ

メソッド呼び出し時にYARVでsend命令を使う処理のこと

Ruby は内部的にメソッドを11種類のタイプに分けて扱っている
ISEQ(Instruction Sequence): ユーザー定義メソッドで最もよく使われる
ATTRSET: attr_writer によって生成されるメソッド
IVAR: attr_reader によって呼び出される時に使用。instance variable の略
etc...

Ruby がキーワード引数をどう実装しているのか

% ruby -v
ruby 3.4.2 (2025-02-15 revision d2930f8e7a) +PRISM [arm64-darwin24]

irb(main):001" code = <<END
irb(main):002" def add_two(a: 2, b: 3)
irb(main):003"  a+b
irb(main):004" end
irb(main):005" 
irb(main):006" puts add_two(a: 1, b: 2)
irb(main):007> END
=> "def add_two(a: 2, b: 3)\n a+b\nend\n\nputs add_two(a: 1, b: 2)\n"
irb(main):008> puts RubyVM
RubyVM
=> nil
irb(main):009> puts RubyVM::InstructionSequence.compile(code).disasm
== disasm: #<ISeq:<compiled>@<compiled>:1 (1,0)-(5,24)>
0000 definemethod                           :add_two, add_two         (   1)[Li]
0003 putself                                                          (   5)[Li]
0004 putself
0005 putobject_INT2FIX_1_
0006 putobject                              2
0008 opt_send_without_block                 <calldata!mid:add_two, argc:2, kw:[a,b], FCALL|KWARG>
0010 opt_send_without_block                 <calldata!mid:puts, argc:1, FCALL|ARGS_SIMPLE>
0012 leave

== disasm: #<ISeq:add_two@<compiled>:1 (1,0)-(3,3)>
local table (size: 3, argc: 0 [opts: 0, rest: -1, post: 0, block: -1, kw: 2@0, kwrest: -1])
[ 3] a@0        [ 2] b@1        [ 1] ?@2
0000 getlocal_WC_0                          a@0                       (   2)[LiCa]
0002 getlocal_WC_0                          b@1
0004 opt_plus                               <calldata!mid:+, argc:1, ARGS_SIMPLE>[CcCr]
0006 leave                                                            (   3)[Re]
=> nil

キーワード引数を呼び出すと、内部的に Hash#key? が呼ばれるみたいだが、Ruby 3.4.2 では呼ばれていないみたい

後で読む

https://zenn.dev/socialplus/articles/87d494abc908c0

dak2dak2

5章: オブジェクトとクラス

Ruby は作成したクラスの各オブジェクトを RObject という C 構造体に保存する
ref. https://docs.ruby-lang.org/capi/en/master/d7/da9/struct_r_object.html

RObject の中には RBasic 構造体と独自に作成したクラスのオブジェクトに固有の情報を含んでる
RObject 以外の情報は、各オブジェクトが含むインスタンス変数の配列、numiv にはインスタンス変数の配列へのポインタが格納
RBasic は 真偽値の集合 flags とクラスポインタの klass
内部的に Ruby は何らかの値の参照は全て VALUE ポインタを用いる

klass と ivptr の調査

irb(main):006* class Mathematician
irb(main):007*   attr_accessor :first_name
irb(main):008*   attr_accessor :last_name
irb(main):009> end
=> [:last_name, :last_name=]
irb(main):010> euler = Mathematician.new
=> #<Mathematician:0x0000000104a73708>
  • #<Mathematician とクラス名が表示されている箇所が euler オブジェクトが持つクラスポインタの値
  • 続く16新数 0x0000000104a73708 はオブジェクトへの VALUE ポインタ
irb(main):011> euler.first_name = 'Leonhard'
=> "Leonhard"
irb(main):012> euler.last_name = 'Euler'
=> "Euler"
irb(main):013> euler
=> #<Mathematician:0x0000000104a73708 @first_name="Leonhard", @last_name="Euler">

インスタンス変数はオブジェクトごとに異なる値を持つので、このインスタンス変数の値の配列をオブジェクトごとに保存している

irb(main):014> eucbuild =  Mathematician.new
=> #<Mathematician:0x0000000109e91c18>
irb(main):015> eucbuild.first_name = 'Eucbuild'
=> "Eucbuild"
irb(main):016> eucbuild
=> #<Mathematician:0x0000000109e91c18 @first_name="Eucbuild">

参照の関係性

一般的なオブジェクト

String や Array などは RObject 構造体を利用せず、それぞれ RString、RArray 構造体を利用
基本的にはユーザーが作成したクラスが RObject 構造体になりうる

また、小さな値やシンボルといった単純な値は VALUE ポインタの中に直接保存された上で扱われている

一般的なオブジェクトはインスタンス変数を持つか?

instance_variable_set を使えば格納できる

irb(main):017> str = "some string"
=> "some string"
irb(main):018> str.instance_variables
=> []
irb(main):019> str.instance_variable_set("@val", "one")
=> "one"
irb(main):020> str.instance_variables
=> [:@val]

dak2dak2

クラス変数 vs クラスインスタンス変数

  • クラスインスタンス変数
    • 対象の RClass 構造体内のインスタンス変数を参照
    • 継承元と継承先で値が違う
  • クラス変数
    • 最も上位のスーパークラスの RClass 構造体で見つかったクラス変数を参照
    • 継承元と継承先で同じ値に

実際の RClass 構造体

  • klass: クラスポインタ
  • m_tbl: メソッドテーブル
  • iv_index_tbl: インスタンスレベルの属性名
  • super: スーパークラスへのポインタ
    • 継承を実現
  • iv_tbl: クラスレベルのインスタンス変数
  • const_tbl: 定数テーブル
  • origin: Module#prepend によって使われる
  • refined_class: Refinements で使われる
  • allocator: 新しいオブジェクトにメモリを割り当てるために使われる

Ruby はクラスメソッドをどこに保存する?

irb(main):001> ObjectSpace.count_objects[:T_CLASS]
=> 2312
irb(main):002> class Mathematician; end
=> nil
irb(main):003> ObjectSpace.count_objects[:T_CLASS]
=> 2314

定義したクラスは1つなのに、実際には2つのクラスを生成している
=> メタクラスの singleton

全ての Ruby オブジェクト(RObject)はクラスポインタ(klass)とインスタンス変数(ivptr)の配列の組み合わせである

dak2dak2

6章: メソッド探索と定数探索

メソッド呼び出し時の探索

モジュールはクラス

クラスと同じように RClass 構造体と rb_classext_struct 構造体のペアを作成する

ref. https://zenn.dev/link/comments/5956513d550033

ただ、iv_index_tbl は取り除かれる
なぜなら、モジュールに対して new メソッドは呼び出せないから
これはモジュールがオブジェクトレベルの属性を持たないことを意味する

また、refined_class と allocator は使わないので除かれる
内部的にはスーパークラスを持っているので super はそのまま

クラスにモジュールを include する

module Professor
end

class Mathematician < Person
    include Professor
end
  • include をすると Professor モジュール用に RClass 構造体のコピーを生成
  • Mathematician クラスのスーパークラスとして参照される
    • Person の RClass の前にスーパークラスとして参照される
  • 継承チェーンは連結リスト
    • というより klass 間の参照は連結リストで表現されていそう

メソッド探索アルゴリズム

対象のクラスかモジュールが見つかるまで super ポインタを使って RClass の Node を辿っていくだけ

Ruby における多重継承

複数のモジュールを include できるが、継承ツリーの連結リストは1本にすることを強制している
シンプルさを維持しながら複数モジュールを include しつつ多重継承のように複数の振る舞いをクラスに追加できる

メソッドキャッシュ

グローバルメソッドキャッシュ

メソッド探索は探索するスーパークラスの数が多いほど時間がかかる
これを軽減するために、探索結果をキャッシュする

klass defined_class
Fixnum#times Integer#times

klass はレシーバ、defined_class は 呼び出された times メソッドがどのクラスで定義されていたかを示す
2度目に Fixnum#times を呼び出したら即 Integer#times を呼び出せれば良い

インラインメソッドキャッシュ

Ruby が実行するコンパイルされたYARV 命令列に情報を記録

YARV の send 命令に検索結果をキャッシュしてあげることで高速化
インラインメソッドキャッシュから先に参照され、見つからなかったらグローバルのキャッシュを参照
https://magazine.rubyist.net/articles/0012/0012-YarvManiacs.html

メソッドキャッシュのクリア

メソッドを削除、追加するごとにグローバルおよびインラインのメソッドキャッシュはクリアされる
メソッド探索の結果が変更されてしまう可能性があるので

また、Refinements を使ったり、ある種のメタプロを採用した際にもクリアされる

複数モジュールをインクルードした時の継承ツリー

class Mathematician < Person
  include Professor
  include Employee
end

include しているモジュールをコピーした RClass 構造体を上から順に継承ツリーの連結リストに突っ込んでいく
まず Professor を Node に突っ込んで、その後に Employee を突っ込む

dak2dak2

Module#prepend の動き

prepend が記述された対象のクラスのコピーを生成し、prepend されたモジュールのスーパークラスとしてそのコピーを設定する

連結リストにおいて、prepend されたクラスは対象クラスの上に位置するように見える
ただ、prepend されたクラスのスーパークラスとして見えている対象クラスは、対象クラスのコピーであり origin ポインタを使って参照を保っている

irb(main):001* module Professor
irb(main):002*   def name
irb(main):003*     "Prof. #{super}"
irb(main):004*   end
irb(main):005> end
irb(main):006> 
irb(main):007* class Mathematician
irb(main):008*   attr_accessor :name
irb(main):009*   prepend Professor
irb(main):010> end
=> Mathematician
irb(main):011> poincare = Mathematician.new
=> #<Mathematician:0x0000000124b5cbe0>
irb(main):012> poincare.name = "Henri Poincare"
=> "Henri Poincare"
irb(main):013> p poincare.name
"Prof. Henri Poincare"
=> "Prof. Henri Poincare"

dak2dak2

インクルードした後でモジュールを変更する

インクルードされたクラスは元のモジュールとメソッドテーブルを共有する

Ruby はインクルードした際にメソッドをコピーせず、RClass をコピーしている
つまり、m_tbl のメソッドテーブルのポインタだけがコピーされる
だから、後からメソッドが追加されてもメソッドテーブルへの追加となるだけなのでポインタ経由で参照できる

irb(main):001* module Professor
irb(main):002*   def lectures; end
irb(main):003> end
irb(main):004> 
irb(main):005* class Mathematician
irb(main):006*   attr_accessor :first_name, :last_name
irb(main):007*   include Professor
irb(main):008> end
irb(main):009> 
irb(main):010> fermat = Mathematician.new
irb(main):011> fermat.first_name = 'Pierre'
irb(main):012> fermat.last_name = 'de Fermat'
irb(main):013> 
irb(main):014> fermat.methods.grep(/name|lectures/).sort
=> [:first_name, :first_name=, :last_name, :last_name=, :lectures]
irb(main):015* module Professor
irb(main):016*   def lectures; end
irb(main):017*   def primary_classroom; end
irb(main):018> end
=> :primary_classroom
irb(main):019> fermat.methods.grep(/name|lectures|primary_classroom/).sort
=> [:first_name, :first_name=, :last_name, :last_name=, :lectures, :primary_classroom]

補足

インクルードされたサブモジュールも後で確認されてそう
hire_date メソッドが追加されてる
書籍では追加されないと記述されていたが

irb(main):001* module Professor
irb(main):002*   def lectures; end
irb(main):003> end
irb(main):004> 
irb(main):005* class Mathematician
irb(main):006*   attr_accessor :first_name, :last_name
irb(main):007*   include Professor
irb(main):008> end
irb(main):009> 
irb(main):010> fermat = Mathematician.new
irb(main):011> fermat.first_name = 'Pierre'
irb(main):012> fermat.last_name = 'de Fermat'
irb(main):013> 
irb(main):014> fermat.methods.grep(/name|lectures/).sort
=> [:first_name, :first_name=, :last_name, :last_name=, :lectures]
irb(main):015* module Employee
irb(main):016*   def hire_date; end
irb(main):017> end
irb(main):018> 
irb(main):019* module Professor
irb(main):020*   include Employee
irb(main):021> end
=> Professor
irb(main):022> fermat.methods.grep(/name|lectures|hire_date/).sort
=> [:first_name, :first_name=, :hire_date, :last_name, :last_name=, :lectures]

後で読む

モジュールをどうコピーしているか
https://github.com/ruby/ruby/blob/5ab0b9143ab5a92b134c6788f893ac539825b300/class.c#L1150-L1173

dak2dak2

定数探索

レキシカルスコープを探索して、見つからなければ親のレキシカルスコープを探索する流れを繰り返す
=> メソッド探索とアルゴリズム構造は同じ

module NameSpace
  SOME_CONSTANT = "Some Value"
  class MyClass
    p SOME_CONSTANT
  end
end

仮にメソッド探索と同じだとすると、SubClass は SOME_CONSTANT をどのように見つけるか?
NameSpace モジュールはスーパークラスではないのでどのように辿るのか => レキシカルスコープ

クラスやモジュール(RClass 構造体)を作成するたびにレキシカルスコープを作成して定数を格納する

定数の探索は nd_next ポインタを利用して親のレキシカルスコープを辿ることになる

厳密な探索

まず autoload を辿ってからスーパークラスのチェーン内を探索する
autoload は引数で与えられた定数が未定義だった場合に Ruby に新しいソースファイルを開いて読み込むように指示する
=> Rails 側でやってるね

dak2dak2

7章: ハッシュテーブル: Ruby 内部の働き者

メソッドや定数、整数やシンボルといった一般的なオブジェクトのインスタンス変数もハッシュテーブルに格納される

RHash 構造体

my_hash[:key] = "value"

このコード行を実行すると Ruby は st_table_entry という新しい構造体を作成して、 my_hash 用のハッシュテーブルに保存

Ruby 1.9 では新しい空のハッシュのために11個のバケットを作成する

ハッシュ関数を通してハッシュ値を生成し、ハッシュ値を使ってどのバケットに格納するかを決定する
異なるキーでも同じハッシュ値になって衝突する可能性はあるが、同じバケット内で連結リストとして格納される

ハッシュに格納する値が多くなってくるとバケット内のエントリの数が多くなってくる
上から順にエントリを辿っていくので線形探索になってしまう

エントリの再ハッシュ

バケットの密度が5を怖えくると11より多くのバケットを割り当ててエントリを再ハッシュして、連結リストの長さを短いままで保つようになっている
それにより数が多くなってもハッシュの要素を高速で取得できる

再ハッシュの実装内容: 後で読む

https://github.com/ruby/ruby/blob/5ab0b9143ab5a92b134c6788f893ac539825b300/st.c#L2233-L2247

Ruby 2.0 におけるハッシュ最適化

2.0 からハッシュに含まれる要素が6個以下の場合、Ruby はハッシュ値の計算を回避して、単純にハッシュデータを配列に保存する => これは pack されたハッシュという

st_table_entry と bucket のテーブルも作成せず、代わりに配列を作成して key pair を保存
7番目のキーと値を挿入したら、Ruby は配列を破棄してバケットの配列を作る
6個だと線形探索でもバケットの配列を使ってハッシュ値を計算するより速い

dak2dak2

8 章: Lisp から借用したアイディア

ブロックは関数と関数を呼び出す際に使用される環境の組み合わせ

str = "The quick brown fox"
10.times do
    str2 = "jumps over the lazy dog"
    puts "#{str} #{str2}"
end

Sussman と Steele によって定義されたクロージャの定義

  • ラムダ式: 引数のセットを取る関数
  • ラムダ(関数)を呼び出す時に使用される環境

Ruby のブロックで考えると

  • iseq はラムダ式へのポインタ: 関数あるいはコード片
  • EP はラムダを呼び出した時に使われる環境変ポインタ: 周囲のスタックフレームへのポインタ

while ループ vs each にブロックを渡すのどちらが速いか

A. each の方が遅くなる

ブロックを yield するために、rb_block_t 構造体を作成して、その構造体の EP に、参照元の環境を設定し、ブロックを eqach 呼び出しへと引き渡す
ループを繰り返すごとに YARV 内部スタックに新しいスタックフレームを作成し、ブロックのコードを呼び出す
最後にブロックの EP を新しいスタックフレームにコピーする
という一連の動作がオーバーヘッド

後で読む

rb_block_t ではなく rb_block struct に rename されていそう
https://github.com/ruby/ruby/blob/5ab0b9143ab5a92b134c6788f893ac539825b300/vm_core.h#L884-L898

スタックとヒープ

  • スタック
    • 各メソッドのローカル変数や返り値、および引数などを格納
    • メソッド呼び出しから返ると、YARV はスタックフレームとその中のすべての値を削除
  • ヒープ
    • メソッド呼び出しから返ってもしばらくの間必要になる情報を格納
    • 参照されている限りは残り続け、そうでなければ GC によって破棄され、他の用途のためにメモリを解放する

Ruby はスタック上にデータへの参照のみを保存する => VALUE ポインタを保存している
true、falseや nil、シンボルなど定数値への参照は実際の値
ただ、他のデータ型では、RObject 構造体へのポインタを指していて、実際の構造体はヒープ内にある

Ruby はラムダをどう作るか

このラムダを呼び出したらスタックとヒープ間でどういう現象が起こるのかを示す

def message_function
  str = "The quick brown fox"
  lamda do |animal|
    puts "#{str} jumps over the lazy #{animal}"
  end
end
function_value = message_function
function_value.call("dog")

lambda を呼び出すと現在のスタックフレームをヒープにコピーする

スタックフレームのコピーと一緒に2つの新しいオブジェクトをヒープ内に作成する

  • rb_env_t: 内部環境オブジェクト
    • スタックのヒープコピー用のラッパー
    • Binding クラスを使用することでプログラム内からこの環境オブジェクトに直接アクセスできる
  • rb_proc_t: Proc オブジェクト
    • lambda キーワードからの実際の返り値であり、message_function から返されている値でもある
    • iseq や EP を持つ rb_block_t を包含している
      • => Proc オブジェクトは内部にブロックを包むオブジェクトの一種
        • 通常のブロックと同様、Proc はブロックのコードと参照元の環境をクロージャとして保持し続ける
        • ヒープにコピーされた新しいスタックフレームをポイントするため、Ruby はこのブロックに EP を設定する

現在のスタックフレームをヒープにコピーしており、新しく作成された rb_prock_t 内部の EP がコピーしたスタックフレームを参照することで、メソッドスコープ内の str を参照できる

呼び出し時もそこまで大きく変わらず、Proc.call で呼び出すと新しいスタックフレームをヒープに作成し、ヒープの参照元の環境を示すように EP を設定

ラムダを呼び出した後でローカル変数を変更する

def message_function
  str = "The quick brown fox"
  runc = lamda do |animal|
    puts "#{str} jumps over the lazy #{animal}"
  end
  str = "The sly brown dog"
  runc
end
function_value = message_function
function_value.call("dog")

ヒープにスタックの新しいコピーを作成すると、コピーしたところを指すように、rb_control_frame_t 構造体中の EP をリセットする

dak2dak2

9章: メタプログラミング

  • クラスやモジュールのスコープ内では、self は常にそのクラスがモジュール
  • class か module キーワードを使うと Ruby は新しいレキシカルスコープを作成し、スコープのクラスを新しいクラスかモジュールに設定
  • クラスメソッド含むメソッド内では、 Ruby は self にそのメソッド呼び出しのレシーバを設定

Refinements

eval instance_eval binding

eval

eval に文字列を渡すと、Ruby は直ちにパースとコンパイルを行い、コードを実行する
Bison 文法規則と構文解析エンジンを使って文字列を字句解析して構文解析してコンパイル。YARV バイトコード命令を実行

ログインするとコメントできます