見出し画像

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


大規模アプリでの使用実績が多い

参照:https://sorbet.org/

もう1つの理由が大規模アプリでの使用実績が多いということです。
Sorbetを公開しているStripeはもちろんのこと、ShopifyやCoinbaseが使用しています。国内の企業では、freeeさんやWantedlyさんが導入しているみたいです。これだけ大きなサービスで利用されているのであれば、周辺ツールも充実しやすいのではないかと考えています。


速度が圧倒的に速い

参照:https://sorbet.org/

Sorbetの公式ページにも書かれている通り、型チェックがとても速いです。Sorbetの思想として型チェックの速さをかなり重視しているみたいですね。

具体的な速さに関してはWantedlyさんのblogを参考にさせていただいたのですが、Sorbetが1秒以下で型チェックが終わった一方で、Steepは20秒弱とかなり型チェックの速さに差があるようです。


Sorbetの導入方法

次に導入方法について書きます。

1:Gemのインストール

# -- Gemfile -- 
gem 'sorbet', :group => :development 
gem 'sorbet-runtime'
# Install the gems
❯ bundle install

以下のようなコメントが出たら、インストール成功です。

Thanks for installing Sorbet! To use it in your project, first run:

bundle exec srb init

which will get your project ready to use with Sorbet.
After that whenever you want to typecheck your code, run:

bundle exec srb tc

For more docs see: https://sorbet.org/docs/adopting

2:初期化

次に以下のコマンドで初期化します。

bundle exec srb init


初期化すると、以下のようなメッセージが出てくるのでYを押してください。Sorbetでは、型の注釈にRBIファイルを使用しているのですが、初期化することによってこのRBIファイルが自動生成されます。


To set up your project, this script will take two potentially destructive
actions:

1. It will require every file in your project. Specifically, every script in
your project will be run, unless that script checks if __FILE__ == $PROGRAM_NAME
before running any code, or has the magic comment # typed: ignore in it.

2. It will add a comment to the top of every file (like # typed: false or
# typed: true, depending on how many errors were found in that file.)

❔ Would you like to continue? [Y/n]

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の導入はうまくいっています。

No errors! Great job.

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を使って得られた知見については、また別の記事で書ければなと思っています。


参考資料


この記事が気に入ったらサポートをしてみませんか?