おもしろwebサービス開発日記

Ruby や Rails を中心に、web技術について書いています

gimeiに型をつけた話

僕はRailsアプリケーション開発者としてはなるべく型は書きたくない派閥に属しています。でもライブラリ作者としては型をつけておくと利用者が嬉しいだろう、という気持ちがあります。

そんな折gimeiにPRがきたので、重い腰を上げて型を導入したときのメモを残しておきます。

関連エントリ: gimeiのv1.3.0をリリースしました - おもしろwebサービス開発日記

型はどうやって学ぶか

執筆時点では日本語におけるまとまったいい感じの記事は少なめな印象です。文法については pockeさんの記事が詳しく、読むと基本的な文法について把握することができます。

あとは公式のドキュメントでをひたすら読むのがよさそう。

gimeiで定義した型が正しいかチェックする

ドキュメントを読むとなんとなく型をつけていくことはできます。僕はgimeiの作者なのでgimeiの持っている各メソッドが期待する型について他の人よりも詳しいですが、作り始めてから12年ほど経過しているので忘れていることも多々あります。

遠い記憶を掘り起こすよりもツールを使って機械的に定義した型が正しいかをチェックしたほうが楽なので、lib/ 配下をsteep checkでチェックするようにしてみました。するといくつかのチェックが失敗します。以下はそのうち困った点について書いています。

困った点1. 依存ライブラリの型がない

gimeiにはふりがなをローマ字で表示するための機能があります。

gimei = Gimei.name
gimei.kanji          #=> "斎藤 陽菜"
gimei.hiragana       #=> "さいとう はるな"
gimei.katakana       #=> "サイトウ ハルナ"
gimei.romaji         #=> "Haruna Saitou"

ふりがなやフリガナに関してはリポジトリ中にデータとして保持しているのですが、ローマ字はromaji を利用してふりがなからローマ字に変換したものを使っています。

このromajiの型定義がなくて型チェックに失敗します。執筆時点ではromajiのリポジトリにも gem_rbs_collection にも型情報はありません。

一般的な振る舞いとしてはromajiもしくはgem_rbs_collectionにPRを出すのが良さそうですが、gimei内でromajiの型を追加してひとまず解決としました。

この件に関してだけでいうと、gimeiの依存ライブラリからromajiを削除する、というのも有力な選択肢です。

困った点2. 特異クラスでdefine_methodしたときの結果がおかしい

gimeiには次のようにdefine_methodを使って動的にメソッド生成している箇所がありました。これは必ずしもdefine_methodを使わなくてもよかったのですが、同じ内容のメソッドを大量に書くのが煩わしくてこうなっています。

class Gimei
  class << self
    %i[kanji hiragana katakana romaji first last family given].each do |method_name|
      define_method(method_name) do |gender = nil|
        name(gender).public_send(method_name)
      end

ここが次のように怒られます。

lib/gimei.rb:44:13: [error] Unexpected positional argument
│ Diagnostic ID: Ruby::UnexpectedPositionalArgument
│
└         name(gender).public_send(method_name)
               ~~~~~~

エラーメッセージはGimei.nameに期待しない位置引数が渡されている、と読めます。しかしGimei.name(gender)は別の場所で定義済み。

ちゃんとコードを追いかけたわけではないのですが、steepが特異クラスのdefine_methodのコンテキストを取り違えていそうだなと推測しています。同じ内容のIssueもたっていました。

上でも書いたように、define_methodである必要性はなかったので実装をclass_evalに変更して対応しました*1

困った点3. 特異クラスでextendできない

次のようにdef_delegatorsを使ってGimei.maleとしたらGimei::Name.maleに委譲しているところでsteepのチェックが失敗します。

class Gimei  
  class << self  
    extend Forwardable    
    def_delegators 'Gimei::Name', :male, :female

RBSでは、次のようにクラスにextendしていることを型を表すことはできるけど、特異クラスに対してextendしていることを表す方法は現時点でなさそうでした。

class Gimei
  extend Forwardable

ruby-jpで相談したところ次のようにしてスキップする方法を教わりました。

__skip__ = begin
  extend Forwardable
  def_delegators ...
end

Allow skipping type checking by soutaro · Pull Request #73 · soutaro/steep に説明があるように、__skip__に代入する式全体がsteepの型チェックのスキップ対象となるようです。

また、# steep:ignore 無視したいエラー内容のようにして回避する手段があります。が、対応時点ではまだマージされていなかったので採用しませんでした。

Ignore diagnostics by steep:ignore comment by soutaro · Pull Request #1034 · soutaro/steep

最終的に次のコマンドでsteep_expectations.ymlを生成し

bundle exec steep check --save-expectations

それを--with-expectaitonsオプションで参照しつつsteep checkするようにしました。このようにすると、現状の型エラーは予想されたものとして扱われ、steep checkはステータスコード0で終了します。

bundle exec steep check --with-expectations

ひとまず 💚

しかしこれで型が過不足なく定義できたか、というと不安が残ります*2。現状ではlib/しか見てないので、ライブラリの一番外側のインタフェースはチェックされていないはず。spec配下を見れば外側のインタフェースを利用しているコードがあるので不安はだいぶ減りますが、これを実現するにはテストフレームワーク(minitest)の型も必要です。また、minitestでも何かしらのメタプロが駆使されているので、採用には苦難が伴いそう。

gem_rbs_collectionはどうやっているのかな、と思ってみると次のようにひたすらインタフェースのコードを書いて、それをsteep checkしていました。例としてgimeiのテストコードを上げていますが、ほかのgemに対するテストも同じような感じでした。

gem_rbs_collection/gems/gimei/1.1/_test/test.rb at main · ruby/gem_rbs_collection

require "gimei"

gimei = Gimei.name
gimei.kanji
gimei.hiragana
gimei.katakana
gimei.romaji
gimei.gender
gimei.male?
gimei.female?
# ...

このコードが実際にライブラリが提供しているインタフェースと一致しているかはテスト中では特に保証されていない模様です。ここの信頼性をあげるには、テストフレームワークの型付けを頑張ってやるか、RBS::Testで型のテストを書くしかないのかな?となっています。

所感

gimeiに型を導入してみて、型の導入に完璧を目指そうとすると大変だな、という所感を得ました。完璧を目指そうとせずエイヤで型を定義していき、うまく定義できないところはuntypedするくらいの大らかさで導入していくのが良さそう。

ベストエフォートな型の追加でもそれなりに恩恵はあるようなので、特にgemの作者はできる範囲でちょっとずつ型を足していくとみんなが幸せになると思います。やっていきましょう。

*1:本当はdefで地道にやったほうがいいと思います

*2:実際漏れがあってあとで型情報を追記しました…