ある Rails アプリケーションの型を整備していたところ、CSV ファイルを読み込む処理で型エラーが発生していました。本記事では、その顛末について報告します。
エラーが発生したコードは、次のようなものです。ブログ用にすこし簡略化していますが、ほぼ実際のコードのままです。
result = CSV.foreach(filename, headers: %i[email name])
.map { |row| ... } # いい感じの変換処理 (ここでは割愛)
このコードでは CSV.foreach
を使って CSV ファイルを読み込み、 #map
を使って各行を別のデータ形式に変換しています。
このコードに対して、 steep check
で型検査を行うと、次のようなエラーが検出されました。
app/models/some_model.rb:100:17: [error] The method cannot be called without a block
│ Diagnostic ID: Ruby::RequiredBlockMissing
│
└ result = CSV.foreach(filename, headers: %i[email name])
~~~~~~~
app/models/some_model.rb:101:7: [error] Type `void` does not have method `map`
│ Diagnostic ID: Ruby::NoMethod
│
└ .map { |row| ... }
~~~
2つのエラーはそれぞれ、以下のことを意味しています。
CSV.foreach
の呼び出しにはブロックが必須であることCSV.foreach
は返り値を返さない(void
)ため、直後の #map
は呼び出せないこと正しく動作するはずのコードなのに、なぜか型エラーが発生しています。困りましたね。
型エラーが出てしまい、 CSV.foreach
の正しい挙動に自信が持てなかったため、まずは Ruby のリファレンスマニュアルを確認しました。すると、CSV.foreach
の説明には以下のように書かれています。
この説明によると、必ずブロックを渡す必要があり、返り値は nil
であると説明されています。
次に、RBS パッケージが提供する CSV の型定義も確認してみました。最新版の rbs-3.4.4 では、以下のように定義されています。
$ bundle exec rbs method --singleton CSV foreach
::CSV.foreach
defined_in: ::CSV
implementation: ::CSV
accessibility: public
types:
[U] (::String | ::IO | ::StringIO path, ?::Hash[::Symbol, U] options) { (::Array[::String?] arg0) -> void } -> void at /Users/tkomiya/.dotfiles/_rbenv/versions/3.3.0/lib/ruby/gems/3.3.0/gems/rbs-3.4.4/stdlib/csv/0/csv.rbs:1725:20...1725:125
こちらもリファレンスと同様、必ずブロックを渡す必要があり、返り値がない (void
) と定義されています。
今度は実際の挙動を確かめてみます。irb から呼び出してみると、ブロックなしの CSV.foreach
は CSV::Row
の Enumerator
を返すようです。
irb(main):001> require 'csv'
=> true
irb(main):002> CSV.foreach('test.csv', headers: ["a", "b", "c"])
=> #<Enumerator: ...>
irb(main):003> CSV.foreach('test.csv', headers: ["a", "b", "c"]).first
=> #<CSV::Row "a":"1" "b":"2" "c":"3">
ブロックなしの each 系メソッドが Enumerator を返すのは、Ruby っぽい挙動だと感じますよね。each.with_index みたいな呼び出しもできて便利ですしね。
ここまで調べてみてわかったことは、実際の動作とリファレンスや型定義が一致していないということです。ですので、リファレンスや型定義が誤っているのか、ドキュメント化されていない非公式の動作であるということになります。非公式の動作の場合、いつか挙動が変化してしまう可能性があるので、裏付けを取りたいですよね。
こういった言語仕様や実装の経緯については bugs.ruby-lang.org で議論されています。キーワード検索も用意されているので、早速調べてみました。その結果、 Feature #8929: CSV.foreach(filename) without block returns failing Enumerator という feature request を見つけることができました。
この議論を読み進めていくと、ブロックなしの CSV.foreach
は Enumerator を返すよう提案され、Ruby に反映されたようです。つまり、この動作は Ruby の正式な仕様であるということになります。
つまり、型定義やリファレンスは誤っていたということです。これはコントリビュートチャンスですね。
まずはリファレンスマニュアルを修正しましょう。便利なことに、Ruby のリファレンスマニュアルにはページ下部に「このマニュアルを編集する」というリンクがあります。
このリンクをたどると GitHub の rurema/doctree リポジトリの該当ファイル、該当行が開かれます。GitHub のオンラインエディタ上でドキュメントを修正し、右上の “Commit Changes…” ボタンを押すと修正がコミットされ、PR を送ることができます。
今回の調査結果を受けて、 CSV.foreach: ブロックが与えられていない場合の挙動を加筆 という PR を送っておきました。さくっとマージしていただいたので、いずれ Web サイトにも反映されるはずです。
続いては型情報の修正です。Ruby に標準添付されているライブラリの型情報は RBS リポジトリで管理されているため、ruby/rbs リポジトリを手直しします。
しかし、修正しようと型定義を確認したところ、すでにブロックなしの CSV.foreach
の型定義が追加されていました。どうやら Update type for CSV.foreach という PR で、すでに修正されていたようです (ただし、未リリース)。
ただ、この PR の修正は不完全なものでした。 headers
パラメータは true, :first_row
, 配列、文字列など、いくつかの種類のパラメータを受け取ることができますが、この PR では true を指定した場合についての型定義だけが追加されていました (ご本人もよく使われる true のケースに対応するとコメントされています)。
我々のケースでは headers
パラメータに配列を渡しており、さらに型定義に手を加える必要がありました。ですので、こちらにも stdlib: the headers keyword for CSV.foreach can take String という PR を送っておきました。
また、この型定義が含まれる RBS パッケージがリリースされるまでの回避策として、手元のアプリケーションでは CSV.foreach
の型定義に以下のパッチを当てて回避することにしました。
# sig/gems/csv/csv.rbs
class CSV
def self.foreach: (String | IO path, ?String mode, headers: Array[untyped], **untyped options) -> Enumerator[::CSV::Row, void]
| ...
end
ここでは、型のオーバロード(...
)を使って、既存の型定義に headers
パラメータが配列を受け取る型を追加しています。
CSV.foreach
に対して steep が型エラーを発生させていたオフィシャルのマニュアルや型定義と言えども、誤りは含まれるものです。おや? とおもったことでも、調べて手直ししていけると、明日はちょっとよい世界になりますね。レッツコントリビュート!
オチ:英語版のリファレンスマニュアルには、ブロックなしの CSV.foreach
の挙動が正しく掲載されていました。もう少し資料を読み込んでおくと、早めに問題に気づけたのかもしれません。