CSV.foreach の型エラーと格闘した話


2024年 06月 13日

ある Rails アプリケーションの型を整備していたところ、CSV ファイルを読み込む処理で型エラーが発生していました。本記事では、その顛末について報告します。

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.foreachCSV::RowEnumerator を返すようです。

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 が型エラーを発生させていた
  • リファレンスマニュアルにもブロックなしの挙動が記載されていなかった
  • bugs.ruby-lang.org を検索して、Enumerator を返す挙動が正しいこと(仕様であること)を確認した
  • リファレンスマニュアルと型定義の修正を行った

オフィシャルのマニュアルや型定義と言えども、誤りは含まれるものです。おや? とおもったことでも、調べて手直ししていけると、明日はちょっとよい世界になりますね。レッツコントリビュート!

オチ:英語版のリファレンスマニュアルには、ブロックなしの CSV.foreach の挙動が正しく掲載されていました。もう少し資料を読み込んでおくと、早めに問題に気づけたのかもしれません。