ruby3.0の型チェックを今更だけど試してみる

2021-10-22

今仕事で書いている Rails アプリケーションで「いやぁ、これ型指定させて欲しいなぁ」と思う事が多々あり、後回しにしていた Ruby 3.0 から導入された型チェックを本格的に試してみる事にしました。

詳しい個々の説明は他のサイト様におまかせして、自分はひとまず実際に動かして型チェックするまでのシンプル目な説明で記事を書いてみたいと思います。

Ruby の型チェックアプローチについて

Ruby の型チェックは、コンパイラ言語によくあるようにビルトインされるでもなく、TypeScript のようにトランスパイルをする方法でもありません。

あくまでも rb ファイルの中身を変えず(基本的にそのまま動く状態。書き加える事もあるけど)、その中身を元に型情報である rbs(Ruby Signature の略)を生成し、それを元に型チェックを行うというアプローチのようです。

中身を元に型情報というのも 2 段階あり、たとえば

class Foo
  def initialize(attribute)
    @attribute = attribute
  end
end

というコードがあったとして、この class の情報だけだと型情報が分かりません。これを実際に使うコードを使って無理やり型情報を解析します。

Foo.new(123)

上のコードから、「initialize の引数は Integer」という具合にコードを解析して型情報のファイルを作るという具合です。

そしてそれらの情報を元に型チェックを行ってくれるツールがあります。

つまり、必要なのは

  • rbs ruby の型に関するコア機能。ruby のシンタックスと違うシンタックスの rbs 言語を提供します
  • typeprof コードを元に型情報を解析するツール
  • steep それらの情報を元に検査をしてくれるライブラリ

3 つの要素になります。

とはいえ、これは当然完璧に動作するのは難しく、ある程度生成してから自分で修正していくという形が現実的でしょうか。

実際に rbs で解析してみる

実際に rbs ファイルを作ってみたいと思います。

以下の rb ファイルを rbs コマンドで生成してみます。一応、rbs ファイルは sig ディレクトリに配置するのが通例っぽいんで、sig ディレクトリに出力しています。

class Pokemon
  # @dynamic name
  attr_reader :name

  def initialize(name:, hp:)
    @name = name
    @hp = hp
  end

  def damage_taken(damage)
    @hp -= damage
  end
end

pokemon = Pokemon.new(name: "ヒトカゲ", hp: 123)
pokemon.damage_taken(100)
puts pokemon.name

rbs コマンドはrbs prototype rbrbs prototype runtimeの 2 つがあり、rb コマンドの方はファイルを静的に解析し、runtime の方は実際に ruby を実行した環境下で解析するようになっています。

ちなみに attr_reader や define_method など、def で宣言していないメソッドは# @dynamic foobarという感じにコメントで教えてあげる事ができます(というか、教えてあげないと後ででてくる検査で「なにこれ?」と言われる感じです 😅)。

$ rbs prototype rb app/pokemon.rb > sig/pokemon.rbs

すると、sig/pokemon.rbs というファイルが生成されました 👏

class Pokemon
  # @dynamic name
  attr_reader name: untyped

  def initialize: (name: untyped name, hp: untyped hp) -> void

  def damage_taken: (untyped damage) -> untyped
end

コードの中ではとくに型に触れていないので、色々な所が untyped になっていますねー 🤔

次は、この rb ファイルを今後は typeprof を使って解析してみたいと思います!

$ typeprof app/pokemon.rb -o sig/pokemon.rbs

するとこんな感じのファイルになりました。

# TypeProf 0.20.0

# Classes
class Pokemon
  @hp: Integer

  attr_reader name: String
  def initialize: (name: String, hp: Integer) -> void
  def damage_taken: (Integer damage) -> Integer
end

一気に情報が増えましたね 👏

シンプルな内容だったらこんな感じでうまく解析してくれますね。でもこれ Rails 規模だったらうまくいくのかが気になる所。

ひとまず今回は環境を作る所までやってみます!

上の rbs ファイルを元に検査をしてくれる steep という gem をインストールします。

$ gem i steep

そして、設定ファイルを生成します。

$ steep init

すると、Steepfile が作られるので、中身を編集します。最初に色々書いてあったりするので、コメントアウトしたりでバックアップしておき、今回書いた分だけを検査するシンプルな内容に変更します。

target :app do
  check "app"
  signature "sig"
end

check に調査対象のディレクトリ(またはファイル)を書き、signature の方に型定義である rbs ファイルがあるディレクトリを指定します。

この状態で型検査を行ってみます。

$ steep check
# Type checking files:

.............................................................

No type error detected. 🧉

無事検査できたっぽいです 👏

ちょっと型が間違っているパターンを見てみます。

class Pokemon
  # @dynamic name
  attr_reader :name

  def initialize(name:, hp:)
    @name = name
    @hp = hp
  end

  def damage_taken(damage)
    @hp -= damage
  end
end

pokemon = Pokemon.new(name: "ヒトカゲ", hp: 123)
pokemon.damage_taken("foobar")
puts pokemon.name

damage_taken は Integer という定義になっていたので、引数に”foobar”という間違った型を入れてみます。

$ steep check
# Type checking files:

............................................................F

app/pokemon.rb:16:21: [error] Cannot pass a value of type `::String` as an argument of type `::Integer`
   ::String <: ::Integer
     ::Object <: ::Integer
       ::BasicObject <: ::Integer

 Diagnostic ID: Ruby::ArgumentTypeMismatch

 pokemon.damage_taken("foobar")

Detected 1 problem from 1 file

お、ちゃんと引っかかってくれました!

この型チェックをエディター(VS Code)で行って欲しいので拡張を入れます。

Steep - Visual Studio Marketplace

このプラグインを入れた状態で VS Code を起動すると、

ちゃんと教えてくれます 🎉


今回は rbs・typeprof・steep を使ってみた感じです。自分が公開している gem とかに追加できそうかなぁ。

あとやっぱり rails に本格的に導入した時にどうなるか気になるところですが、それは別の記事で書きたいと思います 😄

それでは 🤟