コンテンツにスキップ

Part 3 ローカル変数と不変 Scope

この章のゴールは、x = 1で型を覚え、あとでxを読めるようにすることです。そのために「変数名 → 型」の対応である型環境(Scope)を導入し、文から文へと縫って渡します。


x = 1と書いたら「xInteger(正確には1)」と覚え、あとでxを読んだらその型を返したいです。覚えておく場所が要ります。それがScope(型環境)です。ただの「変数名 → 型」の対応です。

class Scope
def initialize(locals = {})
@locals = locals.freeze
end
def local(name) # その名前の型(未束縛なら nil)
@locals[name]
end
def with_local(name, type) # 束縛を 1 つ足した「新しい」Scope を返す
Scope.new(@locals.merge(name => type))
end
end

これは、あなたがコードを読むとき頭の中でやっていることと同じです。x = 1の行を見たら「xは数だな」と覚え、数行下でxが出てきたら「さっきの数」と思い出します。その手元のメモをプログラムが持てるデータにしたのがScopeです。型チェッカーも人間と同じで、変数を見たら「これは何だっけ」と引けるメモが要ります。

ポイントは不変であることです。with_localは元のScopeを変えず、束縛を足した新しいScopeを返します。『しくみ』がtyEnvを破壊せず{ ...tyEnv, x: 型 }とコピーしたのと同じ作法で、これをオブジェクトの形にしました。

なぜわざわざ不変にするのでしょうか。ふつうのHash@locals[name] = typeと書き換えても動きそうです。理由は少し先取りになります。Part 4、5でifの枝ごとに「この枝の中でだけxInteger」という別々のメモを持ちたくなるからです。

ここで言うメモは型チェッカーが内部で持つもので、Rubyの実行時の変数スコープとは別の話です。その内部のHashを1個そのつど書き換えていたら、枝の中で足したメモが枝の外の検査にまで残ってしまいます。新しいScopeを返す不変設計なら、「その枝だけのメモ」を元を汚さずに作って渡せます。いまは恩恵が見えにくいですが、この「足しても元は変わらない」性質が、後の章の絞り込み(ナローイング)で効いてきます。

  • ① 型理論:変数の型を覚える対応は型環境tyenvです(『しくみ』3、4章)。
  • ② Rubyだと:ローカル変数は当たり前に使います。x = ...; ...x...
  • ③ Rigorだと:Scopeは不変です。束縛を足すと新しいScopeを返します(本物のRigorも同じ不変設計)。

type_ofに1行足すだけです。ローカル変数の読みは、Scopeから型を引きます。

when Prism::LocalVariableReadNode then scope.local(node.name) || Type::Dynamic.new

type_ofにはscopeを渡すようにします。受け手や引数の型を求める再帰にも一緒に渡します。未束縛ならDynamicです。型エラーを出して脅かしません。


ここまでの1 + 2foo.barは、評価すると型が出てくるだけでした。でもx = 1は違います。型(1)が出てくるのに加えて、「以後xが使える」という効果をあとに残します。こういう、値を出すだけでなくスコープを増やすものを「文」と呼んでいます。この「あとに効く」分を取りこぼさないために、文を1つ評価して[その文の型,更新後のスコープ]を返す関数を作ります。

def eval_statement(node, scope, diagnostics)
case node
when Prism::LocalVariableWriteNode
type = type_of(node.value, scope, diagnostics) # 右辺の型を求めて…
[type, scope.with_local(node.name, type)] # …その名前に束縛した新スコープを返す
else
[type_of(node, scope, diagnostics), scope] # 代入以外はスコープを変えない
end
end

checkannotateは、文の列を上から評価しながらスコープを縫っていきます。前の文で更新したスコープを次の文へ手渡していきます。コードを上から読んで「ここまでで何が定義済みか」を覚えながら進むのと同じ動きです。

scope = Scope.new
program.statements.body.each do |stmt|
_type, scope = eval_statement(stmt, scope, diagnostics) # scope を更新して次へ
end

これで「上で定義した変数を、下で使う」が型でも追えます。

check("x = 1\nx + 2") # OK
check("x = \"a\"\nx + 1") # ["expected String but got 1"]
  • ① 型理論:文を順に評価し、環境を育てながら進みます(『しくみ』4章の逐次実行)。
  • ② Rubyだと:上から下へ、定義した変数が後ろで見えます。
  • ③ Rigorだと[型,スコープ]を返して縫います。スコープは不変なので「どこで何が見えるか」がはっきりします。

Rubyでは同じ変数に違う型を入れ直せます。with_localは単に束縛を上書きするので、これも自然に追えます。

# annotate("x = 1\nx\nx = \"a\"\nx\n")
1: 1 # x は 1
2: 1 # x を読む → 1
3: "a" # x に "a" を再代入
4: "a" # x を読む → "a"(型が変わった)
check("x = 1\nx = \"a\"\nx + 1") # 再代入後 x は String → ["expected String but got 1"]

『しくみ』はここで「同一ブロックでの再定義をエラーにするか」を論点にしましたが、学習の本筋ではないとしてシャドーイング処理を省きました。私たちも同じです。再代入は素直に型の差し替えとして扱います。


足したものは、Scope(不変の型環境)とeval_statement(文を縫う)です。check/annotateはスコープを引き回すようになりました。

動かすとこうなります。

Chibirigor.annotate("x = 1\nx\n").each { |a| puts "#{a[:line]}: #{a[:type]}" }
puts Chibirigor.check("x = \"a\"\nx + 1").map { |d| d[:message] }.first
1: 1
2: 1
expected String but got 1

x = 1の型1が次の行のxにそのまま運ばれ(1: 12: 1)、"a"を再代入したあとのx + 1String#+1(Integer)を渡すので型エラーになります。

この章の三つの視点:

内容
① 型理論(『しくみ』3、4章 / 『TAPL』9、11章)変数の型を覚える型環境tyenv、文を縫う逐次実行
② Ruby/RBS再代入で型が変わる、裸の名前は代入が無いとメソッド呼び出し
③ Rigor実装の問題不変Scopeで「どこで何が見えるか」を明確化、再代入は型差し替え

続編に送ったもの

  • ブロックが外側のローカルを捕獲したときの事実無効化(実RigorのFactStoreの機微)
  • 複合代入x += 1LocalVariableOperatorWriteNode)、多重代入
  • インスタンス変数、定数、グローバル変数などローカル以外の束縛
  1. x = 1\ny = x\ny + 2が通ることを確かめ、型がどう運ばれたかを追え。
  2. 再代入x = 1\nx = "a"の前後でxの型がどう変わるかをannotateで観察せよ。
  3. 複合代入x += 1(PrismではLocalVariableOperatorWriteNode)は今のコードでどう扱われるか。 対応させるにはeval_statementに何を足せばよいか考えよ。

次章予告(Part 4とPart 5)ifで型が枝分かれする場合の型(Union)をPart 4で、x.nil?のような条件で型を絞る「ナローイング」をPart 5で作ります。Scopeがここで本領を発揮します。この「再代入で束縛を差し替える」不変スコープの発想は、後の章の絞り込みでも同じように効いてきます。


この章の実装(演習の答え合わせにも)impls/dist/part3/lib

© 2026 TypedDuck. Licensed under CC BY-SA 4.0.