はじめに
先日12月8日にRuby 3.0.0-preview2がリリースされました。
https://www.ruby-lang.org/ja/news/2020/12/08/ruby-3-0-0-preview2-released/
その中に「静的解析」の文字が!この記事では早速Rubyの静的解析に関わる「RBS」と「TypeProf」と「Steep」を使ってみます。
詳しい理論や内部実装には触れず、どうやって使うのかについて記載します。
Ruby 3.0.0-preview2を使う
rbenvの更新
インストールしていない方はインストールをしてください。
$ brew upgrade rbenv ruby-build
3.0.0-preview2のインストール
※MacOSでの失敗を確認しています。issue解決待ち。
$ rbenv install 3.0.0-preview2
# 3.0.0-preview1で妥協
$ rbenv install 3.0.0-preview1
$ rbenv local 3.0.0-preview1
作業後のテストプロジェクトの構成
root/
- lib/
- user.rb
- sig/
- user.rbs
- .ruby-version
- main.rb
- Gemfile
- Gemfile.lock
- Steepfile
RBS, TypeProf, Steepの追加
ついでにTypeProfと型チェックに使用するSteepをインストールします。
# Gemfileの雛形作成
bundle init
# frozen_string_literal: true
source "https://rubygems.org"
git_source(:github) {|repo_name| "https://github.com/#{repo_name}" }
# gem "rails"
gem "rbs"
gem "typeprof"
gem "steep"
# rbsとtypeprofとsteepのインストール
bundle install
Userクラスの追加
class User
def initialize(name:, age:)
@name, @age = name, age
end
attr_reader :name, :age
end
mainの追加
require "./lib/user"
def main
user = User.new(name: "John", age: 20)
puts user.name
end
if __FILE__ == $0
main
end
動作確認
$ ruby main.rb
John
RBS
RBSはRubyプログラムの型を記述するための言語です。
TypeProfなどの型検査ツールを初めとする静的解析を行うツールは、RBSを利用することでRubyプログラムをより精度良く解析することができます。
RBSでは、Rubyプログラムのクラスやモジュールの型を定義します。メソッドやインスタンス変数、定数とその型、継承やmixinなどの関係などが記述できます。
RBSはRubyプログラムに頻出するパターンをサポートするように設計されており、ユニオン型、メソッドオーバーロード、ジェネリクスなどの機能を提供します。さらに「インタフェース型」によってダックタイピングをサポートします。
Ruby 3.0には、このRBS言語で書かれた型定義を処理するためのライブラリである rbs gemが同梱されています。
つまり、超簡単にまとめると
- RBSでRubyプログラムの型を定義する。
- プログラムの構造も定義できる。
- Ruby3.0からRBSが同梱される。
RBSを使ってみる
型定義ファイル(.rbs)を書いていく
class User
attr_reader name : String
attr_reader age : Integer
def initialize : (name: String, age: Integer) -> nil
end
main.rbの型定義もします。
class Object
private
def main: -> nil
end
型情報が書けたのでsteepでチェックをします。
Steepの設定
$ bundle exec steep init
# target :lib do
# signature "sig"
#
# check "lib" # Directory name
# check "Gemfile" # File name
# check "app/models/**/*.rb" # Glob
# # ignore "lib/templates/*.rb"
#
# # library "pathname", "set" # Standard libraries
# # library "strong_json" # Gems
# end
# target :spec do
# signature "sig", "sig-private"
#
# check "spec"
#
# # library "pathname", "set" # Standard libraries
# # library "rspec"
# end
target :lib do
signature "sig"
check "lib"
check "main.rb"
end
型情報はsig
ディレクトリに、チェック対象はlib
ディレクトリとmain.rb
と指定しました。
Steepでチェック
$ bundle exec steep check
何も表示されずに終了したら問題がないということです。
試しに型情報と違う実装に変更する
require "./lib/user"
def main
user = User.new(name: "John", age: "20") # ageをStringに変更
puts user.name
end
if __FILE__ == $0
main
end
$ bundle exec steep check
main.rb:4:37: IncompatibleAssignment: lhs_type=::Integer, rhs_type=::String ("20")
::String <: ::Integer
::Object <: ::Integer
::BasicObject <: ::Integer
==> ::BasicObject <: ::Integer does not hold
zsh: exit 1 bundle exec steep check
実装箇所と内容が表示されています。
型情報とチェックについて簡単に実装しました。
次は型情報の記載を楽にしてくれるTypeProfを使用します。
TypeProf
TypeProf は Ruby パッケージに同梱された型解析ツールです。
TypeProf の現在の主な用途は一種の型推論です。
型注釈の無い普通の Ruby コードを入力し、どんなメソッドが定義されどのように使われているかを解析し、型シグネチャのプロトタイプを RBS フォーマットで生成します。
インストールは終わっているので早速使用します。
TypeProfを使ってみる
$ bundle exec typeprof lib/user.rb
# Classes
class User
attr_reader name: untyped
attr_reader age: untyped
def initialize: (name: untyped, age: untyped) -> [untyped, untyped]
end
このように実装からある程度、RBSフォーマットで自動で生成してくれます。ただname
やage
にどの型が渡されるかがUserクラスの定義だけではわからないためuntyped
となっています。
では、次に実際にUserクラスを使用しているmain.rb
に対してtypeprofを実行してみます。
$ bundle exec typeprof main.rb
# Classes
class Object
private
def main: -> nil
end
class User
attr_reader name: String
attr_reader age: Integer
def initialize: (name: String, age: Integer) -> [String, Integer]
end
main.rb
では実際にname
に"John"
、age
に20
を指定しているためUserクラスの型情報が正確になりました。ただ、initialize
の返り値が[String, Integer]
になっています。実際、期待する返り値はnil
ですので、実装を修正します。
TypeProfの出力から実装の改善
class User
def initialize(name:, age:)
@name, @age = name, age
return # returnを記載することで返り値が無いことを明示する
end
attr_reader :name, :age
end
typeprofを再実行してみます。
$ bundle exec typeprof main.rb
# Classes
class Object
private
def main: -> nil
end
class User
attr_reader name: String
attr_reader age: Integer
def initialize: (name: String, age: Integer) -> nil
end
initialize
の返り値がnil
になりました!
流れまとめ
- 実装
- TypeProfでRBSを生成
- Steepでチェック
▼ 今回の内容のリポジトリ
https://github.com/naro143/ruby-static-program-analysis-trial
まとめ
Rubyに静的解析の機能が追加されるのは嬉しいです。RBSの記載は慣れないですがTypeProfを上手く使用すればある程度は楽ができそうです。また、TypeProfが正しく生成できるように実装がより意図を明示するようになるのも嬉しいです。
個人的には、「存在しないメソッドを叩いてnilが返っている」ような挙動は問題ないが意図とはずれている実装や、定義ジャンプの際に候補が複数個も提案されるといったことも静的解析によって改善されると嬉しいです。