コンテンツにスキップ

ナローイング

キャリア(carrier)はあるプログラム地点における値を記述します。ナローイング(narrowing)は、制御フローが述語を通過するときにキャリアがどう変化するかを記述します。この章では、Rigorが現在認識しているナローイングのあらゆる形式を解説します。

メンタルモデル: 各述語は2つのスコープを生成します — 真値のエッジと偽値のエッジです。各エッジの内側では、変数のキャリアが述語が証明したものに鋭利化されます。述語が認識されなければ、両エッジはエントリースコープをそのまま共有します。

この章の内容 真偽性 · nil? · is_a? / kind_of? / instance_of? · リテラル等値 · case / when · 論理演算 · 整数比較 · 述語メソッド · 名前付きキャプチャ正規表現 · 否定とunless · ローカル再バインド · まだナローイングされないもの · ナローイングトレースを読む

最も単純な形式です。if xは「xが真値」と「xがfalseまたはnil」を分離します:

def shout(name)
if name
# name: String — 真値エッジで `false | nil` が除去される
name.upcase
else
# name: Constant<false> | Constant<nil>
"(no name)"
end
end

これこそ、ありふれたif valueイディオムが解析時に有用になる理由です: if本体の内側で、Rigorはvalueが非nilであることを知ります。

def length(s)
return 0 if s.nil?
# s: Nominal[String] (String?のnil成分が消えた)
s.length
end

s.nil?は真値エッジをConstant<nil>にナローイングし、偽値エッジを「それ以外すべて」— 通常はnilが除去された元の型 — にナローイングします。

これら3つはすべてクラス階層に基づいてナローイングします:

def kind(x)
if x.is_a?(Integer)
# x: Integer
x + 1
elsif x.is_a?(String)
# x: String
x.length
end
end

サブクラスの関係が考慮されます: is_a?(Numeric)IntegerFloatを受け入れ、それに応じてナローイングします。instance_of?はより厳密で — 完全に一致するクラスのみ — Rigorもそれに応じてナローイングします。

偽値エッジでは一致したクラスが除外されます:

x = some_call_that_returns_integer_or_string
unless x.is_a?(Integer)
# x: String — Integerが除外される
x.upcase
end

Rigorは信頼できるリテラル値に対して==!=をナローイングします:

state = some_call_returning_a_symbol
if state == :ready
# state: Constant<:ready>
send_request
elsif state == :pending
# state: Constant<:pending>
retry_in(5)
end

これはstate自体が定数のユニオン(union、合併型とも)(Constant<:ready> | Constant<:pending> | Constant<:failed>)のときに最も有用です。各ブランチが1つのメンバーを剥がし、Rigorは最後のelseが残りの定数のいずれかであると証明できます — 「任意のSymbol」ではありません。

case x; when …は、等値チェックとクラスチェックに対するナローイング構文糖です。各whenブランチではxが一致したメンバーにナローイングされます:

case n
when 0 then :zero # Constant<0>
when 1..9 then :small # int<1, 9>
when 10 then :ten # Constant<10>
else :large # それ以外すべて
end

結果型はブランチごとの結果のユニオンです。入力が有限リテラルユニオンのとき、すべてのメンバーが一致する場合、Rigorはelseブランチが到達不能であることを証明します。

case x; in pattern(1行パターンマッチング)も、Rigorが理解するパターン — クラスチェック、リテラル等値、配列/ハッシュ構造パターン — に対して同じようにナローイングします。

def safe_size(s)
if s && !s.empty?
# s: non-empty-string
s.size
end
end

&&は左から右へのナローイングを連鎖します: 右オペランドは左の真値エッジ下で評価されます。||は偽値エッジを連鎖します。!は2つのエッジを入れ替えます。

これは他のすべてと組み合わせられます:

if x.is_a?(Integer) && x > 0
# x: positive-int
end

is_a?xIntegerにナローイングし、次に整数比較がさらにint<1, max>にナローイングしました。

<<=>>=、およびInteger#zero? / #positive? / #negative? / #nonzero? / Comparable#between?はすべて整数範囲をナローイングします:

def safe_index(arr, n)
return :empty if arr.empty?
return :out_of_range if n < 0 || n >= arr.size
# n: int<0, arr.size - 1> (実際には: int<0, max>
# が `n >= arr.size` に対して締め付けられる)
arr.fetch(n)
end

範囲比較はリテラルと組み合わせられます:

n = some_input
if n.between?(1, 9)
# n: int<1, 9>
end

リファインメントに対する述語メソッド

Section titled “リファインメントに対する述語メソッド”

Rigorは少数の「型キャリア述語メソッド」を認識します — 戻り値型がboolで、真値/偽値エッジがレシーバーをナローイングするメソッドです:

メソッドレシーバーをナローイングする先
String#empty?Constant<"">(真値) / non-empty-string(偽値)
Array#empty?Constant<[]>(真値) / non-empty-array[T](偽値)
Hash#empty?Constant<{}>(真値) / non-empty-hash[K,V](偽値)
Integer#zero?Constant<0>(真値) / non-zero-int(偽値)
Integer#positive?positive-int(真値) / non-positive-int(偽値)
Integer#negative?negative-int(真値) / non-negative-int(偽値)

期待通りに組み合わせられます:

def first_word(s)
return "" if s.empty?
# s: non-empty-string
s.split.first # ランタイムでは常にStringを返す、
# nilではない — Rigorもそれを知っている
end

名前付きキャプチャ正規表現ナローイング

Section titled “名前付きキャプチャ正規表現ナローイング”

名前付きキャプチャを持つ正規表現がif / unlessの述語位置でマッチすると、キャプチャされたローカル変数はマッチ後にString | nilにバインドされ、真値ブランチではStringにナローイングされます:

def parse_date(s)
if /(?<year>\d{4})-(?<month>\d{2})-(?<day>\d{2})/ =~ s
# year, month, day: String (String | nilからナローイングされた)
"#{year}/#{month}/#{day}"
else
"no match"
end
end

(真値エッジをさらに特定のリファインメントキャリアにナローイングすること — \d{4}ならdecimal-int-stringを生成する — は、正規表現パターン → リファインメント名の認識器のトラックにおける需要駆動のフォローアップです。docs/ROADMAP.mdを参照。)

どちらも否定されていない形式の機械的な鏡です。ナローイング目的ではunless xif !xと同じです。x != y!(x == y)と同じです。Rigorは2つのエッジを入れ替えます。

ローカル変数の再バインドはナローイングをリセットする

Section titled “ローカル変数の再バインドはナローイングをリセットする”

ナローイングの事実はスコープローカルです。変数を再代入した瞬間に、その事実はリセットされます:

def example(s)
return if s.nil?
# s: String
s = some_other_call # sが再バインド — ナローイングが除去される
s.upcase # s: 呼び出しの戻り値型によっては
# 再びString?になる
end

これがエンジンのナローイング事実が特定の変数名ではなく特定のスコープに紐付けられている理由です。再バインドは検出されますが、メソッド呼び出しを通じた変異は検出されません(Rigorは変異を追いません)。

まだナローイングされないもの

Section titled “まだナローイングされないもの”

Rigorが今日ナローイングしない形式でよく期待されるもの:

  • respond_to?(:method_name) — 「このオブジェクトはそのメソッドに応答する」を証明するには、エンジンがまだ公開していない構造的ファセットが必要です。
  • frozen?などの変異ガード — Rigorはまだミュータビリティをナローイング事実として追跡しません。
  • 任意のユーザー定義case_eqに対する===によるオープンエンドのクラス比較 — Class / Module / Range / Regexpのみが認識されます。
  • selfターゲットディレクティブ内のメソッドチェーンレシーバー(get_user.admin?)— ナローイングするスコープバインディングがありません。ローカル変数、インスタンス変数、明示的self、暗黙的selfのレシーバーはすべてサポートされています。

ナローイングが認識されない場合、両エッジはエントリースコープをそのまま共有します — Rigorは間違った判断をするよりも保守的に留まります。

特定の地点でRigorが何をナローイングしたかを確認したいとき:

def foo(x)
if x.is_a?(Integer)
dump_type(x) # この行にinfo診断を出力する
end
end

dump_type(...)はイントロスペクションヘルパーです。ランタイムではno-op(Rigorのテストハーネスが使うKernel拡張に存在)で、推論された型を名前付きのdump.type診断として出力します。ナローイングが発火したことを確認するデバッグ時に使います。

assert_type("expected-string", value)はより厳密な兄弟です: 推論された型が文字列に一致しないとき診断を出力します。ハンドブックの例が動作を固定するために使っています。

第4章は構造的キャリア — TupleHashShape — を扱います。これらはArrayHashの要素ごとのナローイングによく似ています。

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