RubyからRBSを生成する各方法の特徴
この記事はRuby 3.0 Advent Calendar 18日目の記事です。
昨日の記事は id:Pocke さんで「ruby/rbsに取り込まれた私のパッチ」でした。
この記事ではRuby 3で導入される型定義ファイルであるRBSファイルを自動生成する方法について説明します。
既存のRubyコードに対するRBSを1から書いていくのは大変です。
そのためRBSを生成するプログラムがいくつか開発されており、Ruby 3にもTypeProf、rbs prototype rb
、rbs prototype runtime
の3つが同梱されます。
ところがこれら3つの特徴を解説した情報は現時点ではあまりありません。私がrbs prototype
を主に開発していることもあり、今回それぞれの特徴を記事にまとめることにしました。
前提
前提知識としてmameさんによる「Ruby 3の静的解析機能のRBS、TypeProf、Steep、Sorbetの関係についてのノート」を読んでおくと理解が深まるでしょう。
RubyからRBSを生成する各方法
前述したとおり、現時点でRubyのコードからRBSを生成するのには、主に3つの方法があります。
rbs prototype rb
rbs prototype runtime
- TypeProf
この内私が詳しいrbs prototype rb
とrbs prototype runtime
について詳しく解説します。
私はTypeProfにはあまり詳しくないため、この記事では概略だけを解説します。
rbs prototype rb
まずはrbs prototype rb
コマンドについて説明します。
このコマンドはRubyのコードを静的にパースして解析し1、RBSファイルを生成します。
使い方
rbs prototype rb
の使い方は簡単です。
たとえば次のようなRubyコードがgreeting.rb
として存在する場合には、そのファイル名を引数としてコマンドに渡せばRBSを生成できます。
# greeting.rb class Greeting def hello(name) puts "Hello, #{name}!" end end Greeting.new.hello("pocke")
# 実行例 $ rbs prototype rb greeting.rb class Greeting def hello: (untyped name) -> untyped end
また対象となるRubyファイルが複数ある場合は、それらを全て引数として渡します。
たとえばlib/
ディレクトリ下のRubyファイルを全てRBSに変換するには、rbs prototype rb lib/**/*.rb
2と実行します。
# RuboCopの型定義を生成する場合 $ cd path/to/rubocop-hq/rubocop/ $ rbs prototype rb lib/**/*.rb # These aliases are for compatibility. module RuboCop NodePattern: untyped ProcessedSource: untyped Token: untyped end module RuboCop # Converts RuboCop objects to and from the serialization format JSON. # @api private class CachedData def initialize: (untyped filename) -> untyped def from_json: (untyped text) -> untyped # 以降型定義がずらずらと出力される
利点
まず第一に実行が簡単です。 対象のRubyコードを実行せずにRBSファイルを生成するため、安定して動くことが期待できます。
また、単純なケースではメソッド戻り値の型を出力することもできます。 たとえば次のようなコードでは戻り値の型がRBSファイルにも出力されます。
# return-literal.rb class C def foo 'str' end def bar return 'str' if cond 42 end def baz [1, 2, 3] end end
$ rbs prototype rb return-literal.rb class C def foo: () -> "str" def bar: () -> ("str" | 42) def baz: () -> ::Array[1 | 2 | 3] end
これはrbs prototype runtime
にはない利点です。3
欠点
一方rbs prototype rb
はコードを静的にパースするだけなので、define_method
やeval
などのメタプログラミングによって定義されたメソッドには無力です。
たとえば次のようなコードに対しては、rbs prototype rb
はメソッド定義を見つけることができません。
# meta-programming.rb class C %i[a b c].each do |sym| define_method(sym) do sym end end eval <<~RUBY def foo end RUBY end c = C.new c.a; c.b; c.c; c.foo
$ rbs prototype rb meta-programming.rb class C end
そのためメタプログラミングを多用したプログラムに対しては、後述するrbs prototype runtime
を使ったほうがより効果的かもしれません。4
rbs prototype runtime
このコマンドは対象のRubyコードを実行した上で、ランタイムのAPIを使ってクラスやメソッドの情報を取得し、RBSを生成します。
RubyではModule#instance_methods
メソッドでそのモジュール/クラスのインスタンスメソッドの一覧が取れます。またUnboundMethod#parameters
メソッドでメソッドの引数情報を取得できます。
rbs prototype runtime
ではこれらのメソッドを使ってRBSファイルを生成しています。
使い方
基本的な使い方
rbs prototype runtime
は、出力したいクラス/モジュールの名前を引数に受け取ります。また、読み込みたいRubyのコードを-r
オプション(require
相当)か-R
オプション(require_relative
相当)で指定します。
例えば次のgreeting.rb
(前に挙げたものと同じです)で定義されているGreeting
クラスのRBSを生成したい場合、次のように実行します。
# greeting.rb (再掲) class Greeting def hello(name) puts "Hello, #{name}!" end end Greeting.new.hello("pocke")
# 実行結果 $ rbs prototype runtime -R greeting.rb Greeting Hello, pocke! class Greeting public def hello: (untyped name) -> untyped end
rbs prototype rb
のときとほぼ同じコードが生成されました。
ここで、Hello, pocke!
が出力されているのに注意してください。rbs prototype runtime
は実際にgreeting.rb
を実行しているので、この出力がなされます。
ネームスペース
また、特定のネームスペース下のクラスを全て出力するために*
が使えます。
# namespace.rb class A class B class C end end class D end end
# 実行結果: Aネームスペース以下のクラスを全て出力する $ rbs prototype runtime -R namespace.rb 'A::*' A class A end class A::B end class A::B::C end class A::D end
Railsアプリケーション
Railsアプリケーションの型を出力する場合、config/environment.rb
を読み込み先に指定すると良いでしょう。
また、config/environments/development.rb
でeager_load
とcache_classes
をtrue
に設定しておくと良いでしょう。5
# config/environments/development.rb Rails.application.configure do config.cache_classes = true config.eager_load = true end
# Userモデルの型を出力する例 $ rbs prototype runtime -R config/environment.rb User class User < ApplicationRecord # たくさんのメソッド定義が出力される end
利点
rbs prototype rb
と比べた時の大きな利点は、やはりメタプログラミングによって生成されたメソッドの扱いです。
rbs prototype runtime
はメソッドの定義方法によらず、そのメソッドが定義されてさえすれば動きます。
たとえば先程のmeta-programming.rb
を対象に実行してみましょう。
# meta-programming.rb (再掲) class C %i[a b c].each do |sym| define_method(sym) do sym end end eval <<~RUBY def foo end RUBY end c = C.new c.a; c.b; c.c; c.foo
$ rbs prototype runtime -R meta-programming.rb C class C public def a: () -> untyped def b: () -> untyped def c: () -> untyped def foo: () -> untyped end
このようにメタプログラミングによって生成されたメソッドであっても問題なくRBSが生成できました。
欠点
rbs prototype runtime
はコードを実行する必要があるため、rbs prototype rb
に比べて多少動かすのが難しいでしょう。
たとえば先程の例のGreeting
クラスのRBSを生成するときにも、Hello, pocke!
が表示されてしまっていました。
Module#instance_methods
などのメソッドが不適切に再定義されている場合にも、うまく動作しないかも知れません。6
rbs prototype runtime
の実行時には出力したいクラスが読み込まれている必要があるため、遅延ロードを行うModule#autoload
との相性は悪いです。Module#autoload
をしている場合には、何らかの方法で事前に対象のモジュールを読み込む必要があるでしょう。
また出力したいクラスの名前を指定する必要があるのは、グローバルな名前空間を散らかすRailsアプリケーションのようなケースでは多少不都合かも知れません。
git grep -E '^(module|class)' app/ lib/ | cut -d ' ' -f 2 | sort -u | ruby -ple '$_=$_+"::* " + $_'
のように雑にクラス名を列挙すると良いでしょう。
ほかにも、先に述べたとおり、メソッドの戻り値がリテラルで書かれていてもuntyped
が戻り値型として出力されます。
TypeProf
TypeProfは、Rubyプログラムを型レベルで抽象的に実行することでRBSを生成します。 詳しい説明はできないため、以下のリンク先を参考にしてください🙏
使い方
ここでは簡単な使い方だけを示します。
typeprof
コマンドに解析したいRubyコードのエントリポイントを引数として渡します。
たとえば先に挙げたgreeting.rb
を解析すると次のようになります。
# greeting.rb (再掲) class Greeting def hello(name) puts "Hello, #{name}!" end end Greeting.new.hello("pocke")
$ typeprof greeting.rb # Classes class Greeting def hello: (String) -> nil end
rbs prototype
と違い、メソッド引数と戻り値の型が出力されているのが分かります。
雑感
TypeProfはrbs prototype
と違い高度な解析を行っているため、より具体的な型が出力されることが期待できます。
一方で型の生成にかかる時間はrbs prototype
と比べて遅いです。7
また実行にはコードを実行するエントリポイントを指定する必要があります。(Railsアプリだとどこを指定するのが良いんだろう…)
Sord
ほかにもSordというgemでYardからRBSの生成ができるらしい……ですが、全く試せていないのでよくわかっていないです。 一応リンクだけのせておきます。
まとめ
rbs prototype
をメインに、RBSを生成する方法について解説しました。
RBSの生成時にこの記事が手助けになれば幸いです。
FAQ
-
RubyVM::AbstractSyntaxTree
を使っています。↩ -
当然シェルが
**
を解釈する必要があります。↩ -
AST.of
を使えばruntimeでも同様の実装ができるのですが、eval
などで扱いづらいので実装していません。 https://bugs.ruby-lang.org/issues/16983↩ -
しかし
rbs prototype runtime
がメタプログラミングばりばりなコードに対して実行しやすいかと言うと……どうなんだろう。↩ -
eager_load
をtrue
にするのは、必要なクラスを予め読み込んでおくため。cache_classes
をtrue
にするのは、あるクラスを表すClassオブジェクトが2つ以上できてしまわないようにするため……だと思う。↩ -
rbs prototype
はどちらもわりと一瞬で終わります↩