リンクアンドモチベーションでソフトウェアエンジニアをしているkoboriです。
何の記事を書こうか考えた時に、少し前に話題になった、Rubyで型検査ができる話を思い出しました。気になりつつも全然触れていなかったので、この機会に触ってみました。
Steepとは
Steepとは、RBSを使ってRubyの型検査をしてくれるライブラリです。
RBSとは何かについても触れておきます。
RBS is a language to describe the structure of Ruby programs.
RBSとは、Rubyのプログラムの構造を記述するための言語のようです。
つまりSteepを使った型検査とは、プログラムの構造をRBSで表現し、それをもとにSteepを使って検査していく、と説明できそうです。
なぜ型があると嬉しいのか
一般に言われている静的型付け言語のメリットをざっと挙げてみます。
- 実行速度が速い
- コンパイル時にエラーを検知することができる
Steepはコンパイルして中間言語を吐き出してくれるライブラリではないため、1. の恩恵は受けることはできません。
一方、RBSで定義したプログラムの構造との差異を検知できるという点から、2.に近い恩恵を受けることができそうです。
触ってみる
それでは早速触っていきます。
今回の実行環境は、下記の通りです。
READMEによると、SteepはRuby2.6系以降をサポートしているようです。(2.6系はEOLを迎えています)
Ruby: 3.1.2
Steep: 1.1.1
最終的なディレクトリ構成はこんな感じになりました。
準備
まずは、Steepをインストールします。
$ gem install steep
インストールが完了したら、下記のコマンドでSteepの定義ファイルを生成します。
$ steep init
コマンドを実行すると、 Steepfile
というファイルが生成されました。
Githubに記載されているサンプルを参考に、下記のように Steepfileを修正します。
target :app do
check "src" # 型検査をしたいrubyファイルが格納されているディレクトリ名
signature "sig" # 型定義を記述するRBSファイルが格納されているディレクトリ名
# 取り込みたいライブラリ
library "set", "pathname"
end
次に、プログラムの構造を定義するRBSファイルを作成します。 signaturesからとって sig
というディレクトリを切るのが慣習のようです。
記述自体はGithubのサンプルを参考に簡素なものにしました。RBSの記法を知らなくても、なんとなく雰囲気は読み取れるかなと思います。
class Person
@name: String
@gender: String
def initialize: (name: String, gender: String) -> untyped
end
次に、検査対象のRubyのコードを用意します。
src
ディレクトリを作成し、その配下にClassを作成しました。
class Person
attr_reader :name
attr_reader :gender
def initialize(name:, gender:)
@name = name
@gender = gender
end
end
初めての実行
ここまでで準備が整ったので、検査実行してみます。
$ steep check
実行後、の下記結果が出力されました。
特に問題は検出されていません。
# Type checking files:
......................................................................
No type error detected. 🫖
型検査に失敗するとどのような警告を出してくれるのか
インスタンス変数の定義漏れの検査
型検査に失敗した場合に、どのような警告が表示されるのかを確認するため、まずはRBSに定義されていないインスタンス変数が記述されている場合の警告を見てみます。
先ほど書いた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クラスに下記のようにメソッドを追加してみます。
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
を呼び出す記述を追記しました。
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に変更します。
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に戻します。
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に型があれば、と思うことは多々あるので、上手く活用する方法は考えてみたいと思いました。
まだまだ触れられていない機能は沢山ありそうなので、また触ってみます。