Ruby 1.8 と 1.9 の両方で動作する簡易 CSV 形式データ処理を考える。

本記事で対象とするのはとても単純な一行の CSV 形式データを処理するプログラムです。

とあるプログラムで必要になって制作を強いられたので、そのときの情報を整理して書いておこうと思います。

CSV 形式のデータとは、カンマ区切りのデータのことです。CSV は Comma Separated Values(カンマで区切られた値)の略です。

もしも似たような問題にぶつかることがあったら、この記事を参考の一つにしてみてください。


なぜこの記事を書いたのか?

Ruby の標準添付ライブラリには csv という CSV 形式を扱うライブラリが存在します。

これを使えば簡単と最初は思っていました。

しかし、実際に使ってみると Ruby 1.8 と 1.9 の csv ライブラリには互換性がないことがわかりました。

以下に Ruby 1.8.7 と 1.9.2 両方の csv ライブラリドキュメントへのリンクを貼っておくので、CSV 形式のデータを解析して配列で値を取得する parse メソッドのインターフェイスを調べてみてください。

Ruby 1.8 の csv ライブラリは、処理が遅いことで知られており FasterCSV というライブラリが存在していましたが、Ruby 1.9 では FasterCSV を元にしたライブラリが標準添付ライブラリに採用されたようです。

ちなみに本記事では CSV 形式データを処理する簡易プログラムを自作しますが、FasterCSV を利用するのも一つの手だと思います。

試していませんが、おそらく Ruby 1.8 でも Ruby 1.9 でも動くのではないでしょうか?(知っている方、コメント下さると嬉しいです^^;)

今回はできる限り外部のライブラリを使いたくないのと、それほど大掛かりで仕組みである必要がないとの判断から CSV 形式データの解析を自前でやることに決めました。


簡易 CSV 形式データ処理の仕様

  1. parse_csv という名前のメソッドとして定義する。
  2. 引数は1つ。UTF-8 でエンコードされた解析対象となる CSV 形式データの文字列を受け取る。
    1. ※ 後で説明するが UTF-8 を想定しているのは重要なポイントである。
  3. 戻り値は、解析した結果のデータを UTF-8 の文字列配列として返す。
  4. カンマ(,)を区切り文字とする。
  5. ダブルクオーテーション(")を囲み文字とする。
  6. 囲み文字でデータを囲む/囲まないは任意とする。
    1. ※ カンマをデータとして扱いたい場合を想定している。
  7. 囲み文字で囲んだ文字列中の2連続したダブルクオーテーションは1つのダブルクオーテーション文字列として扱う。
    1. ※ 囲み文字で囲んだデータ中にダブルクオーテーションを含めたい場合を想定している。
  8. 囲み文字でデータが正常に囲まれていなかった場合、ArgumentError を発行して異常終了する。

CSV 形式のデータ処理としてはありがちな仕様だと思う。

囲み文字の扱いについてはエスケープ文字列の導入などを行う方法を取る場合もありますが、とりあえずこれを今回のざっくり仕様とします。


ポイントの説明

ポイントは、以下の2つである。

  • 解析対象となる CSV 形式データ文字列が UTF-8 にエンコードされた文字列であること。
  • 区切り文字と囲み文字が ASCII 文字列であること。

Ruby 1.9 のみを対象にするなら特に問題はありません。しかし、Ruby 1.8 で正常動作することを考えると、この条件はとても重要になってきます。

その理由は Ruby 1.8 と 1.9 の文字列の扱いの違いです。

Ruby 1.9 から M17N という考え方が文字列(String)クラス に導入されました。M17N とは 文字列のインスタンス毎にエンコードを管理するという考え方です。Ruby 1.8 にはこの考え方がありません。

Ruby 1.8 と 1.9 の大きな違いは、文字列を扱う単位の違いです。以下に各バージョンごとの扱う単位の違いを示します。

処理系単位
Ruby 1.8バイト
Ruby 1.9文字

この違いはとても大きいです。Ruby 1.9 では日本語の1文字を1文字として扱いますが、Ruby 1.8 ではそうはいきません。

Ruby 1.8 では同じ日本語でも、それを構成しているバイトコードの中身まで見えてしまうのです。

同じ日本語でもエンコードの方法によっては違うバイトコードの並びになります。

その中には今回区切り文字や囲み文字として採用したカンマ(,)やダブルクオーテーション(")に該当してしまうものが存在するかもしれません。

かといって、Ruby 1.8 でエンコードを意識して文字単位で処理をするのはとても難しいような気がします。

少なくとも、僕が持っている文字コードの知識にそんなものはありません。処理系がやってくれているからできることであり、自分で実装するとなるとどれほどの労力がかかることやら…という感じです。

しかし、この問題を解決するのが UTF-8 というエンコードです

  • 参考資料)プログラマのための文字コード技術入門 - p.147 参照
    • UTF-8 の特徴として、ASCII と互換であるという利点があります。
    • 0x7F 以下のバイトは常に ASCII とみなしていいことになります。

UTF-8 の文字列を対象とすれば、バイト単位であろうと文字単位であろうと区切り文字や囲み文字が ASCII 文字の範囲内である限りは、文字の判断を誤ることはありません。


内部仕様

ポイントの説明にて Ruby 1.8 と 1.9 の違いによる問題を解決する方法の目処が付いたので、内部仕様について検討します。

CSV 形式のデータの解析を、文字列の先頭から1バイトもしくは文字単位で走査していく方法で実装したいと思います。

また、内部仕様は以下のような状態遷移図として定義することにします。

これは start すなわち (1).head から始まり、文字列の走査によって状態遷移していくモデルです。最終的には正常終了(finish)かエラー(error)にたどり着きます。

(2).no_quote は囲み文字(")で囲まれなかった場合の処理で、カンマ(,)もしくは文字列の終端(EOL=End Of Line)に到達にするまでは同じ状態を維持します。

(3).quoting は囲み文字(")で囲まれている場合の処理で、囲み文字以外のバイト/文字がくるまでは同じ状態を維持します。ただし、途中で文字列の終端(EOL)に達してしまった場合は、囲みの閉じ忘れと考えられるのでエラー(error)にたどり付きます。

(4).quote_end は囲みが囲み文字によって閉じられるのか、2連続の囲み文字(")によってダブルクオーテーションをデータとして扱うのかを判断するための状態です。囲みが終了する場合、次に予想されるのはカンマ(,)によって新しいデータが始まるか、文字列の終端(EOL)によって解析が正常終了(finish)するかのいずれかであるはずです。従って、それ以外のバイト/文字が来た場合はエラー(error)にたどり付きます。


実装例

以上を受けて実装した例を以下に示します。

 def parse_csv(line)
   result = []
   comma = ','[0]
   quote = '"'[0]
   state = 1
   i = 0
   buffer = ""
   while i < line.length
     str = line[i]
     case state
     when 1  # head
       case str
       when quote
         state = 3  # quoting
       when comma
         result.push(buffer)
         buffer = ""
         # next data
       else
         buffer << str
         state = 2  # no_quote
       end
     when 2  # no_quote
       case str
       when comma
         result.push(buffer)
         buffer = ""
         state = 1  # head
         # next data
       else
         buffer << str
       end
     when 3  # quoting
       case str
       when quote
         state = 4  # quote_end
       else
         buffer << str
       end
     when 4
       case str
       when quote
         buffer << str
         state = 3
       when comma
         result.push(buffer)
         buffer = ""
         state = 1  # head
         # next data
       else
         raise ArgumentError.new("引数の囲みが正常に閉じられていません。")
       end
     end
     i = i + 1
   end
   if state == 3 then
     raise ArgumentError.new("引数の囲みが正常に閉じられていません。")
   else
     result.push(buffer)
   end
   return result
 end

実装例のポイント

細かいですが、上記の例の中で Ruby 1.8 および 1.9 の両方で動作するよう特殊なイディオムが使われている箇所がいくつかあります。

1つ目は、以下の行です。

 comma = ','[0]
 quote = '"'[0]

ちょっと不思議な記述ですが、[0] にはちゃんとした意味があります。

Ruby 1.8 と 1.9 の文字列の扱いの違いについては既に述べました。

Ruby 1.8 は文字列の各要素をバイト単位で扱うため line[i] の結果は String ではなく Integer になります。

Ruby 1.8 では String 型である ',' や '"' と line[i] の結果を case 文で比較しても正常に動作しません。以上のイディオムはそれを防ぎます。

  • Ruby 1.8 で ','[0] は Integer です。line[i] も同様に Integer です。
  • Ruby 1.9 で ','[0] は String です。line[i] も同様に String です。

2つ目は、以下の行です。

 buffer << str

文字列の連結にはもう一つプラス演算子(+)を使って以下のように記述することが可能です。

 buffer = buffer + str

この2つには大きな違いがあります。それはバイト(Integer 型の値)を受け取ることができるか否かです。

プラス演算子(+)で Integer 型の値を連結することはできません。

しかし、<< 演算子はバイト(Integer 型)をうまく処理してくれます。

これも先ほどと同じ Ruby 1.8 と Ruby 1.9 の文字列の扱いの違いに起因する問題を解決するためのイディオムです。


最後に文字列のエンコーディング変換について。

Ruby 1.9 では M17N の考え方を取り入れることによって String#encode メソッドによって簡単に文字列のエンコードができるようになりました。これは非常に便利です。

それに対して Ruby 1.8 には String#encode メソッドがなく、nkf モジュールを使わなければなりません。

このままだと文字列のエンコーディング変換の処理が Ruby 1.8 と 1.9 で異なるものになってしまいます。

しかし、この差を吸収し、完全ではありませんが簡易的な互換性を保つことができるイディオムを以下の記事で紹介しています。

こちらも参考にしてみてください。