固定長形式のテキストの一行を、shapeless でケースクラスにマッピングする方法を考えてみた。
動機
『Think Stats-プログラマのための統計入門』では、第1章の分析対象が固定長形式のテキストデータとして書かれている(NSFGの'2002FemResp.dat'、ファイル形式はここ)。
固定長形式とは、1レコードを1行とし、各フィールドのデータを固定の開始・終了桁で示した位置に書きこんだもの。
この形式のファイルを読み込む Python コードが、書籍のサイトで提供されているが、同じことを Scala + shapeless で宣言的に書いてみたい。
方針
簡単のために、以下のようなシンプルなケースクラスIceCream
で考えてみる(『The Type Astronaut's Guide to shapeless』から借用)。
case class IceCream(name: String, numCherries: Int, inCone: Boolean)
このインスタンスを、以下のようなフォーマットのテキスト行をデコードして生成してみたい。
- name: 1桁~12桁
- numCherries: 13桁~14桁
- inCone: 15桁~17桁(yes または no)
(shapeless は 2.12の 2.3.3、ソースはここ)
やり方
まず行の指定の位置から、変換関数を用いて値を抽出する関数を返す高階関数extract
を、以下のように定義する。
def extract[A](start: Int, end: Int, convert: String => A): String => A =
line => convert(line.substring(start - 1, end).trim)
これを使ってフィールドごとに抽出関数を作って、関数のHList
とする。
val extractors =
extract(1, 12, identity) ::
extract(13, 14, _.toInt) ::
extract(15, 17, _ == "yes") ::
HNil
これを行に適用して変換結果フィールド値のHList
を得るには、同じ要素数だけ行を繰り返したHList
を作って引数のHList
とし、extractors
とはり合わせる形で関数適用する。
指定数だけ行を繰り返して引数のHList
とするには以下のような関数を使う。
def repeat[I <: HList, L <: Nat, O <: HList](in: I, s: String)(
implicit
len: Length.Aux[I, L],
rep: Repeat.Aux[String :: HNil, L, O]
): O = rep(s :: HNil)
「関数のHList
」を「引数のHList
」に適用するには、zipApply
を使い、最後にGeneric
を使ってIceCream
に変換する。まとめると次のような関数になる。
def lineToIceCream(line: String): IceCream =
Generic[IceCream].from(extractors zipApply repeat(extractors, line))
以下のように確認できる。
val lines = List (
"Sundae 1no ", // IceCream(Sundae, 1, false),
"Cornetto 13yes", // IceCream(Cornetto, 13, true),
"Banana Split 0no ") // IceCream(Banana Split, 0, false)
.map(lineToIceCream)
所感等
-
命令型のコーディングだと、フィールド定義をループしながら一個ずつ値を抽出・変換して、結果のオブジェクトに書き込んで更新する形になるが、Scala + shapeless だとかなり宣言的に書ける。
-
IceCream
と同様に NSFG のデータ 13593件も問題なくデコードできる。 -
FS2などを用いてStreaming I/O的にレコードの流れを扱う方法などは、別の機会にやってみたい。
まとめ
型レベルプログラミングも宣言的なコーディングに役に立つ。