この記事は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 rbrbs 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/**/*.rb2と実行します。
# 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はどちらもわりと一瞬で終わります↩