RubyKaigi 2019 "A Type-level Ruby Interpreter for Testing and Understanding" の発表要旨

こんにちは、クックパッドで仕事で Ruby の開発をしている遠藤(@mametter)です。もうすぐ RubyKaigi ですね! クックパッドはいろんな形で RubyKaigi に参加していく予定なのでよろしくお願いします。詳しくは昨日の記事をごらんください。

さて、そういうわけで RubyKaigi です。遠藤は "A Type-level Ruby Interpreter for Testing and Understanding" という発表を予定しています。遠藤の発表予定の内容をあらかじめざっと紹介してみます。

この記事は発表資料を作り終えてから書いているのですが、発表資料よりも要点がまとまっている気がします。

はじめに: Ruby 3の静的解析

2020 年にリリースが予定されている Ruby 3 は、「静的解析」「高速化」「並列性」の 3 つを備えることを目標に掲げています。この発表は 1 つめの「静的解析」に関わるものです。

Ruby 3 に向けた型システムとして、SteepSorbet が提案されていますが、いずれもメソッドの型はユーザが指定する前提になっています。

本発表では、「型は絶対に書きたくないでござる」の人たちのために、型注釈がない Ruby プログラムに適用可能な静的型解析器、「型プロファイラ」を提案します。

型プロファイラとは

型プロファイラは、

  • 型注釈がない素の Ruby プログラムを入力して、
  • 型エラーの可能性を警告したり(Testing)、
  • 型シグネチャのプロトタイプを生成したり(Understanding)

できるツールです。

Testing の例

型エラーを警告する例を示します。

def foo(n)
  if n < 10
    n.timees {|x| # TYPO!
    }
  end
end

foo(42)

このプログラムは Integer#times を typo して timees と書いています。このプログラムに型プロファイラを適用すると、次のような出力が得られます。

$ ./run.sh /tmp/test.rb
/tmp/test.rb:3: [error] undefined method: Integer#timees
Object#foo :: (Integer) -> (NilClass | any)

Integer#timees は undefined である、というエラーが出ています。なお、元のプログラムを普通に実行するだけではこのバグを検知できないことに注意してください(n < 10 なので)。

Understanding の例

次は型シグネチャのプロトタイプを得る例です。

def foo(n)
  n.to_s
end

foo(42)
foo("STR")
foo(:sym)

このプログラムに型プロファイラを適用すると、次のような出力が得られます。

$ ./run.sh /tmp/test.rb
Object#foo :: (Integer) -> String
Object#foo :: (String) -> String
Object#foo :: (Symbol) -> String

foo はオーバーロードされていて、

  • Integer を受け取ったら String を返す
  • String を受け取っても String を返す
  • Symbol を受け取っても String を返す

ということを表現する型シグネチャのプロトタイプとして使えます。

これが型プロファイラの基本です。

もっとデモがみたい!

このへんにいろいろ転がってます。詳しくは発表で。

https://github.com/mame/ruby-type-profiler/tree/master/smoke

型プロファイラをどのように使うか?

おおまかに 2 つの使い方を想定しています。

  1. 開発中にテストと合わせて実行し、型エラーの可能性を調べてみる
  2. Ruby プログラムから型シグネチャをプロトタイプし、手修正の上で型検査器(Steep や Sorbet)を使ってきちんと検証する

前者の使い方は、従来の Ruby のプログラミング体験にあまり影響を与えず、静的解析を補助的なテストとして利用する方法です。 推定される型シグネチャは特に利用しないか、参考程度にします。

後者の使い方は、型シグネチャの生成支援です。 型シグネチャをあとからまとめて書きたい場合、特に既存の Ruby プログラムに対して型検査器を適用する際に役立つと思います。 また、よくわからない Ruby プロジェクトをいじらないと行けないとき、プログラムの中にどのようなクラス・メソッド定義があるかを俯瞰するためにも有用かもしれません。

型プロファイラのメリット・デメリットは?

メリットはただ 1 点に集約されます。

  • 型注釈がなくても型検査・型推論っぽいことができる

デメリットはいろいろあります。

  • 誤った警告(false positive)を出すことがある
  • 各メソッドを起動する型レベルのテストが必要、足りないとバグの見逃しにつながる
  • 原理的に扱えない Ruby の言語機能がある(たとえば Object#send や特異クラス)
  • スケーラビリティに問題がある

長くなるのでこの記事では説明を省きますが、発表ではこれらの問題の分析や、それらに対して何ができると考えているかについて駆け足で語ります。

型プロファイラはどのように動いているか?

今回のメインコンテンツです。型レベルで Ruby プログラムを解釈実行するインタプリタがコアになっています。

def foo(n)
  n.to_s
end

foo(42)

というコードがあったとき、普通のインタプリタであれば

  1. 関数 foo に整数 42 を渡して呼び出す
  2. 関数 foo の中を n = 42 の環境で評価する
  3. n.to_s を実行した結果の文字列 "42" を関数 foo がリターンする
  4. foo(42) の呼び出しが "42" を返して実行再開する

というように実行が進んで行きます。型プロファイラはこれを型レベルで行います。つまり、

  1. 関数 foo に整数 Integer を渡して呼び出す
  2. 関数 foo の中を n :: Integer の環境で評価する
  3. n.to_s を実行した結果の String を関数 foo がリターンする
  4. foo(42) の呼び出しが String を返して実行再開する

このような型レベルでの実行を記録し、各関数に渡された引数(Integer)や返した返り値(String)を集めて、型シグネチャのような形式にして出力します。

分岐があったらどうするか?

関数に型レベルの情報しか渡さないので、分岐の条件を正確に評価できなくなります。たとえば次の例。

def foo(n)
  if n < 10
    n
  else
    "string"
  end
end

foo(42)

n < 10 という条件式がありますが、n には 42 という具体的な値ではなく Integer という型レベルの情報しか入っていないので、分岐を正確に実行することはできません。

型プロファイラは、分岐があったら状態をフォークします。つまり、true の可能性と false の可能性を両方とも実行します。上の例で言うと、true のケースは n (Integer) をリターンする、false のケースは "string" (String) をリターンする、ということで、これらを組み合わせて

$ ./run.sh /tmp/test.rb
Object#foo :: (Integer) -> (String | Integer)

というシグネチャを生成して出力します。

このフォークのせいで、うまくないコードを書くと状態爆発につながってしまいます。通常のコードで状態爆発が起きにくいように抽象化の粒度や状態管理をうまくやるのが、型プロファイラの設計のむずかしいところです。

この手法は何なのか?

普通の型システムとはいろいろ異なると思います。普通の型システムは、メソッドなどのモジュール単位で検査できるよう、メソッド間をまたがない(intra-procedural な)解析になるように設計されます。この点、型プロファイラはメソッド呼び出しがあったときにメソッド本体を呼び出すので、メソッドをまたがる(inter-procedural な)解析になっています。

型プロファイラの手法を指すぴったりの技術名は調べてもわかりませんでしたが、どちらかというと、抽象解釈や記号実行といった技術に近いようです。

なお、inter-procedural な解析は、先に問題として述べたとおり、スケーラビリティとの戦いになりやすく、型プロファイラも例外ではありません。発表ではどのように対策してきたか、対策していきたいと考えているかを議論します。

型プロファイラの完成度は?

発表で詳しくいいますが、端的に言えば、残念ながらまだまだ完成度が低いです。ソースコードは mame/ruby-type-profiler に公開してありますが、正直に言って、まだまだみなさんのコードに適用を試せる段階にはないです。スケーラビリティのための根本的な対策検討から、地道な組み込み機能のサポートまで、やることがたくさんあって手が回っていません。型を書かない Ruby 体験を維持したいと思っている方は協力をご検討いただけると嬉しいです。

まとめ

本発表では、型注釈がない Ruby プログラムに適用可能な静的型解析器、「型プロファイラ」を提案します。 抽象解釈の考え方に基づいていて、現在のところ型なし Ruby 体験を維持できそうな静的解析アプローチの唯一の提案になっています。

発表では、ここまでに実装できている機能のデモや、現状の問題点の解説、preliminary ながら評価実験などをいろいろご紹介したいと思ってます。 ぜひ聞いていただいて、前向きに興味を持ってくれた方とも批判的な立場の方ともいろいろ議論できることを楽しみにしています。