Tagless-final + EffでExcelシートをパーズする
はじめに
過去のTagless-final + EffなScalaによるExcelパーザーはTagless-finalやExtensible Effectsを駆使したExcelパーザーを作成したが、これはあくまでも1つの行を狙ってパーズするようになっていた。
case class HelloExcel(
a1: String,
a2: Option[String],
a3: Double,
a4: List[String]
)
val actual = ExcelRowReads[R, HelloExcel]
.parse
.runReader(row)
.evalState(0)
.run
このようなコードで次のようなExcelシートをパーズできる。
図1. 1種類の行データで構成されたExcelシート
このままでも使えるが、ExcelはCSVなどの完全なデータフォーマットとは少し違って、人間が手で入力することを考慮しなければならないため、たとえば次の画像のように行ごとに異なるパターンのデータが挿入されていることもある。
図2. 複数のフォーマットを内包したExcelシート
そういった事情を考慮して、より使いやすくするために行ではなくExcelのシートを直接パーズできるようなExcelSheetReads
の追加を行った。この記事ではそのExcelSheetReads
の構造について解説する。また、今回の対応にあたってついでにExcelパーザーのソースコード全体をScala 3対応したので、その部分のコードを紹介する。
この記事で紹介するコードは簡単のため一部を抜き出すなどの加工をしているが、完全なものが下記のGitHubリポジトリーに置かれている。
この記事を読んで疑問や改善点などがあると思った場合、気軽にコメントなどで教えてほしい。
使い方
先程の図2のようなシートをExcelSheetReads
で次のようにパーズできる。
case class Header(
a1: String,
a2: String,
a3: Int,
a4: Boolean
)
val workbook = WorkbookFactory.create(
new File("/test.xlsx")
)
val sheet = ApachePoiSheet(workbook.getSheet("Sheet1"))
ExcelSheetReads
.parse[
R, // Effect stack
Header,
List[Int],
Optional[Boolean],
SkipOnlyEmpties,
(String, String)
]
.runReader(ApachePoiSheet(sheet))
.evalState(0)
.runEither
.run
(
Header("Hello", "Excel", 1, true),
List(1, 2, 3, 4, 5),
Some(true),
1, // スキップした行数
("Good", "Bye!")
)
このように型パラメーターとして行の構造となる型を指定できるうえ、Optional
やSkipOnlyEmpties
など同じフォーマットの行が何個くらい続く可能性があるかも型で指定できるようになっている。
さらに、次の画像にあるシートように特定のデータが繰り返されるようなシートは次のようにExcelSheetReads.loop
でパーズできる。
図3. 複数の文字列と複数の数値が交互に連続するシート
ExcelSheetReads
.loop[
R,
List[String],
List[Int]
]
.runReader(ApachePoiSheet(sheet))
.evalState(0)
.runEither
.run
List(
(List("Hello", "Excel"), List(1, 2, 3)),
(List("This", "is", "a", "pen."), List(9, 8, 7, 6))
)
ExcelSheetReads
型クラスExcelシートをパーズするための型クラスExcelSheetReads
は次のようになる。
abstract class ExcelSheetReads[R, A] { self =>
type Result
def parse(implicit
m1: State[Int, *] |= R,
m2: Either[ExcelParseErrors, *] |= R
): Eff[R, Result]
}
行をパーズするExcelRowReads
[1]と比べて、返り値が型メンバーとして各インスタンスで具体的に与えられるのが特徴的となっている。
また、インスタンスを作る際の便利メソッドfrom
を次のように作っておく。
object ExcelSheetReads {
def from[R, A, B](
f: State[Int, *] |= R => Either[ExcelParseErrors, *] |= R => Eff[R, B]
): ExcelSheetReads[R, A] = new ExcelSheetReads[R, A] {
type Result = B
def parse(implicit
m1: State[Int, *] |= R,
m2: Either[ExcelParseErrors, *] |= R
): Eff[R, B] =
f(m1)(m2)
}
}
from
の第1・第2型パラメーターはExcelSheetReads
の型パラメーターとして利用され、そして第3パラメーターはExcelSheetReads.parse
の返り値の型となる型メンバーResult
として利用される。
パーズ結果の型がなぜこのように型メンバーとなっているのかというと、さきほど例でSkipOnlyEmpties
という空行をスキップする特殊な型を導入したことに由来する。詳細な説明はともかく、空行をスキップするパーザーExcelSheetReads[R, SkipOnlyEmpties]
があったとして、これのパーズ結果はSkipOnlyEmpties
とは関係がない型であるInt
(スキップした行数)となる。このような表現のために型メンバーResult
を返り値の型にしたうえで、具体的なResult
は各インスタンス定義で与えることで拡張可能にしている。こうすればユーザーが別のExcelSheetReads[R, SkipOnlyEmpties]
インスタンスとして返り値がInt
ではないものを定義することもできる。
シートの行に対する操作
Excelシートのある行に対するパーズであるExcelRowReads
では、行データからセルに関する情報を取得するため、Tagless-finalスタイルで記述されたExcelBasicSYM
などのインターフェースが存在した。これと同様にExcelシートに対して、特定の行に対する操作のインターフェースとして次のようなExcelRowSYM
を定義する[2]。
abstract class ExcelRowSYM[Row, R, F[_]: Monad] {
def isEmpty: F[Boolean]
def isEnd: F[Boolean]
def getRow: F[Row]
def withRow[A](
reads: ExcelRowReads[R, A]
): F[Either[ExcelParseErrors, A]]
}
今回は以前の記事のExcelBasicSYM
とは異なり、現在の行カウンターもF
から受け取ることにしたため、引数が存在しない。
また、withRow
が多少非自明な型となっている。withRow
は次のような振る舞いとなる。
-
reads
によるパーズが成功した場合はその結果であるF[Right[A]]
な値を返す -
reads
によるパーズが失敗した場合はF[Left[ExcelParseErrors]]
を返す
なぜこのような失敗をF
に詰め込まないのかというと、インスタンス定義においてこのEither
をパターンマッチで分岐させたい局面が存在するからである。
また、ExcelRowSYM
などと比べて型パラメーターが多いが、これはExcelSheetReads
のインスタンスを説明するときに解説する。
型パラメーターによる行データ指定方法
ExcelSheetReads.loop
/.parse
はどちらも型パラメーターとして行を表すデータ構造を受け取り、それに従って行をパーズしていく。まずはこのメカニズムがどうなっているのか?について解説する。
ExcelSheetReads.loop
/.parse
は主に次の2種類の型をパーズすべき行データとして受け取ることができる。
- 行を表す任意のデータ型
A
(=ExcelRowReads[R, A]
のインスタンスが存在する) - 空行や繰り返しなどの特殊な型
これら2パターンについて順番に説明する。
ExcelRowReads
を利用した1行のパーズ
ExcelRowSYM
で定義したwithRow
と便利メソッドfrom
を利用すれば、次のように書ける。
implicit def aInstance[Row, R, U, A](implicit
sym: ExcelRowSYM[Row, U, Eff[R, *]],
reads: ExcelRowReads[U, A]
): ExcelSheetReads[R, A] =
ExcelSheetReads.from[R, A, A] { implicit m1 => implicit m2 =>
for {
s <- get
a <- sym.withRow(reads).flatMap(fromEither[R, ExcelParseErrors, A])
_ <- put(s + 1)
} yield a
}
今回はエフェクトスタックR
にEither
が積んであるため、このようにすればパーズ失敗で簡単に全体を停止させることができる。
ここでExcelRowSYM
の型パラメーターについて説明が必要になる。ExcelRowSYM[Row, U, Eff[R, *]]
は上記のように3つの型パラメーターを取っているが、2番目の型パラメーターが必要となるのはwithRow
のみであるから、次のようにwithRow
の型パラメーターにしてもよいように見える。
def withRow[R, A](
reads: ExcelRowReads[R, A]
): F[Either[ExcelParseErrors, A]]
しかし、このようにするとsym: ExcelRowSYM[Row, U, Eff[R, *]]
とreads: ExcelRowReads[U, A]
が同じU
であることを保証できなくなってしまう。したがってこのようにimplicitパラメーターsim
が探索されたと同時に、同じ型パラーメーターに対応するreads
を探索させるためにインターフェースから型を注入する設計とならざるを得ない。
空行や繰り返しのパーズ
次に空行や繰り返しのパーズを説明する。
ExcelRowQuantifier
空行や繰り返しなどを表す型:空行などを表すために、次のような特殊な型が用意されている。
object ExcelRowQuantifier {
case class Optional[A]()
case class Many[A]()
case class Skip()
case class SkipOnlyEmpties()
case class End()
}
これらについて簡単な説明を与える。
ExcelRowQuantifier |
効果 |
---|---|
Optional[A] |
Excel行がデータ型A としてパーズできる、または空行 |
Many[A] |
データ型A としてパーズできる行が0以上の任意の回数繰り返す |
Skip |
現在の行をパーズせずにスキップする |
SkipOnlyEmpties |
空行をスキップする |
End |
Excelシートの終端行にマッチする |
ここでsealed trait ExcelRowQuantifier
のような型が存在しないことがポイントである。もしExcelRowQuantifier
のような基底トレイトが存在してしまった場合、たとえば「1つ以上の繰り返しであるMoreThanOne[A]
」を独自に定義できなくなる恐れがある。ExcelReadsは一貫してTagless-finalスタイルを利用してきたため、型の一覧が事前に定義されていることを前提としたプログラミングをしない。したがってこのようになっている。
インスタンス定義
さて、これらのインスタンス定義について見ていく。
End
とSkip
これら2つは比較的簡単なのでまずはこれらから見ていく。
implicit def endInstance[Row, R, A](implicit
sym: ExcelRowSYM[Row, _, Eff[R, *]]
): ExcelSheetReads[R, End] =
ExcelSheetReads.from[R, End, Boolean] { implicit m1 => implicit m2 =>
for {
isEmptyAndEnd <- sym.isEmpty product sym.isEnd
} yield isEmptyAndEnd._1 && isEmptyAndEnd._2
}
このようにExcelRowSYM
のisEmpty
とisEnd
を&&
するだけである。ExcelSheetReads[R, End]
はパーズ結果としてfrom
の第3型パラメーターであるBoolean
を出力する。これは終端行であればtrue
であり、そうでなければfalse
となるためである。
次にExcelSheetReads[R, Skip]
について見ていく。
implicit def skipInstance[Row, R, A]: ExcelSheetReads[R, Skip] =
ExcelSheetReads.from[R, Skip, Unit] { implicit m1 => implicit m2 =>
for {
s <- get
_ <- put(s + 1)
} yield ()
}
このようにパーズは行わずEff
にステートモナドとして埋め込まれた行のカウンターをインクリメントさせるだけとなっている。
SkipOnlyEmpties
このあたりから再帰を利用した面倒なインスタンスになっていく。
implicit def skipOnlyEmptiesInstance[Row, R, A](implicit
sym: ExcelRowSYM[Row, _, Eff[R, *]]
): ExcelSheetReads[R, SkipOnlyEmpties] = {
def loop(
skipLineCount: Int
)(implicit
m1: State[Int, *] |= R,
m2: Either[ExcelParseErrors, *] |= R
): Eff[R, Int] =
for {
s <- get
isEmptyAndEnd <- sym.isEmpty product sym.isEnd
result <-
if (isEmptyAndEnd._1 && !isEmptyAndEnd._2) {
put(s + 1) >> loop(skipLineCount + 1)
} else {
Eff.pure[R, Int](skipLineCount)
}
} yield result
ExcelSheetReads.from[R, SkipOnlyEmpties, Int] { implicit m1 => implicit m2 =>
loop(0)
}
}
このように、現在の行がisEmpty == true
かつisEnd == false
であれば行カウンターをインクリメントする、という操作を再帰的に実行する。そして結果としてスキップした行数(Int
)をパーズ結果として返すようになっている。
Optional[A]
Optional[A]
は現在の行カウンターから読み込んだデータを型A
としてパーズできるか試み、可能であればSome[A]
となり失敗であればNone
となる。したがって結果の型はOption[A]
となる。また、もしreads
でのパーズが失敗した場合もNone
となり行カウンターは更新しない。
implicit def optionalInstance[Row, R, U, A](implicit
sym: ExcelRowSYM[Row, U, Eff[R, *]],
reads: ExcelRowReads[U, A]
): ExcelSheetReads[R, Optional[A]] =
ExcelSheetReads.from[R, Optional[A], Option[A]] { implicit m1 => implicit m2 =>
for {
s <- get
isEmpty <- sym.isEmpty
result <-
if (isEmpty) {
Option.empty[A].pureEff[R]
} else {
for {
ae <- sym.withRow(reads)
result <- Eff.traverseA(ae) { a =>
put(s + 1).map(_ => a)
}
} yield result.fold(
_ => Option.empty[A],
x => Option(x)
)
}
} yield result
}
このようにExcelRowSYM.withRow
の結果に対してEff.traverseA
をすることで、Right
のときのみ行カウンターを更新する振る舞いとなっている。withRow
がもしパーズエラーをEff
のエフェクトとして取り扱ってしまうと、この部分でインタープリター起動が必要となってしまうので、withRow
はEff
の結果としてEither
を返すようにしてある。
Many[A]
最後のインスタンスであるExcelSheetReads[R, Many[A]]
は次のように既存の2つのインスタンスを足したような複雑なものとなる。
-
Optional[A]
のインスタンスのように型パラメーターA
のExcelRowReads
インスタンスを利用し -
SkipOnlyEmpties
のインスタンスのように再帰関数を利用する
こちらもOptional
と同様にreads
でのパーズが失敗した場合はそこで打ち切って結果を返すことにしていて、直ちに失敗とはならないようにしてある。
implicit def manyInstance[Row, R, U, A](implicit
sym: ExcelRowSYM[Row, U, Eff[R, *]],
reads: ExcelRowReads[U, A]
): ExcelSheetReads[R, Many[A]] = {
def loop(
as: Seq[A]
)(implicit
m1: State[Int, *] |= R,
m2: Either[ExcelParseErrors, *] |= R
): Eff[R, Seq[A]] =
for {
s <- get
isEmpty <- sym.isEmpty
result <-
if (isEmpty) {
as.pureEff[R]
} else {
for {
ae <- sym.withRow(reads)
result <- Eff.traverseA(ae) { a =>
put(s + 1) >> loop(as :+ a)
}
} yield result.fold(
{ _ => as },
identity
)
}
} yield result
ExcelSheetReads.from[R, Many[A], Seq[A]] { implicit m1 => implicit m2 =>
loop(Seq.empty[A])
}
}
与えられた複数の型で繰り返しパーズ
ここからはExcelSheetReads.loop
について説明する。loop
はいくつかの行を表現する型をとって、それを繰り返しながらExcelシートをパーズする。
実はこれはloop
関数が型パラメーターの数ごとにオーバーロードするというゴリ押しとなっている。たとえば4行の場合は次のようになる[3]。
def loop[R: _state: _either, A, B, C, D](implicit
end: ExcelSheetReads[R, End],
r1: ExcelSheetReads[R, A],
r2: ExcelSheetReads[R, B],
r3: ExcelSheetReads[R, C],
r4: ExcelSheetReads[R, D]
): Eff[R, Seq[(r1.Result, r2.Result, r3.Result, r4.Result)]] =
loopInternal[R, (((A, B), C), D), (r1.Result, r2.Result, r3.Result, r4.Result)](
r1 andThen r2 andThen r3 andThen r4
) {
_.asInstanceOf[(((r1.Result, r2.Result), r3.Result), r4.Result)] match {
case (((a, b), c), d) => (a, b, c, d)
}
}
このようにボイラープレートをなるべく抑えるために次のようなloopInternal
/loop1
を相互再帰で利用している。
private def loopInternal[R, A, B](
reads: ExcelSheetReads[R, A]
)(
transform: reads.Result => B
)(implicit
m1: State[Int, *] |= R,
m2: Either[ExcelParseErrors, *] |= R,
end: ExcelSheetReads[R, End]
): Eff[R, Seq[B]] =
for {
isEnd <- end.parse
// This `isEnd`s pattern-matching is not rewritable instead of `if`.
// The type of `isEnd` is not `Boolean`, it's `end.Result`,
// so we have to fix that the `end.Result` is `Boolean` using pattern-matching.
result <- isEnd match {
case true =>
Seq.empty.pureEff[R]
case false =>
for {
a <- reads.parse
// This mutual recursion is required by detecting the `ts` type is `Seq[reads.Result]`.
ts <- loop1(m1, m2, end, reads)
} yield a +: ts
}
} yield result.map(transform)
private def loop1[R: _state: _either, A](implicit
end: ExcelSheetReads[R, End],
r1: ExcelSheetReads[R, A]
): Eff[R, Seq[r1.Result]] =
loopInternal[R, A, r1.Result](r1)(x => x)
コメントに書いてあるとおり、型が通らないためこのような相互再帰構造となっているが、実際はisEnd == true
となるまで再帰しているだけである。
ただしMany[A]
のインスタンスとは違って、reads.parse
の結果がLeft
であればその時点で全体が失敗となる。
Scala 3対応
前回の記事の段階ではScala 3への対応はされていなかったため、shapelessを利用したマクロ部分がコンパイル不可能であった。その部分はExcelRowReadsGenericInstances
として切り出してScala 2とScala 3で別々に用意することでScala 3でもコンパイル可能にした。説明が難しいので、興味がある場合は下記のコードを実際に見てほしい。
まとめ
前回の記事から結構大きく改造したが、これでExcelパーザーは機能的には最終的に完成したと思う。ただ一部asInstanceOf
を利用せざるを得なくなってしまったのが残念なので、技術的な課題としてこれを消去しても動くようにするというのが残されている。
また、今回のExcelパーザーにも依然として選択(正規表現の|
に相当)が存在しない。実はAlt[A, B]
のようなパーザーを作ってよいのかもしれない。
-
前回の記事では
ExcelReads
という名前であったが、シートをパーズする存在が生じたためExcelReads
が行をパーズするのかどうか分かりにくくなると考え、名前をExcelRowReads
とより行への指向性を強調した名前へと変更した。 ↩︎ -
前回の記事の状態と比較して、
Eff
の結果の型にあったValidatedNel[ExcelParseError, *]
はエフェクトスタックR
へと移動させ、その関係でアプリカティブというよりもモナドなEither[NonEmptyList[ExcelParseError], *]
となっている。なおExcelParseErrors
はNonEmptyList[ExcelParseError]
のエイリアスである。 ↩︎ -
なぜかここは
asInstanceOf
をするしかなかった……。 ↩︎
Discussion