pockestrap

Programmer's memo

RubyからRBSを生成する各方法の特徴

この記事はRuby 3.0 Advent Calendar 18日目の記事です。

qiita.com

昨日の記事は id:Pocke さんで「ruby/rbsに取り込まれた私のパッチ」でした。

pocke.hatenablog.com


この記事ではRuby 3で導入される型定義ファイルであるRBSファイルを自動生成する方法について説明します。

既存のRubyコードに対するRBSを1から書いていくのは大変です。 そのためRBSを生成するプログラムがいくつか開発されており、Ruby 3にもTypeProf、rbs prototype rbrbs prototype runtimeの3つが同梱されます。

ところがこれら3つの特徴を解説した情報は現時点ではあまりありません。私がrbs prototypeを主に開発していることもあり、今回それぞれの特徴を記事にまとめることにしました。

前提

前提知識としてmameさんによる「Ruby 3の静的解析機能のRBS、TypeProf、Steep、Sorbetの関係についてのノート」を読んでおくと理解が深まるでしょう。

techlife.cookpad.com

RubyからRBSを生成する各方法

前述したとおり、現時点でRubyのコードからRBSを生成するのには、主に3つの方法があります。

  • rbs prototype rb
  • rbs prototype runtime
  • TypeProf

この内私が詳しいrbs prototype rbrbs prototype runtimeについて詳しく解説します。 私はTypeProfにはあまり詳しくないため、この記事では概略だけを解説します。

rbs prototype rb

まずはrbs prototype rbコマンドについて説明します。 このコマンドはRubyのコードを静的にパースして解析し1RBSファイルを生成します。

使い方

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_methodevalなどのメタプログラミングによって定義されたメソッドには無力です。 たとえば次のようなコードに対しては、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.rbeager_loadcache_classestrueに設定しておくと良いでしょう。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を生成します。 詳しい説明はできないため、以下のリンク先を参考にしてください🙏

github.com

techlife.cookpad.com

使い方

ここでは簡単な使い方だけを示します。

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

github.com

ほかにもSordというgemでYardからRBSの生成ができるらしい……ですが、全く試せていないのでよくわかっていないです。 一応リンクだけのせておきます。

まとめ

rbs prototypeをメインに、RBSを生成する方法について解説しました。

RBSの生成時にこの記事が手助けになれば幸いです。

FAQ

  1. RBS Railsの話は?
  2. 別途記事を書きます💪

  1. RubyVM::AbstractSyntaxTreeを使っています。

  2. 当然シェルが**を解釈する必要があります。

  3. AST.ofを使えばruntimeでも同様の実装ができるのですが、evalなどで扱いづらいので実装していません。 https://bugs.ruby-lang.org/issues/16983

  4. しかしrbs prototype runtimeメタプログラミングばりばりなコードに対して実行しやすいかと言うと……どうなんだろう。

  5. eager_loadtrueにするのは、必要なクラスを予め読み込んでおくため。cache_classestrueにするのは、あるクラスを表すClassオブジェクトが2つ以上できてしまわないようにするため……だと思う。

  6. 例: https://bugs.ruby-lang.org/issues/16982

  7. rbs prototypeはどちらもわりと一瞬で終わります