🧾

Tagless-final + EffでExcelシートをパーズする

2022/01/30に公開

はじめに

過去の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!")
)

このように型パラメーターとして行の構造となる型を指定できるうえ、OptionalSkipOnlyEmptiesなど同じフォーマットの行が何個くらい続く可能性があるかも型で指定できるようになっている。
さらに、次の画像にあるシートように特定のデータが繰り返されるようなシートは次のように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は次のようになる。

ExcelSheetReads.scala
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を次のように作っておく。

ExcelSheetReads.scala
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]

ExcelRowSYM.scala
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種類の型をパーズすべき行データとして受け取ることができる。

  1. 行を表す任意のデータ型A(= ExcelRowReads[R, A]のインスタンスが存在する)
  2. 空行や繰り返しなどの特殊な型

これら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
  }

今回はエフェクトスタックREitherが積んであるため、このようにすればパーズ失敗で簡単に全体を停止させることができる。
ここで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

空行などを表すために、次のような特殊な型が用意されている。

ExcelRowQuantifier.scala
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スタイルを利用してきたため、型の一覧が事前に定義されていることを前提としたプログラミングをしない。したがってこのようになっている。

インスタンス定義

さて、これらのインスタンス定義について見ていく。

EndSkip

これら2つは比較的簡単なのでまずはこれらから見ていく。

ExcelSheetReadsInstances.scala
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
  }

このようにExcelRowSYMisEmptyisEnd&&するだけである。ExcelSheetReads[R, End]はパーズ結果としてfromの第3型パラメーターであるBooleanを出力する。これは終端行であればtrueであり、そうでなければfalseとなるためである。

次にExcelSheetReads[R, Skip]について見ていく。

ExcelSheetReadsInstances.scala
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

このあたりから再帰を利用した面倒なインスタンスになっていく。

ExcelSheetReadsInstances.scala
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となり行カウンターは更新しない。

ExcelSheetReadsInstances.scala
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のエフェクトとして取り扱ってしまうと、この部分でインタープリター起動が必要となってしまうので、withRowEffの結果としてEitherを返すようにしてある。

Many[A]

最後のインスタンスであるExcelSheetReads[R, Many[A]]は次のように既存の2つのインスタンスを足したような複雑なものとなる。

  • Optional[A]のインスタンスのように型パラメーターAExcelRowReadsインスタンスを利用し
  • SkipOnlyEmptiesのインスタンスのように再帰関数を利用する

こちらもOptionalと同様にreadsでのパーズが失敗した場合はそこで打ち切って結果を返すことにしていて、直ちに失敗とはならないようにしてある。

ExcelSheetReadsInstances.scala
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]

ExcelSheetReadsParseLoop.scala
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]のようなパーザーを作ってよいのかもしれない。

脚注
  1. 前回の記事ではExcelReadsという名前であったが、シートをパーズする存在が生じたためExcelReadsが行をパーズするのかどうか分かりにくくなると考え、名前をExcelRowReadsとより行への指向性を強調した名前へと変更した。 ↩︎

  2. 前回の記事の状態と比較して、Effの結果の型にあったValidatedNel[ExcelParseError, *]はエフェクトスタックRへと移動させ、その関係でアプリカティブというよりもモナドなEither[NonEmptyList[ExcelParseError], *]となっている。なおExcelParseErrorsNonEmptyList[ExcelParseError]のエイリアスである。 ↩︎

  3. なぜかここはasInstanceOfをするしかなかった……。 ↩︎

Discussion