Rubyの型解析ライブラリSorbetを導入しました
株式会社YOJO Technologies」から「PharmaX株式会社」へ社名変更いたしました。この記事は社名変更前にリリースしたものになります。
開発BI部門の加藤(@tomo_k09)です。
つい最近、Rubyの型解析ライブラリであるSorbetを自社のアプリケーションに導入しました。
Googleで検索したところ、まだまだRubyの型解析ライブラリに関する記事が少ないようです。本記事ではSorbetの特徴や使い方についてまとめてみましたので、よろしければ参考にされてください。
Sorbetとは
Sorbet(ソルベ)は、Rubyの型チェッカーです。
決済代行サービスを運営しているStripe社が公開しています。
Sorbetを選んだ理由
Rubyの型チェッカーの候補としては、他にはSteepがあったのですが、以下の理由から弊社ではSorbetを選びました。
学習コストが低い
Sorbetの型情報はRubyの文法で記述できるということもあり、学習コストが比較的低いです。
例えば、以下のように型の情報を記述することができます。
sigというメソッドを使うことにより、引数はstring、返り値はintegerというように型の定義が可能です。
# typed: true
extend T::Sig
sig {params(name: String).returns(Integer)}
def main(name)
puts "Hello, #{name}!"
name.length
end
一方、SteepではRBSというファイルに型情報を記述していくのですが、RubyっぽいけどRubyとは少し異なる書き方をする必要があります。
class Person
@name: String
@contacts: Array[Email | Phone]
def initialize: (name: String) -> untyped
def name: -> String
def contacts: -> Array[Email | Phone]
def guess_country: -> (String | nil)
end
メンテやレビューを行いやすい
先ほども書いた通り、Sorbetを導入すると型注釈のコードをRubyファイルに書きます。
そのため、それがそのままドキュメンテーションの役割を果たすため、メンテやレビューを行いやすいです。
# typed: true
extend T::Sig
sig {params(name: String).returns(Integer)}
def main(name)
puts "Hello, #{name}!"
name.length
end
sig {params(name: String).returns(String)}
def greeting(name)
puts "Hello, #{name}!"
end
大規模アプリでの使用実績が多い
もう1つの理由が大規模アプリでの使用実績が多いということです。
Sorbetを公開しているStripeはもちろんのこと、ShopifyやCoinbaseが使用しています。国内の企業では、freeeさんやWantedlyさんが導入しているみたいです。これだけ大きなサービスで利用されているのであれば、周辺ツールも充実しやすいのではないかと考えています。
速度が圧倒的に速い
Sorbetの公式ページにも書かれている通り、型チェックがとても速いです。Sorbetの思想として型チェックの速さをかなり重視しているみたいですね。
具体的な速さに関してはWantedlyさんのblogを参考にさせていただいたのですが、Sorbetが1秒以下で型チェックが終わった一方で、Steepは20秒弱とかなり型チェックの速さに差があるようです。
Sorbetの導入方法
次に導入方法について書きます。
1:Gemのインストール
# -- Gemfile --
gem 'sorbet', :group => :development
gem 'sorbet-runtime'
# Install the gems
❯ bundle install
以下のようなコメントが出たら、インストール成功です。
2:初期化
次に以下のコマンドで初期化します。
bundle exec srb init
初期化すると、以下のようなメッセージが出てくるのでYを押してください。Sorbetでは、型の注釈にRBIファイルを使用しているのですが、初期化することによってこのRBIファイルが自動生成されます。
3:sorbet-railsのインストール
次にsorbet-railsというgemをインストールしましょう。sorbet-rails をインストールすることにより、routesや modelsなどのRailsによって生成されたファイルをRBIとして書き出してくれます。
# -- Gemfile --
gem 'sorbet-rails'
❯ bundle install
以下のコマンドでroutesやmodelsなどのRBIファイルを生成します。
❯ bundle exec rake rails_rbi:routes
❯ bundle exec rake rails_rbi:models
❯ bundle exec rake rails_rbi:helpers
❯ bundle exec rake rails_rbi:mailers
❯ bundle exec rake rails_rbi:jobs
❯ bundle exec rake rails_rbi:custom
# or run them all at once
❯ bundle exec rake rails_rbi:all
以下のコマンドで各ファイルの型チェックのレベルを自動的にアップグレードできます。一旦このコマンドで型チェックの強さをアップグレードしてしまって、後ほど必要に応じて型チェックの強さをカスタマイズすると良いのではないかと思います。
❯ bundle exec srb rbi suggest-typed
型チェックの強さは五段階あります。
各ファイルの1行目位に以下のように#typed:〜と書くことによって、どれくらいの強さで型チェックするかを指定できます。(https://sorbet.org/docs/static)
#typed: ignore
エラーを出さない
#typed: false
文法の間違いや定数が存在しているかなどをチェックする
#typed: true
一般的な型エラーがないかをチェックする
#typed: strict
全てのメソッドに型情報が付与されているかをチェックする
#typed: strong
ファイル内のメソッドの呼び出しに対して型定義がされていなければならない
4:型をチェック
次にSorbetで型チェックをしてみましょう。以下のコマンドで型チェックをすることができます。
# Type check the project
bundle exec srb tc
以下のようなメッセージが出れば、Sorbetの導入はうまくいっています。
Sorbetで型をつける方法
Sorbetで型をつけるには、以下のように記述します。
基本的には各メソッドの前に、sig {params(引数名: 型).returns(返り値の型)}のように書けばOKです。
# typed: true
class Main
extend T::Sig
# 引数xの型はStringで、返り値はInteger
sig {params(x: String).returns(Integer)}
def self.main(x)
x.length
end
# 引数がない場合はこのように書く
sig {returns(Integer)}
def no_args
1
end
# 返り値がない場合はこのように書く
sig {params(name: String).void}
def self.greet(name)
puts "Hello, #{name}!"
end
end
Integer
sig {returns(Integer)}
def age
25
end
sig {params(x: Float).returns(String)}
def float_to_string(x)
x.to_s
end
String
sig {params(x: Float).returns(String)}
def float_to_string(x)
x.to_s
end
T::Boolean
extend T::Sig
sig {params(new_value: T::Boolean).void}
def set_flag(new_value)
@flag = new_value
puts "Set value to #{new_value}"
end
T.nilable
値にnilが入る可能性がある場合、または特定の型の値が存在する必要がある場合に使います。
extend T::Sig
sig {params(x: T.nilable(String)).void}
def foo(x)
if x
puts "x was not nil! Got: #{x}"
else
puts "x was nil"
end
end
T.any
いずれかの型を表現するときに使います。
fooメソッドでは、Integer型かString型が引数として入ります。
class A
extend T::Sig
sig {params(x: T.any(Integer,String)).void}
def self.foo(x); end
end
# 10 and "Hello, world" both have type `T.any(Integer, String)`
A.foo(10)
A.foo("Hello, world")
# error: Expected `T.any(Integer, String)` but found `TrueClass`
A.foo(true)
[Type1, Type2]
固定長の配列の各要素の型を指定する時に使います。
extend T::Sig
sig {params(x: [Integer, String]).returns(Integer)}
def foo(x)
T.reveal_type(x[0]) # Revealed type: `Integer`
end
# --- What you expect ---
foo([0, '']) # ok
foo(['', 0]) # error: type mismatch
foo([]) # error: not right tuple type
T::Array[Type]
可変長の配列の各要素の型を指定する時に使います。
# 要素の型がInteger
sig {returns(T::Array[Integer])}
def returns_ints; [1, 2, 3]; end
# 要素の型がIntegerかString
sig {params(xs: T::Array[T.any(Integer, String)]).void}
def takes_ints_or_strings(xs); end
xs = returns_ints
takes_ints_or_strings(xs) # no error
{key1: Type1, key2: Type2}
固定長のhashの各keyに対して型をつけるときに使います。
extend T::Sig
sig {params(x: {a: Integer, b: String}).void}
def foo(x)
# Limitation! returns T.untyped!
T.reveal_type(x[:a]) # Revealed type: `T.untyped`
# Limitation! does not warn when key doesn't exist!
x[:c]
end
# --- What you expect ---
foo({a: 0, b: ''}) # ok
foo({a: '', b: 0}) # error: type mismatch
foo({}) # error: missing keys
T.untyped
どの型の値でも入れられます。TypeScriptでいうところのany型にあたる感じだと思います。(使い所はよくわかりません)
T.noreturn
T.noreturnはデッドコードの解析で威力を発揮します。具体的には、例外をraiseするような呼びした際に何も返さないメソッドをマークするために使います。
「何も値を返さないマークしたメソッドの次に何かしらの処理が書かれていたら、それはおかしいよ(デッドコード)」ということをunreachable code errorという形で教えてくれます。
# typed: true
extend T::Sig
sig {returns(T.noreturn)}
def loop_forever
loop {}
end
sig {returns(T.noreturn)}
def exit_program
exit
end
sig {returns(T.noreturn)}
def raise_always
raise RuntimeError
end
x = exit_program
puts x # error: This code is unreachable
T.let, T.cast, T.must, T.assert_type!
これらは変数や定数に型を付与する際に使います。
<T.let>
Sorbetの型チェックが走る時とプログラムが実行された時に型がチェックされます。
x = T.let(10, Integer)
T.reveal_type(x) # Revealed type: Integer
y = T.let(10, String) # error: Argument does not have asserted type String
<T.cast>
戻り値をcastする時に使います。
extend T::Sig
class A; def foo; end; end
class B; def bar; end; end
sig {params(label: String, a_or_b: T.any(A, B)).void}
def foo(label, a_or_b)
case label
when 'a'
T.cast(a_or_b, A).foo
when 'b'
T.cast(a_or_b, B).bar
end
end
<T.must>
T.mustはnilであってはいけないものに対して使います。T.must(xxx)と書くことによって、xxxがnilでないことを保証できます。
class A
extend T::Sig
sig {void}
def foo
x = T.let(nil, T.nilable(String))
y = T.must(nil)
puts y # error: This code is unreachable
end
sig {void}
def bar
vals = T.let([], T::Array[Integer])
x = vals.find {|a| a > 0}
T.reveal_type(x) # Revealed type: T.nilable(Integer)
y = T.must(x)
puts y # no static error
end
end
<T.assert_type!>
T.letに似ていますが、追加の制約があります。どんな制約かというと、T.untypedが与えられるとエラーとなります。
class A
extend T::Sig
sig {params(x: T.untyped).void}
def foo(x)
T.assert_type!(x, String) # error here
end
end
終わりに
本記事ではSorbetの特徴や使い方をメインに書きました。
実際にSorbetを使って得られた知見については、また別の記事で書ければなと思っています。
参考資料
この記事が気に入ったらサポートをしてみませんか?