はじめに
Ruby には CSV 形式の読み書きができる,その名もずばり csv というライブラリーが標準添付されている。
この記事では,読み込む CSV テキストに空のフィールドがあった場合の注意点を述べる。
あ,不正確なところがあったらツッコミを入れてください。
CSV おさらい
CSV をよく知ってる人は飛ばしてください。
CSV は n 行 m 列に並んだ文字列群を表現するフォーマットの一つで,列の区切りにカンマを使い,行の区切りに改行を使う。
要するに
FORTRAN,1954
LISP,1958
Ruby,1995
のようなやつ。
フィールドをカンマで区切るからそういう名前になっている。日本語でも「カンマ区切りテキスト」などと呼ぶ。
単純そうだけど,「フィールド値にカンマが含まれてたらどう表現すんの?」とか「フィールド値に改行を含むことは可能か」というようなことを考え出すと,そう単純でもない。
極めて重要かつ頻用されるフォーマットなのに,JIS とか ISO とかにはなっていない。
その代わり RFC4180 という文書が存在して,これに CSV の仕様が書かれている。
邦訳例:
https://github.com/ruby/ruby/blob/trunk/lib/csv.rb#L1905
RFC という言葉は Request for Comments の頭字語で,初期の RFC が「〇〇について仕様書を書いてみたんで,コメントくれや」という感じだったことに由来するらしい。
ま,それはともかく,CSV の仕様書で他にコレというものは無いようで,たぶん多くの人が「CSV って RFC4180 に基づくのがイイんじゃね?」と思っているんではないかと勝手に期待している。
(あー,このさいエクセルくんのことは放っておきましょう)
RFC4180 の中身
とりあえず以下のことだけ知っといてくだされ。
各フィールドはダブルクオートで囲っても囲まなくてもいい。以下の二つは同じデータを表している。
foo,bar
foo,"bar"
カンマや改行を含むフィールドはダブルクオートで囲まなくてはならない。
以下の CSV は 2 行 2 列だ。
"foo,bar",baz
"foo
bar",baz
また,ダブルクオートを含むフィールドもダブルクオートで囲まなくてはならないし,囲んだ中のダブルクオートは二重のダブルクオートによって表す。
つまりこんなふうに書く。
foo,"bar""baz"
Ruby の csv ライブラリーの使い方
Ruby の csv ライブラリーはもちろん RFC4180 に従った CSV の読み書きができる。
CSV ファイルの読み込みも,CSV テキストのパースもできる。第 1 行をヘッダー(フィールド名の行)とみなして,各行の各フィールドの値をフィールド名でハッシュのように得たりもできる。
いろんな使い方はリファレンスマニュアルを見ていただくとして,記事の説明に必要な最低限の使い方として以下のコードサンプルを挙げておく。
require "csv"
p CSV.parse("foo,bar\nhoge,fuga")
#=> [["foo", "bar"], ["hoge", "fuga"]]
えー,実は RFC4180 では,行の区切りは CR+LF と決まっているんだが,csv ライブラリーでは LF でもいいことになっている。ほどよくゆるいのだ。
空フィールドの問題
前置きが長くなったけど,ここでようやく本題に入る。
空フィールド問題とは
以下のような CSV の行があったとする。第 2 フィールドの値はなんだろう?
foo,,bar
当然,空文字列を期待するところだ。
ところがそうならない。
p CSV.parse("foo,,bar")
#=> [["foo", nil, "bar"]]
nil
なんである。
では,次の CSV は?
foo,"",bar
これの場合は空文字列になる。
p CSV.parse('foo,"",bar')
#=> [["foo", "", "bar"]]
この動作はどうやら
でやっているようだ。つまり,ダブルクオートで囲まれていない場合,空文字列をわざわざ nil
に置き換えているんである。
RFC4180 は何年か前に読んだけど,上記のような動作の根拠になりそうなことは書いてなかったと思う。
まあ世の中には,
CSV のフィールドに値が無いことと値が空文字列であることを区別したい
という需要があるのかもしれない。ふーん。
それに,csv ライブラリーが nil
を与えたとしても後処理で煮るなり焼くなりすればいいではないか,という考え方もあるのだろうな(ハイ,笑うとこですョ)
nil でなく空文字列が欲しいんじゃ
では,nil
でなくとにかく空フィールドは空文字列になってほしいとき,どうするのがベストか。
csv ライブラリーにはコンバーターという概念がある。CSV を読み込むとき,各フィールドの値に何か処理を施すためのもの。
例えば,数字しか書いてない CSV で,フィールド値を Integer
で得るには以下のような書き方ができる。
p CSV.parse("3,4", converters: ->v{v.to_i})
#=> [3, 4]
コンバーターを使えば to_s
で nil
を ""
に変えられるのでは?
p CSV.parse("foo,,bar", converters: ->v{v.to_s})
このやり方は,実際,Ruby 2.1 まではうまくいっていた。
p CSV.parse("foo,,bar", converters: ->v{v.to_s})
#=> [["foo", "", "bar"]]
ところが,Ruby 2.2 で csv ライブラリーの動作がちょっと変わり,このコードでも nil
になるようになった。
p CSV.parse("foo,,bar", converters: ->v{v.to_s})
#=> [["foo", nil, "bar"]]
Ruby 2.2 以降は,〈フィールドが nil
ならコンバーターは通さない〉という仕様だ。
涙ちょちょぎれ!
(追記:2018-04-06)空フィールドでコンバーターが効かない問題は修正された。記事末尾の追記の章を参照。
結局どうすれば
きっと読み込みオプションとか何かあるはず,と思って探したけど,どうやら無い。
読み込んだ後で自分でどうにかするしかなさそうだ。とほほ。
空フィールドを空文字列で得るという当たり前の幸せを得るためにわざわざコードを書かないといけないなんて面倒だし,巨大な CSV の読み込みで,ライブラリーがわざわざ空文字列から nil
に置き換えたものをまた to_s
するなんて無駄だよね。
(追記:2018-04-06)空フィールドでコンバーターが効かない問題は解消
どのバージョンでかはよく分からなかったが,空フィールドでコンバーターが効かない問題はバグと認識されたようで,修正された。
https://bugs.ruby-lang.org/issues/11126
現在の csv ライブラリーは,標準添付ではあるものの,gem の形で配布されている。
確認したところ,csv 1.0.0(2017-12-13 リリース)で既に直っているようだ。
(てことは,Ruby 2.5 で直ったってことかな)
しかし,私の「空フィールドは空文字列になってほしい(オプションでもいい)」という願いはかなえられなかった。
https://bugs.ruby-lang.org/issues/12839
(追記:2018-04-07)コンバーターは速度の敵だった
コンバーターを使って nil
を空文字列に変換するのは速度的にどうなんだろう?と疑問に思っていたが,簡単なベンチマークをやってみた。
gem "benchmark-ips"
require "benchmark/ips"
require "csv"
csv_text = <<EOT
foo,bar,"",baz
hoge,"",temo,""
roo,goo,por,kosh
EOT
conv = ->(s){ s || "" }
Benchmark.ips 20 do |r|
r.report "without converter" do
CSV.parse csv_text
end
r.report "with converter" do
CSV.parse csv_text, converters: conv
end
r.compare!
end
結果:
Comparison:
without converter: 9940.9 i/s
with converter: 8657.0 i/s - 1.15x slower
コンバーターをかますと 15% も遅くなることが分かった。
本文に既に書いたけど,フィールドが空だった場合,csv ライブラリーはわざわざ空文字列を nil
に変換している(このへん)。それをさらにユーザーがコンバーターで空文字列にする(戻す)。無駄だよね。
#(2018-07-04追記)nil_value
オプションで切り替えられるように
@OwlQiita さんのコメントにあるように,nil_value
オプションが導入され,これに空文字列を指定すれば私の望む動作にできるようになりました。
nil_value
は 2018 年 5 月 3 日リリースの CSV 1.0.2 で導入されたようです。
#(2018-07-20追記)nil_value: ""
の空文字列の同一性に気をつけろ
なんかハマりやすそうな点に気付いたので,追記します。
以下のように,空フィールドが空文字列になるようにしてみます。
gem "csv", ">= 1.0.2"
require "csv"
ary = CSV.parse("a,,,a", nil_value: "")
p ary
# => [["a", "", "", "a"]]
結果として文字列の配列の配列が得られます。空文字列が二つありますね。
この二つの空文字列は同じオブジェクトです。nil_value
に与えた文字列そのものです。コピーはされないようです。
さきほどのコードに続けて以下を実行して確かめてみましょう。
# "" は同じオブジェクト
puts ary[0][1].equal?(ary[0][2]) # => true
# "a" は別のオブジェクト
puts ary[0][0].equal?(ary[0][3]) # => false
二つの "a"
は別のオブジェクトであるのに対し,二つの ""
は同じオブジェクトであることが分かります。
ということは,この空文字列に破壊的に加工を施すと,思わぬ影響が出そうですね。
つまりこういうことです。
ary[0][1] << "hoge"
p ary # => [["a", "hoge", "hoge", "a"]]
一つ目の空文字列の末尾に "hoge"
を追加したのですが,次のフィールドの値も同じオブジェクトなので当然 "hoge"
になっているわけですね。
思わぬバグを招きそう。
まあこれに限らず,文字列に破壊的変更を加えるときは,その文字列オブジェクトの由来をきちんと把握することが大事なわけですが。