Steepを使ってRubyの型検査に入門する

リンクアンドモチベーションでソフトウェアエンジニアをしているkoboriです。
何の記事を書こうか考えた時に、少し前に話題になった、Rubyで型検査ができる話を思い出しました。気になりつつも全然触れていなかったので、この機会に触ってみました。

# Steepとは
Steepとは、RBSを使ってRubyの型検査をしてくれるライブラリです。

https://github.com/soutaro/steep

RBSとは何かについても触れておきます。

> RBS is a language to describe the structure of Ruby programs.

https://github.com/ruby/rbs

RBSとは、Rubyのプログラムの構造を記述するための言語のようです。


つまりSteepを使った型検査とは、プログラムの構造をRBSで表現し、それをもとにSteepを使って検査していく、と説明できそうです。

# なぜ型があると嬉しいのか
一般に言われている静的型付け言語のメリットをざっと挙げてみます。

1. 実行速度が速い
2. コンパイル時にエラーを検知することができる

Steepはコンパイルして中間言語を吐き出してくれるライブラリではないため、1. の恩恵は受けることはできません。
一方、RBSで定義したプログラムの構造との差異を検知できるという点から、2.に近い恩恵を受けることができそうです。

# 触ってみる
それでは早速触っていきます。

今回の実行環境は、下記の通りです。
READMEによると、SteepはRuby2.6系以降をサポートしているようです。(2.6系はEOLを迎えています)
```markdown
Ruby: 3.1.2
Steep: 1.1.1
```

最終的なディレクトリ構成はこんな感じになりました。

![29f0d5cc-0f34-7f73-2f80-95df2853630e.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/243837/cce5d736-e0bf-f097-a51a-5e287071f409.png)

## 準備

まずは、Steepをインストールします。
```bash
$ gem install steep
```

インストールが完了したら、下記のコマンドでSteepの定義ファイルを生成します。
```bash
$ steep init
```

コマンドを実行すると、 `Steepfile` というファイルが生成されました。
Githubに記載されているサンプルを参考に、下記のように Steepfileを修正します。

```ruby
target :app do
  check "src" # 型検査をしたいrubyファイルが格納されているディレクトリ名
  signature "sig" # 型定義を記述するRBSファイルが格納されているディレクトリ名

  # 取り込みたいライブラリ
  library "set", "pathname"
end
```

次に、プログラムの構造を定義するRBSファイルを作成します。 signaturesからとって `sig` というディレクトリを切るのが慣習のようです。
記述自体はGithubのサンプルを参考に簡素なものにしました。RBSの記法を知らなくても、なんとなく雰囲気は読み取れるかなと思います。
```sig/sample.rbs
class Person
  @name: String
  @gender: String

  def initialize: (name: String, gender: String) -> untyped
end
```

次に、検査対象のRubyのコードを用意します。
`src` ディレクトリを作成し、その配下にClassを作成しました。
```src/person.rb
class Person
  attr_reader :name
  attr_reader :gender

  def initialize(name:, gender:)
    @name = name
    @gender = gender
  end
end
```

## 初めての実行
ここまでで準備が整ったので、検査実行してみます。
```bash
$ steep check
```

実行後、の下記結果が出力されました。
特に問題は検出されていません。
```
# Type checking files:

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

No type error detected. 🫖
```

## 型検査に失敗するとどのような警告を出してくれるのか

### インスタンス変数の定義漏れの検査
型検査に失敗した場合に、どのような警告が表示されるのかを確認するため、まずはRBSに定義されていないインスタンス変数が記述されている場合の警告を見てみます。
先ほど書いたRBSファイルを下記のように修正します。
```sig/sample.rbs
class Person
  @name: String
  # @gender: String コメントアウト

  def initialize: (name: String, gender: String) -> untyped
end
```

検査を実行してみると、無事エラーが吐かれました。
「知らんインスタンス変数 `@gender` が記述されている」と教えてくれています。
```
# Type checking files:

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

src/Person.rb:7:4: [error] Cannot find the declaration of instance variable: `@gender`
│ Diagnostic ID: Ruby::UnknownInstanceVariable
│
└     @gender = gender
      ~~~~~~~

Detected 1 problem from 1 file
```

### メソッドの定義漏れの検査
次に、RBSに定義されていないメソッドが記述されていた場合の警告を確認します。
Personクラスに下記のようにメソッドを追加してみます。
```src/person.rb
class Person
  attr_reader :name
  attr_reader :gender

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

  # 追加
  def call_gender
    "#{gender}です"
  end
end
```

検査を実行。「知らんメソッド `call_gender` が記述されている」という警告を期待していましたが、検査は通ってしまいました。
```
# Type checking files:

.............................[Steep 1.1.1] [typecheck:typecheck@1] [background] [#typecheck_source(path=src/sample.rb)] [#type_check_file(src/sample.rb@app)] src/sample.rb is empty source code
.........................................

No type error detected. 🫖
```

調べた結果、Steepの検査は メソッドを呼び出す記述を検査の対象としているようでした。`call_gender`メソッドはどこからも呼ばれていないために、型検査は問題なく通っていたようです。
落ち着いて考えてみると、動的型付け言語なので、検査のためにメソッドがどう呼ばれているかが必要になるのは当たり前の話でした。

新たにファイルを作成し、 `Person#call_gender` を呼び出す記述を追記しました。
```src/sample.rb
Person.new(name: "hoge", gender: "man").call_gender
```

再び検査を実行してみると、期待通り 「知らんメソッド `call_gender` が記述されている」という警告がちゃんと表示されました。

```
# Type checking files:

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

src/sample.rb:1:40: [error] Type `::Person` does not have method `call_gender`
│ Diagnostic ID: Ruby::NoMethod
│
└ Person.new(name: "hoge", gender: "man").call_gender
                                          ~~~~~~~~~~~

Detected 1 problem from 1 file
```

### メソッドの戻り値の検証
最後に、メソッドの戻り値がRBSの定義と異なる場合の警告を見てみます。
RBSファイルの、`call_gender` メソッドの戻り値をIntegerに変更します。
```sig/sample.rbs
class Person
  @name: String
  @gender: String

  def initialize: (name: String, gender: String) -> untyped
  def name: -> String
  def gender: -> String
  def call_gender: -> Integer # 変更
end
```

検査を実行します。
期待通り「call_genderから知らん型の戻り値が返ってきている」と警告してくれました。
```
# Type checking files:

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

src/person.rb:10:6: [error] Cannot allow method body have type `::String` because declared as type `::Integer`
│   ::String <: ::Integer
│     ::Object <: ::Integer
│       ::BasicObject <: ::Integer
│
│ Diagnostic ID: Ruby::MethodBodyTypeMismatch
│
└   def call_gender
        ~~~~~~~~~~~

Detected 1 problem from 1 file
```

一応最後に正常系も確認するため、RBSファイルの戻り値をStringに戻します。
```sig/sample.rbs
class Person
  @name: String
  @gender: String

  def initialize: (name: String, gender: String) -> untyped
  def name: -> String
  def gender: -> String
  def call_gender: -> String # 修正
end
```

検査を実行すると無事通りました。
```
# Type checking files:

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

No type error detected. 🫖
```

# さいごに
入門ということで、Steepの基本的な機能に触れてみました。普段とは違うRubyに触れられた気持ちになり、とても楽しかったです。
現状の理解度でプロダクトに導入というのは厳しそうですが、Rubyに型があれば、と思うことは多々あるので、上手く活用する方法は考えてみたいと思いました。
まだまだ触れられていない機能は沢山ありそうなので、また触ってみます。