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