はじめに
Qiita の Ruby 企画に参加しようと思い立ち、10年ぶりに Ruby を使ってみました。
10年前は主に Treasure Data 社の embulk や digdag の (J)Ruby Plugin を書いていて、 DSL 的な使い方をしていたので Ruby 自体にはあまり詳しくありません。1 認識違うところなどあるかもしれませんが、その点は詳しくない人が書いている前提でのご理解お願いいたします。
調べてみると Ruby 3 から「RBS」という型定義の仕組みが導入されていることを知りました。2
Python でも普段 Type Hint 3 を書いているので、Ruby ではどのように実現されているのかが気になって試してみたところ、モジュールを include したさいの名前空間のあつかいで少し戸惑うポイントがありました。
本記事では、今回ハマった Ruby(RBS + Steep)での、モジュールを include したときの挙動と学びについて記載します。
確認した環境
- Ruby : 4.0.2 ( > 3.2 であればよいはずです)
- Steep : 2.0.0
本題・実装
結論から言うと、Rubyの実行時と、Steepによる型チェック時で、include したモジュールの名前空間の解決方法に違いがある という点で戸惑いました。
Ruby側の挙動(期待する動作)
Rubyでは、モジュール(例えばコアモジュールの Math)を include すると、名前空間を省略してメソッドを呼び出すことができます。
class MyCalculator
include Math
def calculate_root(x)
# Math.sqrt ではなく sqrt だけで呼べる
sqrt(x)
end
end
これはRubyの柔軟なところで、非常に直感的です。
RBS (Steep) 側での挙動とエラー
しかし、これと同じコードに対して Steep で型チェックを走らせるとエラーになります。
素朴に考えると以下のような RBS ファイルでチェックが通るはずです。
class MyCalculator
# ここで Math を include しているので sqrt が認識されて欲しい
include Math
# 元の定義の Math.sqrt が Math で定義された double 型使っているので
# それを参照 (Numeric + .to_f な型)
def calculate_root: (Math::double x) -> Float
end
しかし、チェックを実行すると以下のようなエラーが出ます。
$ bundle exec steep check
# Type checking files:
.F
lib/calc.rb:6:4: [error] Type `::MyCalculator` does not have method `sqrt`
│ Diagnostic ID: Ruby::NoMethod
│
└ sqrt(x)
~~~~
Detected 1 problem from 1 file
なぜこのような挙動になるのか完全には把握しきれていませんが、コアモジュールに関して言えば、RBS側で「 特異メソッドのみ 」の定義になっていることが原因の一つであると推測されます。
Math.sqrt の RBS ファイルでの定義箇所は
で、 def self.sqrt と特異メソッドのみの定義のみなのが分かります(全く同じ定義のプライベートメソッドをもう一つ書くという二重管理になるのがよくないという判断でしょうか?)。
解決策の検討
Steep の型チェックを通すためには、主に2つの対応方法が考えられると思います。
対応案1: Ruby 側でフルの名前空間を明示する
一番シンプルな解決策は、Ruby 側のコードを書き換え、名前空間を省略せずに明示的に呼び出すことです。
class MyCalculator
# なくてもよい
include Math
def calculate_root(x)
# フルパスで呼ぶ
Math.sqrt(x)
end
end
こちらは Ruby の rbs/core/math.rbs が参照できるようになった状態ですね。
対応案2: RBS 側で追加定義を行う
Ruby 側のコードを書き変えたくない場合は、RBS 側で対応する必要があります。
例えばコアモジュールに対して、RBSのモンキーパッチ的に private メソッドの記述を追加定義してあげることで、Steep に認識させることができます。
例えば以下のような sig/patches/math_ext.rbs を用意します。
module Math
# Math の private メソッドとしての sqrt の定義
# Math.sqrt と全く同じ定義
def sqrt: (Math::double x) -> Float
end
bundle exec steep check を実行するとチェックが通るようになります。
今回作成した sig/patches/math_ext.rbs から読み込みが行われていることが想定されますが、以下のコマンドを実行することで、実際の参照場所を確認することができます。
$ bundle exec rbs -I sig method MyCalculator sqrt
::MyCalculator#sqrt
defined_in: ::Math
implementation: ::Math
accessibility: public
types:
(::Math::double x) -> ::Float at sig/patches/math_ext.rbs:2:12...2:37
最後の行を見ると今回定義した sig/patches/math_ext.rbs を参照していることが分かります。
どちらを採用すべきか?
この2つのどちらを採用すべきかは、プロダクトの状況によって変わるため、 チームでの決定が必要な内容 だと感じました。
私としては以下の方針かなと思っています。
-
新規コードベースの場合:
- 最初から「名前空間は省略しない(対応案1)」という方針で統一
- RBSとの相性も良く、コードの出処が明確になるため
-
既存コードベースの場合:
- すでに
includeを多用したコードが大量にある場合はRBS側で型定義を追加する(対応案2)アプローチ - Ruby側のコードを全て修正するのはリスクが伴うため
- ただし、パッチの配置・変更のメンテナンスコストがかかります
- すでに
おわりに
10年ぶりの Ruby でしたが、RBS という型の表現が公式にサポートされていました。
今回のように「Ruby特有の柔軟な記法」と「静的型解析」のすり合わせの部分では、設計思想を理解しきれていない部分(なぜコアモジュールが特異メソッドのみの定義なのかなど)もありました。
今後 Ruby を使うときには頭に置きながら、再度どうなっているかを調べていきたいと思います。
-
当時所属していたプロジェクトでメンテするときに Java よりは Ruby の方が確度が高かったため ↩
-
https://docs.ruby-lang.org/en/3.2/NEWS/NEWS-3_0_0_md.html#label-RBS ↩