型から始めるプログラミング


この記事は「F# Advent Calendar 2013」16日目の記事です。


型に導かれて


 静的な強い型を持った関数型プログラミングにある程度馴染んでくると、次第に「コンパイルを通ったものは大抵動く」という不思議な感覚を持つようになります。この感じは手続き型プログラミングしかやったことがない人にはなかなか伝わらないもので、そんな話をしても大体「そんなわけあるわけないwww」というような反応を受け、こちらもうまく伝えられずモゾモゾすることになりがちです。

 もちろん「コンパイル通ればできてる」というのが正しくない、というのは分かりきったことなのですが、「コンパイルを通った時点で動いたものに何の問題もなかった」という事象の確率がかなり高いのもまた統計的なデータこそないものの皆様肌で感じているのではないかと思います。

 こういう「コンパイル通ったと思ったら動いた」というプログラムは、僕の経験では「大がかりな型」を想定することからスタートすることで持たされることが多い気がします。

 今回は課題として、CSVを読みこみいくつかのチェックと変換をして別のファイルに書きだすプログラムを書きながら、「大がかりな型」からプログラムを作っていく過程を見ていただきましょう。とはいっても最初なので小さめですが。

作るもの

 作ってみるプログラムの機能はこんなものです。

  1.  CSVから一行を読みこみ、カンマで区切って前後のダブルクォーテーションや空白を削除し、文字列の配列を作る
  2.  配列の特定の項目について、
  • 空白ならば0とする
  • 特定の項目間で何らかの計算をする
  • 条件を満たしていない場合は例外を出す(本当はログを出した方がいいのですが、今回の主旨からはずれるので)
  1. 結果として出来上がったデータをCSVとして保存する。

 手続き型プログラミングでも普通に作れそうなものですが、ここはF#なりに、「大がかりな型」をベースにプログラミングをしていくことにします。

ファイルの読み込みとCSV展開


 F#では、行データのシーケンスとしてファイルを扱うことが一般的です。

let getCsvDataFromFile (filename : string) = 
  seq {
    use sr = new System.IO.StreamReader(filename, System.Text.Encoding.GetEncoding("shift_jis"))
    sr.ReadLine() |> ignore
    while not sr.EndOfStream do
      let oneLine = sr.ReadLine()
      yield oneLine.Split(',') |> Array.map (fun (v : string) -> v.Trim(' ').Trim('"'))
  }

 ファイルを開き、先頭行を読み飛ばしたら後はファイルの終端まで行ごとに読みこみ、それをカンマで区切って文字列の配列とし、要素ごとに前後のスペースとダブルクォーテーションを除いたものを各要素とするシーケンスが作成されました。ここまでは一般的な実装だと思います。細かいことを言うと、要素の中にカンマが入っていることを想定する必要がある場合にはこの実装はNGです。その場合はもっとまじめにパーサを書く必要があります。

ファイルの書き込み

 書き込みはあまり難しいことを考える必要がありません。淡々と書きましょう。
let writeCsvDataToFile (outFile : string) (s : string array seq) =
  use outStream = new System.IO.StreamWriter(outFile, false, System.Text.Encoding.GetEncoding("shift_jis"))
  Seq.map (fun record -> Array.reduce (fun s v -> s + "," + v)) s
    |> Seq.iter (fun s -> outStream.WriteLine(s))
 ちなみに、この「Array.reduce (fun s v -> s + "," + v)) s」でCSV化する処理はとてもお気に入りです。手続き型のプログラミングだと、ループを回りつつ、最初や最後だけ特別扱いしてカンマを避ける必要がありますが、reduceを使うととてもすっきり書けるので。
 ヘッダ出力は割愛しています。すみません。

チェック関数を書く


 次に、実際にチェックを行う関数を書いていきましょう。そんな末端から・・・という気もするかもしれませんが、F#は定義が先に書かれていない関数を呼び出すことができません。中身はなくとも関数の引数と戻り値の型まではここで決めて大枠だけでも書いておく必要があります。

 まずは、ある項目の中身がなかった場合は、それを"0"として与える関数を想定します。
 チェック対象の項目の値が渡されたら、空白かどうか調べてその場合はゼロを返します。

let null2Zero value = 
  if value = "" then "0" else value

 簡単ですね。とりあえず先を急ぎましょう。

 次には、二つの項目の値を足した値を自分の項目とする関数を考えます。足し合わせる項目は二つのインデックス番号で与えられるものとします。数値でなければ例外を投げるようにしましょう(手抜きですが)。
let adder i1 i2 (ar : string []) =
  let val1 = Int32.TryParse(ar.[i1])
  let val2 = Int32.TryParse(ar.[i2])
  match val1, val2 with
  | (false, _), _ -> failwithf "数値に変換できない値があります(%s)" (ar.[i1])
  | _, (false, _) -> failwithf "数値に変換できない値があります(%s)" (ar.[i2])
  | (_, v1), (_,v2) -> string (v1 + v2)

 こういう関数は通常テストファーストで書きます。先にテストを書いてから関数を書くのです。末端の関数はテストが書きやすいこともありますが、何よりこういうプログラムではバグが出るとすれば大抵このような末端の関数だからなのです。ちなみにインデックスの範囲チェックなども本当は必要です(割愛しますが)。

 最後に条件を満たしていない場合は例外を出す関数を書いてみましょう。

let checker (f : string -> string[] -> string option) value (ar : string []) =
  match f value ar with
  | Some err -> failwith err
  | None -> value

 関数を引数にとって、その関数に項目の値と行全体を渡したら、エラーの場合Some にエラーを包んで返してくれる関数です。

と、ここまで作ってきて、これらの関数は行を処理する関数から共通インターフェースで呼ばれる必要があるので、引数をそろえましょう。とは言っても揃えるのは戻り値の型と、引数の最後のいくつかだけです。項目の値と行全体の値の療法がないと処理できない関数があるため、すべての関数でそれを引数とするように調整します。不要なものは(_)アンダーバーで受けるようにするのです。

let null2Zero value _ = 
  if value = "" then "0" else value

let adder i1 i2 _ (ar : string []) =
  let val1 = Int32.TryParse(ar.[i1])
  let val2 = Int32.TryParse(ar.[i2])
  match val1, val2 with
  | (false, _), _ -> failwithf "数値に変換できない値があります(%s)" (ar.[i1])
  | _, (false, _) -> failwithf "数値に変換できない値があります(%s)" (ar.[i2])
  | (_, v1), (_,v2) -> string (v1 + v2)

let checker (f : string -> string[] -> string option) value (ar : string []) =
  match f value ar with
  | Some err -> failwith err
  | None -> value

 これでチェック関数はとりあえず揃いました。実際に仕事で使う場合は、このような関数を必要なだけ追加していくことになります。

チェック内容をリストとして記述する

 さて、作成した関数を使用してチェック内容を記述していくことにしましょう。
 チェック内容は、CSVの項目ごとのリストとします。項目ごとにチェック内容を複数持てるよう、これもリストの形にします。

[[項目1のチェック1;項目1のチェック2];[項目2のチェック1];[];[項目4のチェック1;項目4のチェック2]]

 こんな形でチェック内容を記述できるようにしておきます。サンプルになるようなものを書いてみましょう。

let checks = 
  [
    [
      null2Zero;
      checker (fun v _ -> if List.exists ((=) v) ["0"; "1"; "2"] then None else Some "許容値エラー" )
    ];
    [
      null2Zero;
      checker (fun v (ar : string []) -> if ar.[0] = "1" && v <> "0" then None else Some "関連チェックエラー")
    ];
    [
    ];
    [
      adder 0 1
    ]
  ]

 ここで注目して欲しい点が一つあります。各チェック関数の引数は異なっていましたが、このようにオプションにあたるものを先に部分適用させることで、リストの要素としてはすべて同じ型の要素になっていることです。そうしないと一つのリストに収めることができません。
 ちなみにやってることは、
  • 最初の項目は、空白なら0に変換してから、それが"0","1","2"のいずれかでない場合はエラーとする。
  • 二番目の項目は、やはり空白なら0に変換してから、最初の項目が"0"の場合に限り自分が"0"あった場合はエラーとする。
  • 三番目の項目は特になにもしない。
  • 四番目の項目は、最初の項目と二番目の項目を足した値にする。
 というような業務を想定しています。

このリストの型は以下のようになります。

(string -> string [] -> string) list list

 内側のリストは一つの項目の中でのチェックのリストで、外側のリストは項目自体のリストになっています。これが今回のテーマとなっている「大がかりな型」で、このような「関数のリストのリスト」というような型を見て一歩も退かない精神力が、関数型プログラマには必要です。実際の業務ではこれどころではない大がかりな型が使われます。

 ここまでで、必要な業務を記述することはできるようになりましたので、次は最初に読みこんだ配列と、このリストを引数にとって実際にチェックを行うプログラムを書いていきます。

型を解きほぐす


 実はここまでのこの作業で、ほとんどプログラミングは終わっているようなものなのです。完成までのプログラムは「ほぼ」運命づけられているといってもいいかもしれません。これぐらいの型が入力にあれば、できることはかなり限られており、いくつかの分岐点で判断を誤らなければゴールは約束されたようなものです。

 さて、チェックするプログラムの入力は、文字列配列のシーケンス「string array seq」と先ほどの「(string -> string [] -> string) list list」になります。

 出力するのは入力と同じ形のデータなので、プログラムは入力関数と出力関数の間にSeq.mapを挟んだものになることは容易に予想できます。Seq.mapにはとりあえずid関数などを抱かせて黙らせておき深呼吸します。

let csvOperation inFile outFile =
  getCsvDataFromFile inFile
    |> Seq.map id
    |> writeCsvDataToFile outFile

 さて、次にやるべきことは何でしょうか。とりあえずidを(fun ar -> ar)などと書き換え、どうするか考えます。CSVの行方向の繰り返しであったSeqの一皮はすでに向けているので、その内側のカラムのループを剥くことにします。やはり出力の型は同じなので、基本を押さえてmapで剥きます。

let csvOperation inFile outFile =
 getCsvDataFromFile inFile
   |> Seq.map (fun record -> Array.map id record)
   |> writeCsvDataToFile outFile

 再びmapにidを抱かせていますが、これ以上ネストが深くなるのはヤバイ感じがするので、別の関数columnOperationに出すことにしましょう。

let columnOperation value ar = value

let ccsvOperation inFile outFile =
  getCsvDataFromFile inFile
    |> Seq.map (fun record -> Array.map (fun value -> columnOperation value record) record)
    |> writeCsvDataToFile outFile

 これでcolumnOpeartionのことだけを考えればよくなりました。ただし、カラムのチェックには行全体が必要な関数があるため、recordも引数に一緒に渡すようにしました。
 今は引数のvalueを返しているだけですが、これがチェックと変換を通ったものが返るようにすればいい訳です。

 ここまで書いてみて、チェックを記述したリストを見てみます。このリストは、カラムの順番ごとにチェック内容が並んでいるのですが、columnOperationにはすでに一つのカラムの値が来ているのですから、この段階でこのリストを参照するのは遅すぎのように思えます。どこかで間違えたようなので戻りましょう。
 さっきのArray.mapは、カラムごとに特定の処理をさせることが目的だったのですが、目的となっている処理も項目ごとにリストになっているのでややこしいことになっています。こういう場合、ともにListならばmap2なりを使えばいいのですがこの場合そうも行きません。幸いarrayの方はインデックスがわかればランダムアクセスができますので、融通の利かないListの方でループを回るように書きなおしてみます。

let columnOperation value ar checks = value

let csvOperation inFile outFile checkers =
  getCsvDataFromFile inFile
    |> Seq.map (fun record -> 
         List.mapi (fun idx checks -> columnOperation record.[idx] record checks) checkers |> List.toArray) 
    |> writeCsvDataToFile outFile

 こうすれば、チェックリストに書いてある項目ごとにレコードから項目を抜き出して項目ごとのチェックをcolumnOperationの中で行うことができます。
 さて、最後にcolumnOperationの中身を考えることにしましょう。ここでchecksに渡ってきているのは「(string -> string [] -> string) list」という形のリストです。このチェックの場合は最初の関数の結果を次の関数にリレーしていきたいですから、foldを使うのが適当でしょう。引数は「まんま」ですね。

let columnOperation value ar checks = 
  List.fold (fun stat check -> check stat ar) value checks

 単純にはこれでいいのですが、一つ注意点があります。チェックの過程で、後ろの項目のチェックや変換には前の項目の変換結果を参照するということです。これを実現するために、一つの項目の変換が終わったら配列を書き換えておく必要があります。先ほどのcsvOperationにもうひと手間加えましょう。

let csvOperation inFile outFile checkers =
  getCsvDataFromFile inFile
    |> Seq.map (fun record -> 
          List.mapi (fun idx colchks -> let value = columnOperation record.[idx] record colchks
                                        record.[idx] <- value
                                        value
                    ) checkers |> List.toArray) 
    |> writeCsvDataToFile outFile

 破壊的な処理になってしまいましたが、これを避けようとすると結構面倒なことになる上、速度にも影響が出ると思いますのでこれはよしとしましょう。

まとめ

 実際にこういうプログラムを使う場合は、チェックを記述したリストchecksのような関数をfsxファイルに追い出し、fsxファイルの中からcsvOperationsを呼び出すようにして、checks以外の関数はライブラリとしてdllにしてしまい、#rでロードして実行するようにします。こうすることで、業務に関連したチェック一覧だけを必要に応じて修正して実行することができるようになります。詳しくはサンプルファイルを参照してください。

 今回のチェックプログラムに現れた「関数のリストのリスト」というような型は、関数型プログラミングならではと言えなくもないのですが、これは見方を変えると「オブジェクト指向プログラミングにおけるポリモルフィズム」にとてもよく似たことをやっているとも言えます。切り口が異なっているだけと言ってもいいかもしれません。
 ただ、これをListやSeqのmapやfoldを使って書こうとすると、それぞれの型のListやSeqを、タマネギの皮をむくように一枚一枚めくりながら戻り値として何が求められているかを考えると必然的にプログラムが出来上がってしまうところに関数型プログラミングの面白さがあります。そうした「できることの少なさ」が、関数型プログラミングの堅牢さに結びついているのだと思わされるのです。

↓今回のサンプルソースはこちらからどうぞ。
FsAdvCal2013.zip

(文責:片山 功士  2013/12/16)

今日: -
昨日: -
トータル: -
最終更新:2013年12月17日 00:32
添付ファイル