TypeProf: 抽象解釈に基づくRubyコードの型解析ツール
とりあえずデモ
demo.md を参照。
TypeProfの使い方
app.rb を解析する。
$ typeprof app.rb
一部のメソッドの型を指定した sig/app.rbs とともに app.rb を解析する。
$ typeprof sig/app.rbs app.rb
典型的な使用法は次の通り。
$ typeprof sig/app.rbs app.rb -o sig/app.gen.rbs
オプションは次の通り。
-o OUTFILE: 標準出力ではなく、指定ファイル名に出力する-q: 解析の進捗を表示しない-v:-fshow-errorsの別名-d: 解析の詳細ログを表示する(現状ではデバッグ用出力に近い)-I DIR:requireのファイル探索ディレクトリを追加する-r GEMNAME:GEMNAMEに対応するRBSをロードする--exclude-dir DIR:DIR以下のファイルの解析結果を出力から省略する。後に指定されているほうが優先される(--include-dir foo --exclude-dir foo/barの場合う、foo/bar/baz.rbの結果は出力されず、foo/baz.rbの結果は出力される)。--include-dir DIR:DIR以下のファイルの解析結果を出力に含める。後に指定されているほうが優先される(--exclude-dir foo --include-dir foo/barの場合、 foo/bar/baz.rbの結果は出力されるが、foo/baz.rbの結果は出力されない)。--show-errors: 実行中に見つけたバグの可能性を出力します(多くの場合、大量のfalse positiveが出ます)。--show-untyped: デフォルトではA | untypedと推定されたところを単にAと出力しますが、より生の出力、つまりA | untypedと出力します。--type-depth-limit=NUM: (後で書く)
TypeProfとは
TypeProfは、Rubyプログラムを型レベルで抽象的に実行するインタプリタです。 解析対象のプログラムを実行し、メソッドが受け取ったり返したりする型、インスタンス変数に代入される型を集めて出力します。 すべての値はオブジェクトそのものではなく、原則としてオブジェクトの所属するクラスに抽象化されます(次節で詳説)。
メソッドを呼び出す例を用いて説明します。
def foo(n)
p n #=> Integer
n.to_s
end
p foo(42) #=> String
TypeProfの解析結果は次の通り。
$ typeprof test.rb
# Revealed types
# test.rb:2 #=> Integer
# test.rb:6 #=> String
# Classes
class Object
def foo : (Integer) -> String
end
foo(42)というメソッド呼び出しが実行されると、Integerオブジェクトの42ではなく、「Integer」という型(抽象値)が渡されます。
メソッドfooはn.to_sが実行します。
すると、組み込みメソッドのInteger#to_sが呼び出され、「String」という型が得られるので、メソッドfooはそれを返します。
これらの実行結果の観察を集めて、TypeProfは「メソッドfooはIntegerを受け取り、Stringを返す」という情報をRBSの形式で出力します。
また、pの引数はRevealed typesとして出力されます。
インスタンス変数は、通常のRubyではオブジェクトごとに記憶される変数ですが、TypeProfではクラス単位に集約されます。
class Foo
def initialize
@a = 42
end
attr_accessor :a
end
{Foo.new}.a = "str"
p {Foo.new}.a #=> Integer | String
$ typeprof test.rb
# Revealed types
# test.rb:11 #=> Integer | String
# Classes
class Foo
attr_accessor a : Integer | String
def initialize : -> Integer
end
TypeProfの扱う抽象値
前述の通り、TypeProfはRubyの値を型のようなレベルに抽象化して扱います。 ただし、クラスオブジェクトなど、一部の値は抽象化しません。 紛らわしいので、TypeProfが使う抽象化された値のことを「抽象値」と呼びます。
TypeProfが扱う抽象値は次のとおりです。
- クラスのインスタンス
- クラスオブジェクト
- シンボル
untyped- 抽象値のユニオン
- コンテナクラスのインスタンス
- Procオブジェクト
クラスのインスタンスはもっとも普通の値です。
Foo.newというRubyコードが返す抽象値は、クラスFooのインスタンスで、少し紛らわしいですがこれはRBS出力の中でFooと表現されます。
42という整数リテラルはIntegerのインスタンス、"str"という文字列リテラルはStringのインスタンスになります。
クラスオブジェクトは、クラスそのものを表す値で、たとえば定数IntegerやStringに入っているオブジェクトです。
このオブジェクトは厳密にはクラスClassのインスタンスですが、Classに抽象化はされません。
抽象化してしまうと、定数の参照やクラスメソッドが使えなくなるためです。
シンボルは、:fooのようなSymbolリテラルが返す値です。
シンボルは、キーワード引数、JSONデータのキー、Module#attr_readerの引数など、具体的な値が必要になることが多いので、抽象化されません。
ただし、String#to_symで生成されるSymbolや、式展開を含むSymbolリテラル(:"foo_#{ x }"など)はクラスSymbolのインスタンスとして扱われます。
untypedは、解析の限界や制限などによって追跡ができない場合に生成される抽象値です。
untypedに対する演算やメソッド呼び出しは無視され、評価結果はuntypedとなります。
抽象値のユニオンは、抽象値に複数の可能性があることを表現する値です。
人工的ですが、rand < 0.5 ? 42 : "str"の結果はInteger | Stringという抽象値になります。
コンテナクラスのインスタンスは、ArrayやHashのように他の抽象値を要素とするオブジェクトです。 いまのところ、ArrayとEnumeratorとHashのみ対応しています。 詳細は後述します。
Procオブジェクトは、ラムダ式(-> { ... })やブロック仮引数(&blk)で作られるクロージャです。
これらは抽象化されず、コード片と結びついた具体的な値として扱われます。
これらに渡された引数や返された値によってRBS出力されます。
TypeProfの実行
TypeProfは型レベルのRubyインタプリタと言いましたが、その実行順はRubyのものとはまったく異なります。
分岐
分岐は原則として、両方が並行に実行されます。どちらが先に評価されるかは決まっていません。
次の例では、then節では変数xにIntegerを代入し、else節ではxにStringを代入しています。
if rand < 0.5
x = 42
else
x = "str"
end
p x #=> Integer | String
まず条件式が評価され、then節とelse節の両方が実行され(どちらが先かはわかりません)、分岐後は最終的にxにInteger | Stringが入った状態でメソッドpを呼び出します。
リスタート
インスタンス変数に複数の異なる抽象値を代入する場合、さらにややこしい実行順になります。
class Foo
def initialize
@x = 1
end
def get_x
@x
end
def update_x
@x = "str"
end
end
foo = Foo.new
# ...
p foo.get_x #=> Integer | String
# ...
foo.update_x
上記の例では、Foo#initializeの中でインスタンス変数@xにIntegerを代入しています。
Foo#get_xは@xを読み出し、一旦Integerを返します。
しかし、別の箇所でFoo#update_xを呼ぶと、インスタンス変数@xの値がInteger | Stringに拡張されます。
よって@xの読み出しはIntegerではなくInteger | Stringを返す必要があったものとして、遡及して実行し直します。
したがって、Foo#get_xの呼び出しの返り値は最終的にInteger | Stringとなります。
メソッド呼び出し
TypeProfは、実行中にコールスタックを管理しません。 よって、メソッドの実行中、「現在の呼び出し元」という概念を持ちません。 メソッドがリターンするときは、そのメソッドを呼び出しているすべての箇所に返り値の抽象値を返します。
def fib(n)
if n < 2
return n
else
fib(n - 1) + fib(n - 2)
end
end
p fib(10) #=> Integer
上記の例では、メソッドfibは(再帰呼び出しを含めて)3箇所から呼び出されています。
return nが実行されると、これらの3箇所すべてからIntegerが帰ってきたものとして解析が実行されます。
なお、Rubyでは、メソッドの呼び出しの箇所を静的に特定することはできません(レシーバの型に依存するため)。
よって、return nを実行する時点より後でfibへの呼び出しを発見した場合、直ちにIntegerが返されたものとして実行します。
もしメソッドが異なる抽象値を返す場合、実行の遡及が起きる場合があります。
スタブ実行
実行できる箇所をすべて実行したあとでも、どこからも呼ばれなかったメソッドやブロックが残る場合があります。
これらのメソッドやブロックは、untypedを引数として無理やり呼び出されます。
def foo(n)
end
def (n)
foo(1)
end
上記のプログラムだと、メソッドfooとbarがどちらも呼び出されませんが、barをスタブ実行することで、fooにIntegerが渡されるという情報を取り出すことができます。
ただし、この機能は解析が遅くなったり、逆にノイズとなる場合もあるので、設定で有効化・無効化できるようにする予定です。
TypeProfの制限など
値を抽象化するために、一部のRubyの言語機能は扱うことができません。
特異メソッドのように、オブジェクトのアイデンティティが重要な言語機能については基本的に無視されます。 ただし、クラスオブジェクトは抽象化されないため、クラスメソッドの定義は正しく処理されます。 Rubyにあるクラスのクラスのような概念はなく、現状ではインスタンスメソッドとクラスメソッドの2段階のみを扱います。
メタプログラミングは、一部のみ対応しています。
Module#attr_readerやObject#sendは、引数として渡されるSymbolの中身が追跡できている場合(たとえばリテラルで書かれている場合)のみ、正しく扱います。
Kernel#instance_evalは、ブロックが渡された場合にレシーバオブジェクトを置き換える機能のみ対応しています(文字列の中身は追跡されない)。
Class.newは対応されません(untypedを返します)。
Kernel#requireは引数の文字列がリテラルの場合のみ特別に対応しています。
TypeProfの機能
RBSの手書き指定
TypeProfは理論上の制限や実装上の制限により、プログラマの意図を正しく推定できない場合があります。 そのような場合、部分的にRBSを手書きし、TypeProfに意図を伝えることができます。
たとえばTypeProfはオーバーロードを推定できないので、次のようなコードを解析すると少し雑な推定となります。
# プログラマの意図:(Integer) -> Integer | (String) -> String
# TypeProfの推定: (Integer | String) -> (Integer | String)
def foo(n)
if n.is_a?(Integer)
42
else
"str"
end
end
# オーバーロードの意図が尊重されない
p foo(42) #=> Integer | String
p foo("str") #=> Integer | String
foo(42)の結果はIntegerになることを期待していますが、少し広いInteger | Stringとなっています。
このようなとき、RBSを手書きしてメソッドfooの意図を指定すれば、意図通りの推定結果となります。
# test.rbs
class Object
def foo: (Integer) -> Integer | (String) -> String
end
# test.rb
def foo(n)
# 中身に関係なく、test.rbsの記述が優先される
end
# オーバーロードの意図が尊重されるようになる
p foo(42) #=> Integer
p foo("str") #=> String
組み込みクラス・メソッドの多くもRBSによって指定されています。 GemfileがあればそこからライブラリのRBSをまとめてロードする機能は実装予定です(未実装)。
なお、RBSのインターフェイスは未サポートで、untypedとして扱われます。
デバッグ機能
TypeProfの挙動・解析結果を理解するのはなかなか難しい問題です。
現状では、コード中でKernel#pを呼び出すことで、引数の抽象値を観察することができます。
それ以上に解析の挙動を深く理解するには、現状では環境変数TP_DEBUG=1を指定してデバッグ出力を観察するしかありません。
解析結果の説明性は課題と認識していて、今後拡充していく予定です。
flow-sensitive analysis
ユニオンの抽象値の中身を見る分岐では、可能な範囲でユニオンの分離を行います。
たとえばローカル変数varの抽象値がFoo | Bar、分岐の条件式にvar.is_a?(Foo)などと書かれている場合、then節で変数varはFooに、else節ではBarに分離されます。
ただし、レシーバがそのスコープで定義されたローカル変数である場合に限ります(@var.is_a?(Foo)や、ブロックの外側の変数xに対するx.is_a?(Foo)ではユニオンは分離されない)。また、現時点ではis_a?、respond_to?、case/whenで次のように書かれているパターンのみに限定されています。
def foo(x)
if x.is_a?(Integer)
p x #=> Integer
else
p x #=> String
end
end
foo(42)
foo("str")
def foo(x)
if x.respond_to?(:times)
p x #=> Integer
else
p x #=> String
end
end
foo(42)
foo("str")
def foo(x)
case x
when Integer
p x #=> Integer
when String
p x #=> String
end
end
foo(42)
foo("str")
コンテナ型
いまのところ、Array型のコンテナ(ArrayとEnumerator)とHash型のコンテナ(Hash)のみ対応しています。
メソッド内ではオブジェクトのアイデンティティを保持していて(オブジェクトの生成場所で識別される)、それなりに更新などできます。 これにより、次のように配列を初期化するコードは動作します。
def foo
a = []
100.times {|n| a << n.to_s }
p a #=> Array[String]
end
foo
ただし、解析のパフォーマンスの都合で、メソッドをまたがった更新の追跡は行いません。
def (a)
a << "str"
end
def foo
a = []
(a)
p a #=> []
end
foo
インスタンス変数に入っている配列に対する更新は特別に追跡するようになっています。
現在の制限として、ハッシュのキーにコンテナ型を入れる場合はuntypedに置き換えられます。
また、入れ子の配列やハッシュは、深さ5までに制限されています。
これらは解析パフォーマンスの都合であり、設定可能にしたり、手動でRBS指定した場合は深さ制限を超えられるようにするなどの改良をする予定です。
その他
後で書く
- Proc
- Struct