はじめに
Ruby 3.0で型定義が導入されました。
Ruby 3.0.0 リリース
実際に既存のコードに追加するときの手順を確認してみたかったので、ちょうど以前勉強用に作成したライブラリ(Gem)が規模も小さく導入も簡単そうだったので、これに型定義を追加して、実際にGem利用側が型定義を使うまでの流れを確認してみました。
kuredev/simple_ping: Simpe Ping Client for Ruby
大体は以下の記事の流れを参考に、各ステップで確認したことや調べたこと等をまとめていきます。
Ruby 3.0の静的型定義をTypeScriptみたいにライブラリに書いてみた所感 - Narazaka::Blog
ライブラリに型定義を追加する
自動でRBSを作成
まず初めに型定義を追加していくため、RBSファイルを作成する必要があります。1から手で作るのは大変なので、大まかなものをまず自動で作成することを考えます。
自動でRBSの作成には大きく3つの方法がありますが、
- rbs prototype rb
- rbs prototype runtime
- TypeProf
できれば引数/戻り値の型情報も自動で作成してほしかったので、まずは TypeProf の利用を試みました。
参考
RubyからRBSを生成する各方法の特徴 - pockestrap
【Ruby 3.0】RBSの生成方法を色々試してみるメモ - memo.log
TypeProfでの試み
ところが、実際に typeprof で型定義を出力しようとすると以下のエラーが発生してしまいました。
% typeprof lib/simple_ping/client.rb
# Analysis Error
A constant `MonitorMixin' is used but not defined in RBS
メッセージ的にはRBSに MonitorMixin
モジュールの定義が無いと表示されています。
ライブラリ中では logger ライブラリを使用していますが、この中の Logger::LogDevice
が MonitorMixin
を利用しているようでした。
以下のような簡単なコードに対して typedef してもやはり同様のエラーとなったため、再現性がありそうでした。
あまり深入り出来ていませんが、Ruby標準ライブラリの多くは既にRBSの型定義情報が提供されていますが、 MonitorMixin
の定義はまだ無いために typeprof でも型の解釈に失敗してしまったのかなと思います。
require "logger"
class Kure
def self.run
logger = Logger.new
logger.info "hoge"
end
end
rbs prototype runtime による試み
すぐには解決出来なそうだったので、今回は rbs prototype runtime
によってRBSを準備することとしました。
rbs prototype rb
も試しましたが、形式やコメントの有無等で rbs prototype runtime
の方が結果がシンプルだったので、こちらを採用しました。
rbs prototype runtime
でRBSを作成する場合はプログラムを実行するコードが必要なので、以下のコードをリポジトリのトップに準備し、
require_relative "./lib/simple_ping"
ping_client = SimplePing::Client.new(src_ip_addr: "172.31.7.56")
puts ping_client.exec(dest_ip_addr: "8.8.8.8")
以下のように実行します。
% sudo rbs prototype runtime -R sample_run.rb "SimplePing::*"
実行結果
class SimplePing::Client
public
def exec: (dest_ip_addr: untyped, ?data: untyped) -> untyped
private
def initialize: (src_ip_addr: untyped, ?log_level: untyped) -> untyped
def logger: () -> untyped
def socket: () -> untyped
end
SimplePing::Client::TIMEOUT_TIME: Integer
class SimplePing::ICMP
public
def data: () -> untyped
def data=: (untyped) -> untyped
def id: () -> untyped
def id=: (untyped) -> untyped
def is_type_destination_unreachable?: () -> untyped
def is_type_echo?: () -> untyped
def is_type_echo_reply?: () -> untyped
def is_type_redirect?: () -> untyped
def seq_number: () -> untyped
def seq_number=: (untyped) -> untyped
def successful_reply?: (untyped icmp) -> untyped
def to_trans_data: () -> untyped
def type: () -> untyped
def type=: (untyped) -> untyped
private
def carry_up: (untyped num) -> untyped
def checksum: () -> untyped
def gen_data: () -> untyped
def gen_id: () -> untyped
def gen_seq_number: () -> untyped
def initialize: (type: untyped, ?code: untyped, ?id: untyped, ?seq_number: untyped, ?data: untyped) -> untyped
end
SimplePing::ICMP::TYPE_ICMP_DESTINATION_UNREACHABLE: Integer
SimplePing::ICMP::TYPE_ICMP_ECHO_REPLY: Integer
SimplePing::ICMP::TYPE_ICMP_ECHO_REQUEST: Integer
SimplePing::ICMP::TYPE_ICMP_REDIRECT: Integer
class SimplePing::RecvMessage
public
def code: () -> untyped
def data: () -> untyped
def id: () -> untyped
def seq_number: () -> untyped
def to_icmp: () -> untyped
def type: () -> untyped
private
def initialize: (untyped mesg) -> untyped
end
自動作成結果の確認
上記結果をRBSファイルにして sig
ディレクトリに格納し、 steep check
で型情報に問題が無いか確認してみます。
% gem install steep
% mkdir sig
% vim sig/simple_ping.rbs # 上記結果をペースト
% steep init
% vim Steepfile # 下記記載
target :lib do
signature "sig"
check "lib"
check "./"
end
% steep check
sig/simple_ping.rbs:1:0...13:3 UnknownTypeNameError: name=::SimplePing
sig/simple_ping.rbs:17:0...61:3 UnknownTypeNameError: name=::SimplePing
sig/simple_ping.rbs:71:0...89:3 UnknownTypeNameError: name=::SimplePing
sig/simple_ping.rbs:15:0...15:41 UnknownTypeNameError: name=::SimplePing
sig/simple_ping.rbs:63:0...63:60 UnknownTypeNameError: name=::SimplePing
sig/simple_ping.rbs:65:0...65:47 UnknownTypeNameError: name=::SimplePing
sig/simple_ping.rbs:67:0...67:49 UnknownTypeNameError: name=::SimplePing
sig/simple_ping.rbs:69:0...69:45 UnknownTypeNameError: name=::SimplePing
自動で作成した型定義情報なので成功してほしいところですが、失敗してしまっています。 ::SimplePing
が無い(コードでは定義しているがRBSファイルの中では定義していなかった)と怒られてしまいました。これも含めて修正をしていくと都度エラーが発生したため、いくつかの点でRBSファイルを手動で修正していきます。(rbs 生成時のコマンドの引数等で回避できるエラーもいくつかあるかもしれません)
手動で修正
今回は以下の通り修正してみました。
- 上3行は記載しないとエラーとなってしまったので、記載しました。
- 下4行は
SimplePing::Clinet
クラスのコンストラクタとメソッドです。 rbs コマンドで生成した情報は基本的に引数/戻り値の型が untyped となりますが、この Gem のユーザーが直接的に利用するこのクラスは引数の型等を書いておきたいと思ったので untyped から修正して仕様の型に修正しました。
+ module SimplePing
+ end
+ SimplePing::VERSION: String
- def initialize: (src_ip_addr: untyped, ?log_level: untyped) -> untyped
+ def initialize: (src_ip_addr: String, ?log_level: Integer) -> void
- def exec: (dest_ip_addr: untyped, ?data: untyped) -> untyped
+ def exec: (dest_ip_addr: String, ?data: String) -> bool
改めて steep check し、問題無く動作することを確認しておきます
% steep check
# 何も出力されなければOK
動作確認
試しにわざと誤ったコードを書いてみて、型チェックが動作するか確認してみます。
上述で準備した実行用のファイルを以下のように修正してみます。
require_relative "./lib/simple_ping"
ping_client = SimplePing::Client.new(src_ip_addr: 100) # 本来 "IP Address" で指定する部分をInteger
puts ping_client.exec(dest_ip_addr: 100) # 本来 "IP Address" で指定する部分をInteger
steep check してみると、型定義情報にしたがって誤っていることを指摘してくれました。
% steep check
sample_run.rb:3:50: IncompatibleAssignment: lhs_type=::String, rhs_type=::Integer (100)
::Integer <: ::String
::Numeric <: ::String
::Object <: ::String
::BasicObject <: ::String
==> ::BasicObject <: ::String does not hold
sample_run.rb:4:36: IncompatibleAssignment: lhs_type=::String, rhs_type=::Integer (100)
::Integer <: ::String
::Numeric <: ::String
::Object <: ::String
::BasicObject <: ::String
==> ::BasicObject <: ::String does not hold
ちなみにVS CodeにSteepのプラグインを入れておくとコードを書いている途中でも指摘してくれます。便利ですね!
外からGemをインストールして、追加した型定義を使ってみる
リリース
作成した定義情報を入れてリリースします。
Ruby 3.0の静的型定義をTypeScriptみたいにライブラリに書いてみた所感 - Narazaka::Blog 記事でも記載されている通り、 sig
ディレクトリに定義ファイルを入れておけば利用する際に自動で読み込んでくれるようなので、作成したRBSファイルを入れてリリースします。
% git add sig/simple_ping.rbs
% git commit -m "xxx"
% [以下リリース作業...]
外から利用
別の環境で、リリースしたGemをインストールして利用してみます。
Bundler で入れたライブラリの型定義を参照する場合は、Bundlerで入れたSteepを実行する必要があるようだったので、SteepもBundlerで入れておきます。
% bundle init
% vim Gemfile # 以下を追記。Versionは適宜読み替えること
gem "simple_ping", "0.1.1"
gem "steep"
% bundle install --path vendor/bundle
インストールしたらsteep の準備をします。
% bundle exec steep init
% vim Steepfile
target :lib do
signature "sig"
check "./"
library "simple_ping"
end
library "simple_ping"
のように記載するとBundlerでインストールした先のsig配下のRBSを読み込んでくれるようです。
この環境で、Gemを利用したコードを、先程のようにあえて間違った形で書いてみます。
require "simple_ping"
SimplePing::Client.new(src_ip_addr: 1) # 本来は文字列で指定するところをIntegerで指定
steep check
を実行してみます。
型情報の誤りを指摘してくれました!
画像は省略しますが、ちゃんとVS Code上でも警告してくれます。
% bundle exec steep check
sample_run.rb:3:36: IncompatibleAssignment: lhs_type=::String, rhs_type=::Integer (1)
::Integer <: ::String
::Numeric <: ::String
::Object <: ::String
::BasicObject <: ::String
==> ::BasicObject <: ::String does not hold
以上です、お疲れさまでした。
その他参考記事
Rubyで型チェック!動かして理解するRBS入門 〜サンプルコードでわかる!Ruby 3.0の主な新機能と変更点 Part 1〜 - Qiita