イントロダクション
「テストを走らせて型情報を収集すればいいんじゃない?」そのアイデア自体は話題に上がることが多かったかと思われますが、観測範囲では前例がないように見えます。そこで、実際に作ってこそ見える世界があると思い動くものを実装してみました。
Orthoses::Trace
orthosesはRBSを生成するための機能を作るフレームワークで、この機能の一つとしてOrthoses::Trace
というミドルウェアを実装しました。
例
例題として、rack-testというgemのRBSを生成したいとします。
その場合の生成コードをOrthoses::Trace
を使って以下のように準備します。
require 'pathname' require 'orthoses' require 'fileutils' include FileUtils out = Pathname('out') out.rmtree rescue nil Orthoses.logger.level = :warn Orthoses::Builder.new do use Orthoses::CreateFileByName, base_dir: out.to_s use Orthoses::Filter do |name, content| name.start_with?("Rack::Test") end use Orthoses::Trace, patterns: ['Rack::Test*'] run ->(){ cd "src" do load "spec/all.rb" Minitest.run # avoid to run on at_exit module ::Minitest def self.run args = [] 0 end end end } end.call
このコードを実行すると、rack-testのリポジトリ内のテストコードを走らせ、RBSを生成することができます。
Orthoses::CreateFileByName
ミドルウェアの働きによってclass毎にファイルができます。
その一部を見てみましょう。Rack::Test::UploadedFile
の場合は以下のような出力を得ることができます。
class Rack::Test::UploadedFile attr_reader tempfile: Tempfile? attr_reader original_filename: String attr_accessor content_type: String def self.finalize: (Tempfile file) -> Proc private def initialize_from_file_path: (String path) -> nil private def initialize: (String content, ?String content_type, ?bool binary, ?original_filename: nil) -> void | (StringIO content, ?String content_type, ?bool binary, ?original_filename: String) -> void | (String content, ?String content_type, ?bool binary, ?original_filename: String) -> void | (StringIO content, ?String content_type, ?bool binary, ?original_filename: nil) -> void private def respond_to_missing?: (Symbol method_name, ?bool include_private) -> bool def method_missing: (Symbol method_name, *Array[untyped] args) ?{ (*untyped) -> untyped } -> (Integer | String) | (Symbol method_name, *Array[Encoding] args) ?{ (*untyped) -> untyped } -> File def append_to: (String buffer) -> nil def self.actually_finalize: (Tempfile file) -> bool private def initialize_from_stringio: (StringIO stringio) -> StringIO def path: () -> String end
テストコードを走らせて、わりとそれっぽいRBSを得ることができました。
実装方法
TracePointを使いました。基本的にはTracePointのcall
イベントで引数の型を、return
イベントで返り値の型を、それぞれテスト実行時に集めておくという発想です。
コード例で説明します。
def foo(a) a.id end
みたいなコードがあったときに、このままではa.id
が何を返すのかわかりません。
そこで、実際にコードを実行し、foo
が呼ばれたときのa
の値を見て引数の型を、返り値を見て返り値の型をfoo
の型として蓄積しておきます。
結果として
- 引数aは
Record
型, 返り値はInteger
だった。 - 引数aは
Record
型, 返り値はnil
だった。
という情報を集めることができます。
これらの情報を統合して
def foo: (Record) -> Integer?
というRBSに落とし込む、というのが基本的な発想です。 コードの実行が必要ではありますが、コードを実行する最高のサンプルとしてテストコードを使用すれば、既存の資産を流用しつつ0からよりも遥かに効率的にRBSを記述することが期待できます。
既存の手法
静的な型ファイルを自動生成する既存の手法としては以下の前例があります。 これらの前例と今回の手法を比較し、どのようなメリットがあるのかを考えます。
rbs prototype
その名の通り、RBSのプロトタイプを作成するためのコマンドです。 静的解析と動的解析の両方があり、コマンドひとつで生成できるので、RBSのプロトタイプ生成として最も有名な方法だと思われます。
typeprof
静的解析によってできるだけ型情報を収集する手法です。かなり精度も高く「そんなことまで分かるのか!」と驚きます。
rbs-dynamic
RubyKaigi2022で発表されたツールで、コードを実行して型情報を得るものです。 今回実施した手法にかなり近いのですが、今回のように既存のテストコードをまるごと回す用途ではエラーが発生し動かせなかったので今回は使用していません。
比較
rbs prototype rb
ソースコードに対して実行します。簡略化のためコメントは除きます。
$ rbs --version rbs 3.0.4 $ rbs prototype rb src/lib/rack/test/uploaded_file.rb | grep -v '#' module Rack module Test class UploadedFile attr_reader original_filename: untyped attr_reader tempfile: untyped attr_accessor content_type: untyped def initialize: (untyped content, ?::String content_type, ?bool binary, ?original_filename: untyped?) -> void def path: () -> untyped alias local_path path def method_missing: (untyped method_name, *untyped args) { () -> untyped } -> untyped def append_to: (untyped buffer) -> nil def respond_to_missing?: (untyped method_name, ?bool include_private) -> untyped def self.finalize: (untyped file) -> untyped def self.actually_finalize: (untyped file) -> untyped private def initialize_from_stringio: (untyped stringio) -> untyped def initialize_from_file_path: (untyped path) -> untyped end end end
rbs prototype runtime
$ rbs prototype runtime -r rack/test/uploaded_file 'Rack::Test::UploadedFile'
rbs prototype rbとほぼ変わらない結果となったので割愛
typeprof
$ typeprof --version typeprof 0.21.7 $ typeprof src/lib/rack/test.rb src/spec/rack/test/uploaded_file_spec.rb # ...snip... class UploadedFile attr_reader original_filename: String? attr_reader tempfile: String | StringIO attr_accessor content_type: String def initialize: (String | StringIO content, ?String content_type, ?bool binary, ?original_filename: String?) -> void def path: -> untyped alias local_path path def method_missing: (:must_respond_to | :pos | :read method_name, *Symbol args) -> untyped def append_to: (untyped buffer) -> nil def respond_to_missing?: (untyped method_name, ?false include_private) -> true def self.finalize: (String | StringIO file) -> ^-> untyped def self.actually_finalize: (untyped file) -> untyped private def initialize_from_stringio: (String | StringIO stringio) -> (String | StringIO) def initialize_from_file_path: (String | StringIO path) -> void end # ...snip...
比較考察
出力は一部しか示していませんが、全体的な比較として以下のようになりました。
Tool | メリット | デメリット | 備考 |
---|---|---|---|
rbs prototype | 実行が非常に手軽だった | ほとんどがuntypedだった | なし |
typeprof | 実行が手軽、かつある程度型がある。インスタンス変数も対応している。 | untypedはある | rbs collectionで型情報を追加できる可能性がある |
Orthoses::Trace | 型情報が多い | 実行のためにコードを書く必要がある | rbs collectionで型情報を追加できる可能性がある |
手軽さと正確性にある程度のトレード・オフの関係があるように見えます。
もう少し細かく比較してみましょう。
実行の手軽さ
圧倒的にrbs prototypeやtypeprofが手軽です。Orthoses::Trace
ではちょっとしたスクリプトレベルのコードを書く必要があり手軽とは言えません。
型の正確性
方法によって考慮されている型情報に差はありますが、どの手法でも最終的な型情報はプログラマーの理想のものになるはずです。よっていずれの手法でもある程度の手直しは必要そうです。
対応範囲
typeprofは定数やインスタンス変数も対応しているのに対して、Orthoses::Trace
ではTracePoint軸なのでフックが設定でず対応できません。typeprofすごい。
また、Orthoses::Trace
では、呼ばれていないメソッドは情報がないので型生成できません。typeprofは呼ばれてなくても類推できます。typeprofすごい。
テストツールごとの対応
Orthoses::Trace
ではコードを書くことでテストを走らせ、型情報を得ることができることがわかりました。
テストツールは様々にあり、minitestやtest-unit、rspecなどが有名です。コードを書く以上、それぞれのツールに対応したコードを書く必要があります。
ここではOrthoses::Trace
を使った場合のテストツール毎のコードの書き方を示しておきます。
まずテストコードを含むソースコード全体が必要なので、ソースコードをgit clone
等でダウンロードしておきます。
以後説明のため、このソースコード置き場のディレクトリー名を仮にsrc
とします。
minitest
最初に示したrack-testがminitestを使っています。
minitestを使っている場合、autorunを使っていなければload "test/run.rb"
みたいな感じでテストコードを読み、Minitest.run
でテストを走らせることができます。しかし、大抵はautorunを使っているので工夫が必要です。
autorunはat_exit
で実行されるので、そのタイミングだとorthosesで解析するProcの外になってしまいます。
なのでProc内でテストを実行しつつ、autorunを消すためにメソッドをまるごと上書きしています。Minitest.run
はexit codeを返すインターフェースなので0
を返すのがミソです。
test-unit
多分こんな感じだと思います。
Orthoses::Builder.new do use Orthoses::Trace, patterns: ['Foo*'] run ->(){ cd "src" do load "test/foo_test.rb" Test::Unit::AutoRunner.run class ::Test::Unit::AutoRunner def self.run(*, **) 0 end end end } end
rspec
あんまりrspecを使ったライブラリーを見つけられなかったのですが、おそらく以下で動くと思います。 ただし、テストが成功しないとexitで強制終了してしまうのですが、テストが失敗しているということは型は間違っている可能性が高いのでこれでいいのかも。
require "rspec/core" Orthoses::Builder.new do use Orthoses::Trace, patterns: ['Foo*'] run ->(){ cd "src" do load "spec/foo.rb" RSpec::Core::Runner.invoke end } end
めんどい
テストライブラリーに合わせて柔軟に対応できる分、自分でコードを書かなければいけないのがめんどいので、もう少しサポート方法を考えたいところですね。。。 しかしながらリポジトリ毎にテストの走らせ方は千差万別で、gemの依存関係もそれぞれ異なってくるので統一された方法 は難しいと考えています。
思うところ
TracePoint#enable(target: )問題
attr_accessor
系のメソッドがRubyVM::InstructionSequence.of
に対応してくれるともう少しコードが簡単になるのですがレアケースそう……。
実装の詳細
実装面はかなり端折りましたが、実は面白い工夫が盛りだくさんです。TracePointに興味がある人は覗いてみてください。
typeprofすごい
ということでOrthoses内でtypeprofを呼ぶことができる拡張も作りました。
https://github.com/ksss/orthoses-typeprof
これまでにOrthoses内で蓄積した情報をtypeprofに渡して型の参考値にすることができます。
ちなみに今回の出力結果は以下にまとめたので比較してみてください。
Orthoses::Trace: https://github.com/ksss/orthoses/tree/main/examples/rack-test/out/rack typeprof: https://github.com/ksss/orthoses-typeprof/tree/main/examples/rack-test/out/rack
感想
これでライブラリーのRBS追加が少し楽になるかも……?どうだろう、typeprofで十分なのかもしれない……。
とりあえず作ってみる事はできたので、今後は使ってみて便利かどうか確かめる。ですかねえ……。
そもそも何かしらやりたいことがあって、そのときに思いついた手法なのですが、思いついて実装してブログを書くのに5ヶ月ぐらいかかってしまい本来の目的を見失いました。お後がよろしいようで。
思いついたとき。
おもしろそうなコード思いついて試してみたらいけそうで脳汁出てたらこんな時間
— ksss (@_ksss_) 2022年10月30日