作って学ぶRuby3 静的型解析・RBS入門
Ruby 3 の静的型解析について
私が業務で Ruby を書いていた時期は数年前で最近の Ruby についてキャットアップが遅れていました。
最近は Go・TypeScript など型を書くことがほとんどなので Ruby 3 系で導入された型解析について気になっていました。
Ruby って型が無いよねと肩身が狭い生活をしているので(型だけに)、Ruby でも出来らあっ!というところを見せたいと考え書いてみることにしました。
作るもの
HackerNews API を利用して HackerNews の API Client を作ります。要件は以下です。
-
gem として実装する
これはなんやかんやで私が gem を書いたことがないので勉強の一環です。特に gem で書く必要はありません -
rake タスクを実行すると Hacker News のトップページに相当する情報(タイトル, points, author, 日付)が出力される。ページネーションは実装しない
-
全ファイルに型情報をつける
型情報というのは RBS ファイルのことです -
静的解析ツールは Steep を利用する
作ったもの
ネタバレですが完成系のソースはこちらです。
gem の作成
Ruby は 3.2.1
を利用します。
$ ruby --version
ruby 3.2.1 (2023-02-08 revision 31819e82c8) [arm64-darwin22]
$ bundle gem hackernews_api_client
デフォルトで sig
ディレクトリが生成されています
$ tree
.
├── CHANGELOG.md
├── CODE_OF_CONDUCT.md
├── Gemfile
├── LICENSE.txt
├── README.md
├── Rakefile
├── bin
│ ├── console
│ └── setup
├── hackernews_api_client.gemspec
├── lib
│ ├── hackernews_api_client
│ │ └── version.rb
│ └── hackernews_api_client.rb
├── sig
│ └── hackernews_api_client.rbs
└── spec
├── hackernews_api_client_spec.rb
└── spec_helper.rb
5 directories, 14 files
型定義をまとめる Steepfile を生成します。
$ bundle add steep
$ bundle exec steep init
Writing Steepfile...
$ ls | grep Steepfile
Steepfile
Steepfile が生成されるので、ソースコードの lib/
と Gemfile
を対象にします。
# D = Steep::Diagnostic
#
target :lib do
signature "sig"
check "lib" # Directory name
check "Gemfile" # File name
# ignore "lib/templates/*.rb"
# library "pathname", "set" # Standard libraries
# library "strong_json" # Gems
# configure_code_diagnostics(D::Ruby.strict) # `strict` diagnostics setting
# configure_code_diagnostics(D::Ruby.lenient) # `lenient` diagnostics setting
# configure_code_diagnostics do |hash| # You can setup everything yourself
# hash[D::Ruby::NoMethod] = :information
# end
end
# target :test do
# signature "sig", "sig-private"
#
# check "test"
#
# # library "pathname", "set" # Standard libraries
# end
触れてみる
デフォルトで生成された rbs ファイルには HackernewsApiClient::VERSION
の型が定義されています。これを Integer に変更してみます。
module HackernewsApiClient
VERSION: String
# See the writing guide of rbs: https://github.com/ruby/rbs#guides
end
$ bundle exec steep check
# Type checking files:
.......................................................F..........................F
lib/hackernews_api_client.rb:6:8: [warning] Cannot find the declaration of class: `Error`
│ Diagnostic ID: Ruby::UnknownConstant
│
└ class Error < StandardError; end
~~~~~
lib/hackernews_api_client/version.rb:4:2: [error] Cannot assign a value of type `::Integer` to a constant of type `::String`
│ ::Integer <: ::String
│ ::Numeric <: ::String
│ ::Object <: ::String
│ ::BasicObject <: ::String
│
│ Diagnostic ID: Ruby::IncompatibleAssignment
│
└ VERSION = 1
~~~~~~~~~~~
Detected 2 problems from 2 files
定義されていない Error と、VERSION が Integer であるエラーが出力されました。
VSCode との連携
steep には VSCode の拡張があり、steep-vscode を導入することでエラーからさらに型補完までできるようになります。
デフォルトパッケージの定義
次に Logger を実装したいと思います。実装内容は以下の通りです。
require "logger"
module HackernewsApiClient
class Logger < ::Logger
def self.default
return @default if @default
logger = new STDOUT
logger.level = Logger::WARN
@default = logger
end
end
end
が、このコードだと標準パッケージの Logger
Class を解析できず、UnknownConstant
とエラーになります。
$ bundle exec steep check
# Type checking files:
..........................................F.........................................
lib/hackernews_api_client/logger.rb:4:8: [warning] Cannot find the declaration of class: `Logger`
│ Diagnostic ID: Ruby::UnknownConstant
│
└ class Logger < ::Logger
~~~~~~
lib/hackernews_api_client/logger.rb:4:19: [warning] Cannot find the declaration of class: `Logger`
│ Diagnostic ID: Ruby::UnknownConstant
│
└ class Logger < ::Logger
~~~~~~
lib/hackernews_api_client/logger.rb:8:19: [error] Unexpected positional argument
│ Diagnostic ID: Ruby::UnexpectedPositionalArgument
│
└ logger = new STDOUT
~~~~~~
lib/hackernews_api_client/logger.rb:9:21: [warning] Cannot find the declaration of constant: `Logger`
│ Diagnostic ID: Ruby::UnknownConstant
│
└ logger.level = Logger::WARN
~~~~~~
lib/hackernews_api_client/logger.rb:9:13: [error] Type `::Object` does not have method `level=`
│ Diagnostic ID: Ruby::NoMethod
│
└ logger.level = Logger::WARN
~~~~~
lib/hackernews_api_client/logger.rb:10:6: [error] Cannot find the declaration of instance variable: `@default`
│ Diagnostic ID: Ruby::UnknownInstanceVariable
│
└ @default = logger
~~~~~~~~
Detected 6 problems from 1 file
Ruby の標準パッケージを Steep に理解させるには Steepfile の library
に追加をします。
diff --git a/Steepfile b/Steepfile
index 2ff289b..e5d1548 100644
--- a/Steepfile
+++ b/Steepfile
@@ -4,6 +4,7 @@ target :lib do
signature "sig"
check "lib" # Directory name
+ library "logger"
# check "Gemfile" # File name
# ignore "lib/templates/*.rb"
追加することでライブラリを理解したことが確認できます。
$ bundle exec steep check
# Type checking files:
........................................................F.................................
lib/hackernews_api_client/logger.rb:10:6: [error] Cannot find the declaration of instance variable: `@default`
│ Diagnostic ID: Ruby::UnknownInstanceVariable
│
└ @default = logger
~~~~~~~~
Detected 1 problem from 1 file
RBS ファイルの実装
続いて logger.rb
の RBS ファイルを実装します。ここで 1 から実装しても良いのですが、元となるコードがある場合は TypeProf
を利用して RBS ファイルを生成できます。 TypeProf
というのは Ruby のソースコードを解析し、型定義ファイルを生成してくれるツールのことです。
$ typeprof lib/hackernews_api_client/logger.rb
# TypeProf 0.21.3
# Classes
module HackernewsApiClient
class Logger < Logger
self.@default: Logger
def self.default: -> Logger?
end
end
-o
でファイルに出力ができます。
$ typeprof lib/hackernews_api_client/logger.rb -o sig/logger.rbs
が、完璧ではなくこのままだとエラーになります。
$ bundle exec steep check
# Type checking files:
........................................F.................................................F
lib/hackernews_api_client/logger.rb:4:2: [error] UnexpectedError: sig/logger.rbs:5:2...9:5: Detected recursive ancestors: ::HackernewsApiClient::Logger < ::HackernewsApiClient::Logger
│ Diagnostic ID: Ruby::UnexpectedError
│
└ class Logger < ::Logger
~~~~~~~~~~~~~~~~~~~~~~~
sig/logger.rbs:5:2: [error] Circular inheritance/mix-in is detected: ::HackernewsApiClient::Logger <: ::HackernewsApiClient::Logger
│ Diagnostic ID: RBS::RecursiveAncestor
│
└ class Logger < Logger
~~~~~~~~~~~~~~~~~~~~~
Detected 2 problems from 2 files
rbs ファイルを見るとわかるのですが、Logger
Class の指定が絶対指定ではないのが原因です。絶対指定に直すとエラーが修正されました。
$ bundle exec steep check
# Type checking files:
...........................................................................................
No type error detected. 🫖
外部パッケージの利用
HTTP Client として Faraday を利用したいので gemspec を更新します。
$ git diff
diff --git a/hackernews_api_client.gemspec b/hackernews_api_client.gemspec
index 3069b32..41f9c90 100644
--- a/hackernews_api_client.gemspec
+++ b/hackernews_api_client.gemspec
@@ -29,7 +29,7 @@ Gem::Specification.new do |spec|
spec.require_paths = ["lib"]
# Uncomment to register a new dependency of your gem
- # spec.add_dependency "example-gem", "~> 1.0"
+ spec.add_dependency "faraday", "~> 2.7"
# For more information and examples about making a new gem, check out our
# guide at: https://bundler.io/guides/creating_gem.html
Faraday の connection を定義します。
# frozen_string_literal: true
module HackernewsApiClient
module Faraday
module Connection
private
def connection
@connection ||=
begin
options = {
headers: {
'Accept' => 'application/json; charset=utf-8',
'User-Agent' => "HackerNews Ruby Client/#{HackernewsApiClient::VERSION}"
}
}
url = "https://hacker-news.firebaseio.com"
::Faraday.new(url, options) do |connection|
connection.request :multipart
connection.request :url_encoded
connection.response :json, content_type: /\b*$/
connection.response :logger, HackernewsApiClient::Logger.default
connection.adapter ::Faraday.default_adapter
end
end
end
end
end
end
先ほどと同様に TypeProf を利用して RBS ファイルを出力してみます。
$ typeprof lib/hackernews_api_client/faraday/connection.rb
# TypeProf 0.21.3
# Classes
module HackernewsApiClient
module Faraday
module Connection
@connection: untyped
private
def connection: -> untyped
end
end
end
@connection
が untyped となってしまいます。これも当然で Steepfile にライブラリを定義していないため、解析できず untyped になります。
標準パッケージ以外の型情報は gem_rbs_collection
というリポジトリにまとめられています。
Ruby 3.1 以前では型検査のためローカルに取得する必要がありましたが、3.1 からは rbs collection
コマンドが実装されました。
このコマンドは Gemfile から依存する Gem を解析し、steep
やTypeProf
を利用した際の依存性も解決されるようになります。
$ bundle exec rbs collection init
created: rbs_collection.yaml
teitei.tk >> !(main) ~/repos/src/github.com/teitei-tk/hackernews_api_client
$ cat rbs_collection.yaml
# Download sources
sources:
- name: ruby/gem_rbs_collection
remote: https://github.com/ruby/gem_rbs_collection.git
revision: main
repo_dir: gems
# A directory to install the downloaded RBSs
path: .gem_rbs_collection
gems:
# Skip loading rbs gem's RBS.
# It's unnecessary if you don't use rbs as a library.
- name: rbs
ignore: true
rbs_collection.yaml
というファイルが作成されます。これは Gem でいうところの Gemfile になります。
前述の通り Gemfile を読んで依存関係を見ています。今回は gem として実装しており、そのままでは rbs の定義が二重になってしまいエラーになります。さらに steep gem も解析するようになりエラーとなるので、ignore を追加し、解析対象から外す様にします。
# Download sources
sources:
- name: ruby/gem_rbs_collection
remote: https://github.com/ruby/gem_rbs_collection.git
revision: main
repo_dir: gems
# A directory to install the downloaded RBSs
path: .gem_rbs_collection
gems:
# Skip loading rbs gem's RBS.
# It's unnecessary if you don't use rbs as a library.
- name: rbs
ignore: true
- name: steep
ignore: true
- name: hackernews_api_client
ignore: true
- name: logger
- name: faraday
Steepfile に collection_config
を追加し、 rbs_collection.yaml
へのパスを定義します。前述に追加した library
も削除し、rbs_collection.yaml
に型定義を統一します。
# D = Steep::Diagnostic
#
target :lib do
signature "sig"
check "lib" # Directory name
collection_config "rbs_collection.yaml"
# check "Gemfile" # File name
# ignore "lib/templates/*.rb"
# library "pathname", "set" # Standard libraries
# library "strong_json" # Gems
# configure_code_diagnostics(D::Ruby.strict) # `strict` diagnostics setting
# configure_code_diagnostics(D::Ruby.lenient) # `lenient` diagnostics setting
# configure_code_diagnostics do |hash| # You can setup everything yourself
# hash[D::Ruby::NoMethod] = :information
# end
end
# target :test do
# signature "sig", "sig-private"
#
# check "test"
#
# # library "pathname", "set" # Standard libraries
# end
最後に、install コマンドを実行することでローカル環境に型情報が取得されます。
$ bundle exec rbs collection install
install 後、typeprof コマンドを実行すると型が認識されていることがわかります。
$ typeprof lib/hackernews_api_client/faraday/connection.rb
# TypeProf 0.21.3
# Classes
module HackernewsApiClient
module Faraday
module Connection
@connection: Faraday::Connection
private
def connection: -> Faraday::Connection
end
end
end
あとはゴリゴリ開発をするだけです。そして出来上がったものがこちらです
所管
ネガティブな点
登場人物が多い
Ruby の静的解析ツール周りだけでも 5 つはあるのでパッと理解するのが難しい印象を受けました。
- rbs
- rbs collection
- steep
- sorbet
- TypeProf
ちょっと捻った書き方をすると型定義の書き方が分からない
Web 上に存在する記事の大体が触ってみたレベルなので捻った書き方(メタプログラミング等)をする場合、型定義が分からない場合があります。
まだベストプラクティスが固まっていない印象でした。
rbs collection にある gem の数が少ない
これは我々コミュニティの問題ですが、比較的よく利用するであろう aws-sdk
, activerecord
といった gem は存在しますが、まだまだ数が少ないので少しでもマイナーな gem を利用しようと考えると恩恵に授かりづらいです。
ポジティブな点
プロダクションでも利用できる
しばらく Ruby から離れており情報を追いきれておらずプロダクションで利用するのはまだ先かなと勝手に思っていましたが、周辺ツールが整っていることもありプロダクションでも利用できると判断しました。
rbs collection や VSCode の Extension など周辺ツールはかなり整っている印象でした。
じゃあ多くのプロダクトで利用されているであろう Rails はどうだという話ですが、まだ辛い部分はあるものの導入して型の恩恵を受けることはできそうと考えています
最後に
思った以上に道は整っていたのであとはひたすら書くだけだなという印象でした。一方実際の業務で利用している Rails アプリケーションに導入するには一苦労必要になりそうという感想です。
私が導入するのであれば一気に導入するのではなく、少しずつ型定義を反映していくやり方になるだろうと考えました(app/
を全てみるのではなく、app/controllers/hoge_controller
など一つずつ適応という方針です。)
または全てに型定義を反映することは後回しにし、重要なビジネスロジックのコード(課金処理・バッチ処理 etc)をファイルや gem に切り出し、ひとまずそこだけメンテナスをするという方向もありだなと考えています。
Discussion