既存のgemにRBSで型定義を書く
RBSの練習としてhatenablogというgemの型定義をRBSで書いた。
https://github.com/kymmt90/hatenablog/blob/v0.8.0/sig/hatenablog.rbs
まだ該当gemのsigディレクトリに置いているだけだが、やったことを書いておく。
作業の流れ
Ruby 3.0をインストールするなどしてrbs、typeprofは使える状態になっているとする。
- TypeProfで型定義ファイルの雛形を生成する
- Steepを設定する
rbs collectionでサードパーティgemの型定義を導入する
steep checkを実行してエラーを確認する- 型定義やコード本体を修正し、エラーを解消する
- CIでSteepを実行する
ディレクトリ構造
次のようなディレクトリ構造とした。
.├── Steepfile├── lib│ └── (gemのコード)├── rbs_collection.lock.yaml├── rbs_collection.yaml└── sig └── hatenablog.rbs- sigディレクトリにRBSで書いた型定義を置く
- ルートディレクトリに型チェックを実行するSteepの設定ファイルであるSteepfileを置く
- ルートディレクトリにサードパーティgemを管理する
rbs collectionコマンドで使うrbs_collection.yamlとrbs_collection.lock.yamlを置く
型定義ファイルの雛形を作る
今回はTypeProfで雛形となるRBSの型定義ファイルを生成した(ce61db1)。
$ typeprof lib/**/*.rb test/hatenablog/*.rb -I lib/hatenablog > sig/hatenablog.rbsテストもlib配下のコードと一緒にTypeProfに入力して実際にメソッドが使われているコードを増やしたところ、libだけを入力したときよりもuntypedが減った。この方法を使う場合、テスト内のクラスが型定義に入るので削除しておく。
サードパーティgemの型定義を導入する
rbs collectionを使うとサードパーティgemの型定義が管理できる。今回はnokogiriのためにこの機能を使う。
rbs/collection.md at v1.7.1 · ruby/rbs
rbs collectionを使うにはrbs_collection.yamlを作成する。rbs collection initで同ファイルを生成したあと、必要なgemが入るように設定を書く。今回はrbs_railsの同ファイルを参考にして次のように書いた。
# Download sourcessources: - 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 RBSspath: .gem_rbs_collection
gems: # stdlibs - name: erb - name: net-http - name: set - name: time - name: uri
# gems - name: nokogiri
# not used - name: activesupport ignore: true - name: ast ignore: true - name: listen ignore: true - name: parallel ignore: true - name: rainbow ignore: true - name: rbs ignore: true - name: steep ignore: true設定の意図は次のとおり。
- 型定義のインストール先を
pathで設定する。ドキュメント通りにプロジェクトのルートディレクトリの.gem_rbs_collectionとする - 必要なgemのリストを
gems配下のリストに追加する - 自動生成するとRBSとSteep(とその依存)自体の型定義もリストに入るが、これらをライブラリとして使っているわけではないので
ignore: trueで無視する
このファイルが存在する状態でrbs collection installすると、bundle installのように、ruby/gem_rbs_collectionから必要な型定義を取得して.gem_rbs_collectionに保存する。このとき、使用する型定義のバージョンを記録するためのrbs_collection.lock.yamlが生成される。
---sources:- name: ruby/gem_rbs_collection remote: https://github.com/ruby/gem_rbs_collection.git revision: main repo_dir: gemspath: ".gem_rbs_collection"gems:- name: erb version: '0' source: type: stdlib- name: net-http version: '0' source: type: stdlib- name: set version: '0' source: type: stdlib- name: time version: '0' source: type: stdlib- name: uri version: '0' source: type: stdlib- name: nokogiri version: '1.11' source: type: git name: ruby/gem_rbs_collection revision: 88e86e0b67262f9ab6244a356e81dd9ca8c55b37 remote: https://github.com/ruby/gem_rbs_collection.git repo_dir: gems今回はこのファイルもrbs_railsにならってリポジトリにコミットしたが、gemでGemfile.lockはコミットすべきでないという話と同様に、rbs_collection.lock.yamlもbin/setupとかで都度生成するようにしたほうがいいのかもしれない。
Steepを設定する
RBSによる型定義にしたがったコードになっているかはSteepでチェックする。BundlerでSteepをインストールする。
# Gemfilegroup :development, :test do gem 'steep'end設定をSteepfileに書く。steep initで生成した雛形をもとにする。repo_pathで他のgemの型定義が入っているディレクトリのパスを指定し、libraryでruby/rbsに入っている標準ライブラリの型定義やサードパーティgemの型定義のうち、使いたいものの名前を指定することで、他のgemの型定義が利用できる。今回はconfigure_code_diagnosticsを使い、最も強いエラーレベルでlib配下のコードをチェックするように設定した。
# SteepfileD = Steep::Diagnostic
target :lib do check "lib" signature "sig"
repo_path ".gem_rbs_collection" library "erb", "net-http", "nokogiri", "set", "time", "uri"
configure_code_diagnostics(D::Ruby.all_error)end型定義ファイルやコードを修正する
ここまでで型チェックの準備ができた。TypeProfで生成した型定義で
$ bundle exec steep checkを実行するとチェックがたくさん失敗するので、チェックがすべて通るまで型定義や場合によってはコードを直していく。該当PRだと3つ目のコミット以降。
ここでは、一般的に発生しそうなエラーとその解消方法をいくつか取り上げる。
サードパーティgemの型定義に起因するエラー
このgemではXMLを扱うためにNokogiriを使っていて、次のようなコードが存在する。
@categories.each do |category| prev_node.next = @document.create_element('category', term: category) prev_node = prev_node.nextend@documentがNokogiri::XML::Documentのインスタンスであり、create_elementはそのメソッド。このメソッドは省略可能なブロックを取ることができるのだが、gem_rbs_collectionでは次のように省略不可になっていて、結果としてSteepのチェックがエラーになった。
def create_element: (untyped name, *untyped args) { (*untyped) -> untyped } -> untypedサードパーティgem側の型定義を修正すべき場合は修正する。今回はruby/gem_rbs_collectionのNokogiriの該当シグネチャを修正した。
Nokogiri: Make block arguments optional by kymmt90 · Pull Request #88 · ruby/gem_rbs_collection
サードパーティgemの型定義が存在しないエラー
このgemが依存するgemのうち、ostruct(OpenStructを提供する)とoauthとyamlの型定義は、2021年11月時点ではruby/rbsやruby/gem_rbs_collectionに存在しない。これらの型定義については、ひとまずSteepのチェックが通るようにpolyfillを追加した。
# polyfill for ostructclass OpenStruct def initialize: (?Hash[untyped, untyped]? hash) -> OpenStruct def []: (String | Symbol) -> Object def to_h: -> Hash[Symbol, Object]end
# polyfill for oauthmodule OAuth class AccessToken def initialize: (untyped, untyped, ?untyped) -> void end
class Consumer def initialize: (untyped, untyped, ?untyped) -> void endend
# polyfill for yamlmodule YAML def self.load: (String yaml, ?String? filename, ?fallback: bool, ?symbolize_names: bool) -> untypedendこれらについてもruby/rbs、ruby/gem_rbs_collectionにコントリビュートするのが望ましい。
“Type (Foo | nil) does not have method bar”のエラー
次のようにnilになりうるクラスFooの変数
@foo: Foo?に対してメソッドbarを呼び出していると、NilClassにそのようなメソッドがないので表題のエラーになる。いかにnilになるかどうかを意識せずにコードを書いているかがわかる。
現在は、状況に応じていくつかの方法で対応している。
nilにならない型に変更する
このエラーが起きている変数や引数の型からoptionalの?を外すことに問題がない場合、外す。すると(Foo | nil)という型からFooという型になるので、このエラーは起きなくなる。
&.を使う
本当にnilになる可能性もある変数なのであれば、&.を使う。&.であればnilに対してもメソッドを呼び出せるので、Steepのエラーは発生しない。
たとえば
> lib/hatenablog/entry.rb:167:61: [error] Type `(::Time | nil)` does not have method `iso8601`> │ Diagnostic ID: Ruby::NoMethod> │> └ @document.at_css('entry updated').content = @updated.iso8601 ~~~~~~~というエラーに対して
@document.at_css('entry updated').content = @updated&.iso8601というコードに変える。
“Type (^(untyped) -> untyped | nil) does not have method call”のエラー
先ほどのエラーと似ているが、ここではブロック引数がエラーの対象。
たとえばEnumerator#eachの型は2021年11月現在次のようになっている。
def each: () { (Elem arg0) -> untyped } -> Return | () -> selfところで、Enumerableをincludeしてeachを実装しているクラスの場合、
- ブロックが渡されないときはEnumeratorのインスタンスを返す
- ブロックが渡されるときはブロックを実行する
という挙動を実現するために次のようなコードを書くことがある。
def each(&block) return enum_for unless block_given?
@categories.each do |category| block.call(category) endendこのコードではblock_given?を使ってガードすることで、ブロックがないときEnumeratorをすぐに返している。また、このメソッドの型定義は、次のようにブロックを取らずにEnumeratorを返すか、もしくはブロックを取ることを表現するものになる。
def each: () -> Enumerator[untyped, self] | () { (String) -> void } -> Array[String]これでうまくいきそうだが、コード上は&blockはnilになりうる変数なので、Steepは引数&blockの型を^(untyped) -> untyped | nilと見なす。よって、そのままcallしようとすると、nilの可能性が考慮されて表題のエラーになる。
この問題は、Steepの機能であるアノテーションをコードに追加し、Steepにblockの型を明示することで解決できる。
def each(&block) return enum_for unless block_given?
@categories.each do |category| # @type var block: ^(String) -> void block.call(category) endend@typeから始まるコメントが変数に対するアノテーション。blockの型が^(untyped) -> untyped | nilではなく^(String) -> voidであることを明示している。Steepはこのコメントを見て型チェックすることで表題のエラーを発生させなくなる。
動的に生成するメソッドに関するエラー
attr_accessorやOpenStructを使っていると動的にgetter/setterメソッドが生成される。これらのメソッド定義はコード中に存在しないので、そのままsteep checkすると次のエラーが発生する。
lib/hatenablog/feed.rb:7:8: [error] Cannot find implementation of method `::Hatenablog::Feed#uri`│ Diagnostic ID: Ruby::MethodDefinitionMissing│└ class Feed ~~~~動的に生成するメソッドについては、@dynamicというSteepのアノテーションをコードに追加することで、Steepがメソッド定義を見つけられなくてもエラーにしなくなる。
class Feed # @dynamic uri, next_uri, title, author_name, updated attr_reader :uri, :next_uri, :title, :author_name, :updatedendモジュールのメソッドでModuleのインスタンスメソッドを使うとエラー
このgemではモジュールのメソッドでinstance_methodsしたりalias_methodしたりdefine_methodしたりとModuleのインスタンスメソッドを使っている。このモジュールは別のクラスでextendするという用途のために存在している。
あるモジュールはModuleクラスのインスタンスなので、モジュールのメソッドでinstance_methodsなどのメソッドをレシーバなしで呼び出すときSteepのチェックが通ることを期待していたが、次のエラーになった(ここでは::Hatenablog::AfterHookが該当のモジュール)。
> lib/hatenablog/entry.rb:13:11: [error] Type `(::Object & ::Hatenablog::AfterHook)` does not have method `instance_methods`> │ Diagnostic ID: Ruby::NoMethod> │> └ if instance_methods.include? origin_method> ~~~~~~~~~~~~~~~~>> lib/hatenablog/entry.rb:17:8: [error] Type `(::Object & ::Hatenablog::AfterHook)` does not have method `alias_method`> │ Diagnostic ID: Ruby::NoMethod> │> └ alias_method origin_method, method> ~~~~~~~~~~~~>> lib/hatenablog/entry.rb:19:8: [error] Type `(::Object & ::Hatenablog::AfterHook)` does not have method `define_method`> │ Diagnostic ID: Ruby::NoMethod> │> └ define_method(method) do |*args, &block|> ~~~~~~~~~~~~~実際はそれぞれちゃんと型定義が存在するが、“does not have method”と言われている。
とりあえず次のように型定義を素朴に追加するとチェックが通るのでそうしているが、もっといい方法がありそう。
module AfterHook # ...
def alias_method: (::Symbol | ::String new_name, ::Symbol | ::String old_name) -> ::Symbol def define_method: (Symbol | String arg0, ?Proc | Method | UnboundMethod arg1) -> Symbol | (Symbol | String arg0) { () -> untyped } -> Symbol def instance_methods: (?boolish include_super) -> ::Array[Symbol]endCIでSteepを実行する
GitHub ActionsでSteepも実行するようにしておく。ワークフローに次のようにジョブを定義すればよい。
jobs: steep: runs-on: ubuntu-latest steps: - uses: actions/checkout@v2 - uses: ruby/setup-ruby@v1 with: ruby-version: '3.0' - run: bundle install -j4 - run: rbs collection install - run: bundle exec steep checksteep checkの成功
Steepのチェックがすべて通ると標準出力に次のようなメッセージが表示される。これでgemの中のロジックについては型定義と矛盾しない状態になっていることが確認できた。
$ bundle exec steep check# Type checking files:
...................................................................................
No type error detected. 🧉