はじめに
こんにちは、Wantedly の 2021 年サマーインターンに参加した宮下と申します。今回のインターンでは三週間の間 DX (Developer Experience) チームに所属し、Wantedly のコードベースに Ruby の型チェッカーの導入を試みることをテーマにしていました。
インターンの前半では、様々な型チェッカーの性能を調べたり、それぞれの型チェッカーを実際に使ってみることで、開発効率を基準とした比較を行いました。インターンの後半では、現段階では一番実務に適しているだろうと判断した Sorbet に焦点を当て、Wantedly のいくつかのコードベースに実験的に Sorbet を導入した環境を作った型情報をつけていく作業をしていました。 本記事は、主にインターンの前半で調査した、型チェッカーの比較という部分に焦点を当て、文章の形にまとめたものになります。
Ruby の型事情
Ruby は、実行時になるまで変数の型が確定しない、つまり変数が動的な型を持つ言語です。
実際にプログラムを実行しないと変数の型がわからない以上、実際には存在しないメソッドを呼び出そうとしたり、定義されていない定数を使っていても、実際にコードが動くまでエラーを検知できません。
class A
def inc(x)
x + 1
end
end
a = A.new
a.ink(1) # メソッドが存在しない (NoMethodError)
a.inc # 引数が足りない (ArgumentError)
a.inc("1") # String を Integer に足そうとしている (TypeError)
上のコードでは、3種類のランタイムエラーが発生するコードを紹介していますが、いずれも a
の型情報がクラス A
であるとわかっていれば実行前に防げたであろうエラーです。
一方で、型が動的であることを活用した Ruby の言語仕様はたくさん存在します。一つ例に取ると
class C
define_method(:inc) {|x| x + 1}
end
C.new.inc 1 # => 2
構文を介さないメソッドの定義方法は、実行時前に型を知ろうとする静的な型チェッカーにとっては大きな障壁になってしまうことは想像に難くありません。上のコードでは、define_method
というメソッドを利用して、クラス C
のインスタンスメソッド inc
を動的に定義していますが、このような def ... end
構文を介さないメソッドの定義方法は、実行時前に型を知ろうとする静的な型チェッカーにとっては大きな障壁になってしまうことは想像に難くありません。
このような例を始めとして、様々な Ruby 言語の動的な型の仕様と折り合いをつけながら、なんとか実行時前に変数の型検査を行おうとする試みがあります。今回は、Ruby の静的な型チェッカーとして使える Sorbet と Steep という2つのツール [注1] を実際に使ってみて、それぞれの型チェッカーの機能やメリット・デメリットを紹介していきたいと思います。
Sorbet と Steep の比較
Sorbet も Steep も、静的に Ruby のコードベースの型検査を行うことのできるツールです。コードを直接実行せずに型検査が可能なため、冒頭で紹介したような NoMethodError を始めとする様々な型の違いによるエラーを未然に防ぐことが期待されます。
以下の表は、Sorbet と Steep それぞれについて、型チェッカーの仕様や周辺ツールの整備状況などを簡単にまとめたものになります。
注* 2021年9月1日時点での個数
注** 2021年9月8日時点での回数 (https://rubygems.org/)
型情報のありか
Sorbet の場合
Sorbet では、 Ruby ファイルに埋め込む形でメソッドや変数・定数の型を記述します。
class A
extend T::Sig
sig {params(x: Integer, y: Integer).returns(Integer)}
def foo(x,y)
return x+y+1
end
def untyped_foo(x,y)
return x+y+1
end
CONST = T.let("constant", String)
end
上の例では、クラス A
のインスタンスメソッド foo
と定数 CONST
にそれぞれ型がついています。メソッドに対する型情報は sig
メソッドを用い、変数・定数に対する型情報は T.let
などのヘルパー関数を使います。ここでのメソッド untyped_foo
のように、メソッドや定数・変数に型を明示的に付けないことも可能です。型を明示しなかった場合のメソッドの引数や戻り値の型は T.untyped
になります。(T.untyped
は TypeScript でいう any
型で、どの型の値も代入可能で、どの型の変数にも代入可能な値の型です。)
なお、Sorbet が提供する型情報記述用のメソッドを利用するには必ず extend T::Sig
を先頭に記述する必要があります。
また、Sorbet では、gem 中に定義されたメソッドや定数など、Sorbet が直接定義を参照できないものの型情報を定義するために RBI 形式という外部ファイル用の形式が用意されています。RBI は "Ruby Interface" files の略で、メソッドや定数の存在や型情報のみを示した情報が、内部実装を省いて記述されています。
この RBI 形式で記述された RBI ファイルは、プロジェクトのルートフォルダにある sorbet/ ディレクトリ以下に配置することで Sorbet に認識させることができます。RBI は基本的には Ruby のコードと同じ文法ですが、extend T::Sig
を書かなくても sig
を利用できる、などの細かな違いがあります。
有名な gem の一部にはコミュニティが提供する手書きの RBI ファイルが存在し、srb rbi update
コマンド (または srb rbi sorbet-typed
) によって自動的に sorbet/ ディレクトリに取り込むことが可能です。また、対応されていない gem やより正確な型を付けたい gem に対し、自分で RBI ファイルを実装することも可能です。
RBI ファイルの役割をより詳しく説明するために、動的にメソッドを生成する例を取り上げます。上で定義したクラス A
に対し、以下のように動的にメソッド bar
を追加したとします。
class A
extend T::Sig
# 新たなメソッド bar を追加
define_method(:bar) {|x| (x + 1).to_s}
sig {params(x: Integer, y: Integer).returns(Integer)}
def foo(x,y)
return x+y+1
end
def untyped_foo(x,y)
return x+y+1
end
CONST = T.let("constant", String)
end
このままでは Sorbet は A#bar
の存在を検知できず、例えば以下のコード
A.new.bar(1)
を型エラーとしてしまいます。このとき、Sorbet の組み込み機能である srb rbi update
を走らせると、ランタイムリフレクション (コードを実際に実行してオブジェクトを生成することによって、実行時に存在するメソッドについての情報を得ること) によって RBI ファイルが更新され、sorbet/rbi/hidden-definitions/hidden.rbi に以下のコードが追加されます
class A
def bar(x); end
end
これによって A#bar
メソッドの情報も Sorbet に認知され、bar
メソッドを使ったときに発生していた型エラーもなくなります。
ランタイムリフレクションによって Sorbet が得られる情報は、そのメソッド自体の存在と引数の個数のみで、型注釈は一切付きません。実際に、上の例で生成された RBI ファイルには sig
による型注釈がなされていないことがわかります。動的に生成したメソッドに対してカスタムで型注釈を付けたい場合は、自前で sorbet/ 以下に RBI ファイルを作り、以下のように記述します。
class A
sig {params(x: Integer).returns(String)}
def bar(x); end
end
以上の例では動的なメソッド生成を例に取り上げましたが、動的なメソッドに限らず、メタプログラミング的なコードを新たに追加するたびに、srb rbi update
(または、ランタイムリフレクションのみを行うコマンドである srb rbi hidden-definitions
) を実行する、もしくは手動で RBI ファイルを更新する、のどちらかを行い、利用可能な全てのメソッドを Sorbet に認識させることが望ましいでしょう。
Steep の場合
Steep では、Ruby ファイルとは独立した RBS という形式のファイルに全ての型情報を記述します。
RBS は “Ruby Signature” の略で、 任意の Ruby ファイルに実装されたクラスやモジュール中のメンバ変数/メソッド/定数の型情報が含まれています。
例えば、以下の Ruby コード
class A
def foo(x,y)
return x+y+1
end
def untyped_foo(x,y)
return x+y+1
end
CONST = "constant"
end
に対応する RBS ファイルは以下のようになります。
class A
def foo: (Integer, Integer) -> Integer
def untyped_foo: (untyped, untyped) -> untyped
CONST: String
end
RBS の型注釈は Ruby とは異なる文法で書かれています。現時点で RBS 形式は メソッド内の一時変数の型の情報の記述をサポートしていませんが、Steep には Ruby ファイル中に @type
annotation で記述された情報を型情報として利用させることができます。
def bar(x)
# 以下のように、Ruby のコメントとして annotation を追加可能
# @type var y: String
y = x.to_s
...
end
Sorbet 組み込みの機能である RBI 生成と同じように、RBS ファイルも Ruby ファイルから自動生成することが可能です。rbs gem の rbs prototype
を使うと、 Ruby ファイルから RBS ファイルの雛形を生成することが可能です。rbs prototype
にも rb
や runtime
などいくつかのモードが存在しますが、どのモードも、あくまでメソッドの存在と引数の個数のみを情報として収集・利用しているため、引数や戻り値の具体的な型は Sorbet の RBI ファイル同様に手動でつけていく必要があります。
RBS ファイルを生成するもう一つの手段として、Ruby 3.0 から Ruby に同梱されている TypeProf というツールを使う方法があります。TypeProf は、メソッドの呼び出し元からの情報をもとに型推論を行い、 RBS ファイルを出力するツールです。例えば以下の Ruby コード
class A
def foo(x,y)
return x+y+1
end
def untyped_foo(x,y)
return x+y+1
end
CONST = "constant"
end
p = 1
q = 2
r = A.new.foo(p,q)
を TypeProf に通すと、以下の出力が得られます。
# TypeProf 0.15.0
# Classes
class A
CONST: String
def foo: (Integer x, Integer y) -> Integer
def untyped_foo: (untyped x, untyped y) -> untyped
end
## Version info:
## * Ruby: 3.0.0
## * RBS: 1.3.1
## * TypeProf: 0.15.0
A#foo
の呼び出し元が Integer 型の値を引数として呼び出していることから、引数や戻り値は Integer であることが推論できている事がわかります。
TypeProf は RBS 生成と同時に型エラーの検出も行っており、 Ruby 3.1 からは IDE との連携もなされるなど、Sorbet と Steep に続くの第3の選択肢として期待されます。(参考: https://www.slideshare.net/mametter/typeprof-for-ide-enrich-development-experience-without-annotations) Steep よりも低速で、まだ静的な型チェッカーとして大規模なプロジェクトに実用化する例は無いようですが、今後のバージョンアップによっては利用例が増えてくるかもしれません。
RBI 形式と RBS 形式の比較
RBI ファイルの役割は、Sorbet が内部実装を認識していないコードの型情報を保持することです。そもそも Sorbet の思想としては、Ruby のコードに直接型注釈を埋め込むという方法を原則としていますので、あくまで Ruby ファイルが主役で、RBI ファイルは補助的な役割を果たすファイルというイメージではないかと思います。一方、RBS ファイルは Ruby ファイルとは完全に独立していて、全てのメソッドや定数の型情報は RBS ファイルを見ればわかります。この仕様の違いは、単に形式上の違いにとどまらず、実際にランタイムでの挙動の違いも生んでいます。(「ランタイムチェック」の項を参照)
他にも RBI 形式には Ruby の文法という制約があるのに対し、RBS ファイルは独自の文法を持つため、型情報をより簡潔に書ける傾向があるように思われます。Sorbet では型情報の記述に sig
、params
、returns
などのキーワードを必ず書く必要があるので、RBS 形式に比べると長くなってしまっています。
Ruby 3.0 からは、型情報を記述する言語として RBS ファイルが採用されました。これからは RBS ファイルが Ruby の型情報を記述する形式として主流担っていくことが予想される分、Sorbet を使いつづけたい場合は変換ツール (rbs-parser や parlour など) を使用する必要が出てくるかもしれません。
Typecheck にかかる時間
Wantedly の "pulse" という内部リポジトリ (*.rb
ファイル約 400 個からなる Rails アプリケーション) で Sorbet と Steep をそれぞれセットアップし、型検査にかかる時間を計測しました。
$ time bundle exec srb tc
No errors! Great job.
real 0m0.880s
user 0m0.800s
sys 0m0.447s
$ time bundle exec steep check
(中略)
real 0m16.153s
user 1m48.544s
sys 0m14.446s
実時間で Sorbet は 0.9 秒、Steep は 16.2秒という結果になりました。この型検査の他にも、セットアップ時や自動生成されるファイル群の更新などの処理は別途必要ではありますが、Sorbet のほうが Steep よりかなり速いという結果になりました。
実際、Sorbet は型検査の速さをかなり重視しているようで、C++ での実装を始めとして、CPU のキャッシュをうまく活用したり、メモリ割当を手動で実装したりなどの様々なチューニングが功を奏しているようです。(https://blog.nelhage.com/post/why-sorbet-is-fast/)
ランタイムチェック
「RBI 形式と RBS 形式の比較」でも触れたとおり、Sorbet は型のランタイムチェックも行っているのに対し、Steep はランタイムには一切影響を及ぼしません。Sorbet は実行時にも型チェックがデフォルトで行われる分、若干のオーバーヘッドがかかってしまいますが、設定によってオフにすることが可能です。
部分的な型チェック
Sorbet も Steep も、コードの部分によっては型の情報をあえて記述しないという選択を取ることが可能です。しかし、型を付けないファイルの指定の方法は2つのツール間で大きく違います。
Sorbet
Sorbet では、ユーザーは sigil と呼ばれる型チェックの強さを各 Ruby ファイルに付与しなければいけません。ファイルの一行目に #typed: なんらかのsigil
と書くことで、Sorbet に sigil を認識させることができます。[注2]
sigil は以下の五種類からなります。
- #typed: ignore: 無し (一切エラーを出さない)
- #typed: false: Ruby の文法や sig の文法的正しさ、モジュール・クラス・定数の存在が検証
- #typed: true: メソッドの呼び出し時の型エラーなど、いわゆる一般的な型エラー
- #typed: strict: 全てのメソッドに sig が付くことが求められる (全てのメソッドは静的・明示的な型を持つ)
- #typed: strong: すべての演算やメソッド呼び出しは型情報により保証されていなければならない (例えば、T.untyped 型に対するインスタンスメソッドの呼び出しは禁止)
Sorbet は、 srb rbi suggest-typed
コマンドで各ファイルについて適切な sigil を付与してくれるため、全てのファイルにいちいち手動で sigil を追加する必要はありませんが、Sorbet が自動で付与した sigil をユーザーが上げ下げすることは可能です。
Steep
Steep は、Steepfile という 設定ファイルから steep check
によってチェックするディレクトリを指定することができます。例えば、app/models
を指定すれば、Rails 中の全てのモデルのみを型検査することができます。他にも ignore
命令や check
命令を組み合わせて型チェックするファイルを個別に指定することもできます。
VSCode Extension
どちらの型チェッカーも LSP が実装されており、VSCode Extension がそれぞれにあります。
(sorbet-lsp, steep)
型チェックのためにいちいちターミナルを叩く必要がなく、コードが更新されるごとに型チェックが走り、ハイライトや補完がなされます。
sorbet-lsp
steep
型システムの強さ
記事の冒頭で紹介したとおり、Ruby の言語仕様は動的型の性質をフルに活用しており、型システムの導入が難しいフィーチャーも存在します。ここでは、Overloading, Generics, Duck typing, 動的なメソッド生成 という4つの言語仕様を取り上げ、それぞれの型チェッカーがどのような型を付けられるのかを紹介します。
Overloading
Sorbet は標準ライブラリの一部のメソッドを例外として、基本的には overload をサポートしていません。(今後もサポートされる予定は無いとのことです。参考: https://github.com/chanzuckerberg/sorbet-rails/issues/18#issuecomment-504500428)
T.any
(TypeScript の |
に対応する) 型で部分的に対処することは可能ですが、戻り値のキャストは必要になります。
# Sorbet
sig {params(x:T.any(String,Integer)).returns(T.any(String,Integer))}
def foo(x)
if x.is_a?(Integer) then
x + 1
else
x + "1"
end
end
# 実際は Integer 型同士の足し算なのでランタイムエラーは発生しないが、Sorbet は T.any(Integer,String) と Integer 型の足し算だと型エラーを出してしまう
puts(foo(1) + 1)
# 以下のように、foo メソッドの戻り値の型を手動でキャストする必要が出てきてしまう
puts(T.cast(foo(1), Integer), 1)
puts(T.unsafe(foo(1)), 1)
Steep には overload 用の型が存在します。(A) -> B | (C) -> D
は、A
型の引数の戻り値は B
、C
型の引数の戻り値は D
であるメソッドの型を示します。
# Steep
# *.rbs
def foo(x): (Integer) -> Integer | (String) -> String
# *.rb
def foo(x)
if x.is_a?(Integer) then
x + 1
else
x + "1"
end
end
# OK
puts(foo(1) + 1)
Generics
Sorbet はモジュールやクラスの Generics をサポートしていますが、メソッドに対する Generics はサポートしていません。
以下のコード例のように、Generics を付けたいクラスやモジュール中で type_member
メソッドを利用することで、sig
に Generics 型を利用できるようになります。
(詳しい T::Generic
の使い方は https://github.com/sorbet/sorbet/pull/3477/files#diff-50f662d3ba962a259a4f2d454f304ee45f8ee8f16585fe43881a9cac0d5040fb を参照してください。)
extend T::Sig
class MyClass
extend T::Sig
extend T::Generic
GenericType = type_member
sig{params(g: GenericType).void}
def bar(g)
puts g.class
end
end
sig{params(x: MyClass[String]).void}
def foo(x)
x.bar("S")
end
Steep (RBS 形式ファイル) は、クラス・モジュール・メソッドすべての Generics に型を付与することができます。
class MyClass[GenericType]
def bar: (GenericType) -> nil
end
def foo: (MyClass[String]) -> nil
def hoge: [GenericType] (GenericType) -> GenericType
def piyo: [GType1, GType2] (GType1, GType2) -> GType1
Duck Typing
Sorbet は 、個別のクラス (有限個) に対してメソッドがあることを表現するための interface
は存在しますが、「あるメソッドを実装しているクラス」全てを表す型を定義する方法はありません。個別のクラスについての interface
の付け方は以下のコード例のようになります。
# interface `Fooable` を用いた実装
module Fooable
extend T::Sig
extend T::Helpers
# このモジュールは実体を持たせないことを示す
abstract!
# メソッド foo は呼び出せない
sig {abstract.void}
def foo
end
end
class GoodFooable
# GoodFooable は foo を持たなければならない
include Fooable
def foo
puts "Foo!"
end
end
class BadFooable
# BadFooable は foo を持たなければならない -> エラー
include Fooable
end
一方、Steep では、 RBS 形式の interface
(Sorbet の interface
とは異なる) を使うと Duck Typing を実現できます。
# interface の名前は `_` 始まり
interface _Fooable
def foo: () -> nil
end
# foo をメソッドに持つクラスを受け取るメソッド
def bar: (_Fooable) -> nil
# *.rb
def bar(has_foo)
has_foo.foo
end
class GoodFooable
def foo
puts "Foo!"
end
end
class BadFooable
end
bar(GoodFooable.new)
# bar(BadFooable.new) は型エラー
動的な (def を用いない) メソッド生成
Sorbet は、特に使用頻度の高い attr_reader
, attr_writer
, attr_accessor
について、例外的に直接 sig
をつけることができます。
class A
extend T::Sig
sig {returns(Integer)}
attr_reader :reader
sig {params(writer: Integer).returns(Integer)}
attr_writer :writer
# accessor の sig は reader と同じにする
sig {returns(Integer)}
attr_accessor :accessor
# typed: strict 以上の場合、 initialize中でメンバ変数を初期化しなければならない
sig {params(reader: Integer, writer: Integer, accessor: Integer).void}
def initialize(reader, writer, accessor)
@reader = reader
@writer = writer
@accessor = accessor
end
end
define_method
などを用いた一般的な動的メソッド定義については、「型情報のありか」でも触れたとおり、 srb rbi hidden-definitions
などのコマンドを用いるか、手で RBI ファイルを書く必要があります。
Steep は、Ruby コード中のコメントに @dynamic
アノテーションで動的メソッドの存在を宣言することができます。例えば、attr_reader
が生成するメソッドには以下のような annotation を付けることができます。
# *.rb
class A
# @dynamic reader
attr_reader :reader
end
# *.rbs
class A
@reader: Integer
end
型システム・まとめ
以上の4つの例を見てきましたが、Steep・RBS の型表現に比べると、Sorbet の型表現はどうしても不完全なように感じられます。特に、Overloading に関しては今後もサポートされることは無い予定とのことなので、どのような回避策を講じるのか (オーバーロード自体を避けるか、型を untyped にしてしまうか、など) は議論する必要がありそうです。
Rails への対応
Rails が generate するオブジェクトには動的な関数定義が多用されているため、Ruby の知識しか持たない型チェッカーが適切な型を付与できない場合が頻出します。そのため、型チェッカーと Rails の橋渡しを行う専用のツールが Sorbet にも Steep にも存在します。
sorbet-rails を使うと、routes, models, helpers, mailers, jobs などを含めた、Rails で generate された様々なオブジェクトのメソッドを RBI 形式ファイルに書き出すことができます。
rails-rbs は、models と routes (path helpers) の型情報を RBS ファイルとして生成できます。
Ruby 3 への対応
参考: Sorbet と Ruby 3 の関係はどうなるのか
Ruby 3.0 から、型情報の形式として RBS 形式が公式でサポートされるようになり、 それに付随して TypeProf がバンドルされるようになりました。「型情報のありか」でも触れたとおり、TypeProf は Ruby ファイル野型推論を行い、結果を RBS 形式で出力します。そのため、RBS ファイルをそのまま認識できる Steep と相性がよく、TypeProf で自動生成した推論結果を手で少し直して Steep に読み込ませる、といった連携が可能になると思われます。
一方、Sorbet の RBI ファイルについては、RBS への変換を行うツール (parlour など) が存在します。実は、Ruby 3 でバンドルされる標準ライブラリの型情報も元は Sorbet のコミュニティが作った RBI ファイルを変換したものとのことですので、実務にも耐えうる程度には完成されていると考えられます。一方、直接 Ruby ファイルに sig
埋め込みをしている部分は Ruby の文法で記述されているため、そのまま Ruby 3 でも使えます。Sorbet の方も、Ruby 3 および RBS 形式に十分対応できるような環境が整っているように思われます。
まとめ
Sorbet と Steep を様々な尺度によって比較してきましたが、Wantedly のコードベースに適用するのにより良い型チェッカーは Sorbet だと考えました。
理由としては、やはり一回の型チェックの速さが Sorbet のほうが圧倒的に速かった点が一つあります。両方の型チェッカーともに LSP が実装されており、実際に VSCode で補完機能やホバーによる型情報閲覧機能などを試してみましたが、これらのエディター便利機能も型チェックの速さに強く依存しており、Sorbet のほうが圧倒的に応答速度が速く、より快適にコーディングができたように思います。
また、もう一つの理由として、Sorbet はすでに実際のコードベースで使われている実績が多々あり、コミュニティーによって整備されている型ファイルや便利ツールがより多く存在すると感じた点にあります。特に、Sorbet の公式ドキュメント (https://sorbet.org/docs/overview) は一通りのエラーとその対処法がまとめられており、VSCode で波線が引かれた部分から直接ドキュメントに飛べる機能は非常に便利でした。
一方、Sorbet にも機能として Steep (というより、RBS 形式自身) に対して明確に劣っている部分も存在しました。型システムは RBS 形式に比べると弱く、 Ruby 本来の型を反映しきれていないケースがいくつか存在します。また、型情報を記述する方法として Ruby 3.0 からは RBS 形式が採用されましたので、Sorbet を利用しているプロジェクトにとって、いずれ RBS 形式との連携は避けて通れない課題になると思われます。
インターンを終えて
「Ruby の型チェッカーの導入」というテーマで三週間のインターンを終えることができました。このインターンを通じて、Ruby の型に関する知識がついたこと、また実務で使われている様々な Ruby のコードに触れられたことは非常に有意義でした。他にも、目標を立てるにしろ実際のコーディングをするにしろ、「Developer Experience を良くしていく」という観点から物事を見ることは、DX チームならではの面白い体験だったように思います。本インターンで得られた知見を生かして、自分自身としても、今後の Ruby 言語の勉強や Rails でのウェブアプリ開発につなげていきたいと思います。
注
- 実行時にチェックを行う型検査器は Sorbet と Steep の他にもあり、調べたところ Contracts や RDL などがありました。
- 実は sigil は一行目に書く必要はありません。"#typed:" から始まる一番上の行が sigil として認識されるようです