nがひとつ多い。

えぬなおの技術的なことを書いていくとこ。

【Scala】catsのValidatedの使い所、及びValidatedNecとValidatedNelについて

初めに

本記事は Scala Advent Calendar 2019の12日目の記事の代行です。 仕事でなんとなく使っていた、 Scalaの関数型ライブラリのcatsのバリデーションモナドのValidatedNecとValidatedNelの違いについて勉強していきます。 typelevel.org

catsのValidateについて

今日のプログラミングでは「バリデーション」という機能は重要です。 パスワード、メールアドレス、ユーザ名など・・・サービスの品質を高めるために重要なアクションです。

catsを利用したScalaのような型クラスプログラミングがしていく時に、 「検査したかどうか」を型で表現できると複雑な検査項目も安全にエラーハンドルするようにできるのがValidatedNecとValidatedNelです。

Validated, ValidatedNec, ValidatedNelについて

Validated

sealed abstract class Validated[+E, +A] extends Product with Serializable {
  // Implementation elided
}

ジェネリクスな抽象クラスがValidatedで、左に検査エラーが、右に検査が通った時の値が入ります。

final case class Valid[+A](a: A) extends Validated[Nothing, A]
final case class Invalid[+E](e: E) extends Validated[E, Nothing]

この Validated は検査がOKだったことを表すValid[+A](a: A) 、ダメだったことを表すInvalid[+E](e: E) があり、 それぞれ Validated を継承しているので、パターンマッチによって安全に検査内容を取り出すことができます。

import cats.data.Validated
import cats.data.Validated.{Invalid, Valid}

case class Config(map: Map[String, String]) {
  def parse[A : Read](key: String): Validated[ConfigError, A] =
    map.get(key) match {
      case None        => Invalid(MissingConfig(key))
      case Some(value) =>
        Read[A].read(value) match {
          case None    => Invalid(ParseError(key))
          case Some(a) => Valid(a)
        }
    }
}

(すごい・・・ Either みたいです・・・w)

このように検査エラーは検査内容を破棄せずとも、検査結果を一つの Validated クラスとして表現することができました。 さて、Validatedには ValidatedNecValidatedNel というエイリアスがあり、微妙な違いがあるのでそれにもに触れていきます

ValidatedNel、 ValidatedNec

公式ドキュメントに読むと、

ValidatedNec[DomainValidation, A] is an alias for Validated[NonEmptyChain[DomainValidation], A]. Typically, you’ll see that Validated is accompanied by a NonEmptyChain when it comes to accumulation. The thing here is that you can define your own accumulative data structure and you’re not limited to the aforementioned construction.

ValidatedNec [DomainValidation、A]はValidated [NonEmptyChain [DomainValidation]、A]のエイリアスです。通常、Validatedには蓄積に関してNonEmptyChainが付随していることがわかります。ここで重要なことは、独自の累積データ構造を定義でき、前述の構造に限定されないということです。

NonEmptyChainとは、catsのChainに関わってくることで、空じゃないChainを表します。

typelevel.org

多分そんな深入りしない場合は、高速なコレクションみたいなニュアンスで知っておけばいいと思います。

scala> NonEmptyChain(1, 2, 3, 4).toChain.map(println)
1
2
3
4

つまり、leftに入れるような検査エラー系を蓄積できるのがValidatedNecというわけです。

ちなみにValidatedNelはドキュメントでは触れられてないですが、ValidatedNecのChainがListになったバージョンです。 なのでこのブログでもValidatedNelは省略していきます。

type ValidatedNel[+E, +A] = cats.data.Validated[cats.data.NonEmptyList[E], A]
type ValidatedNec[+E, +A] = cats.data.Validated[cats.data.NonEmptyChain[E], A]

さて例をみていきます。

import cats.SemigroupK
import cats.data.NonEmptyChain
import cats.implicits._

trait Read[A] {
  def read(s: String): Option[A]
}

object Read {
  def apply[A](implicit A: Read[A]): Read[A] = A

  implicit val stringRead: Read[String] =
    new Read[String] { def read(s: String): Option[String] = Some(s) }

  implicit val intRead: Read[Int] =
    new Read[Int] {
      def read(s: String): Option[Int] =
        if (s.matches("-?[0-9]+")) Some(s.toInt)
        else None
    }
}

sealed abstract class ConfigError
final case class MissingConfig(field: String) extends ConfigError
final case class ParseError(field: String) extends ConfigError

case class ConnectionParams(url: String, port: Int)

val config = Config(Map(("endpoint", "127.0.0.1"), ("port", "not an int")))

implicit val necSemigroup: Semigroup[NonEmptyChain[ConfigError]] =
  SemigroupK[NonEmptyChain].algebra[ConfigError]

implicit val readString: Read[String] = Read.stringRead
implicit val readInt: Read[Int] = Read.intRead

上記の例では、何かの設定でendpointのアドレス、ポート番号の検査をする時で、

def parallelValidate[E, A, B, C](v1: Validated[E, A], v2: Validated[E, B])(f: (A, B) => C): Validated[E, C] =
  (v1, v2) match {
    case (Valid(a), Valid(b))       => Valid(f(a, b))
    case (Valid(_), i@Invalid(_))   => i
    case (i@Invalid(_), Valid(_))   => i
    case (Invalid(e1), Invalid(e2)) => ???
  }

val v1 = parallelValidate(config.parse[String]("url").toValidatedNec,
                          config.parse[Int]("port").toValidatedNec)(ConnectionParams.apply)
// v1: cats.data.Validated[cats.data.NonEmptyChain[ConfigError],ConnectionParams] = Invalid(Chain(MissingConfig(url), ParseError(port)))

val v2 = parallelValidate(config.parse[String]("endpoint").toValidatedNec,
                          config.parse[Int]("port").toValidatedNec)(ConnectionParams.apply)
// v2: cats.data.Validated[cats.data.NonEmptyChain[ConfigError],ConnectionParams] = Invalid(Chain(ParseError(port)))

val config = Config(Map(("endpoint", "127.0.0.1"), ("port", "1234")))
// config: Config = Config(Map(endpoint -> 127.0.0.1, port -> 1234))

val v3 = parallelValidate(config.parse[String]("endpoint").toValidatedNec,
                          config.parse[Int]("port").toValidatedNec)(ConnectionParams.apply)
// v3: cats.data.Validated[cats.data.NonEmptyChain[ConfigError],ConnectionParams] = Valid(ConnectionParams(127.0.0.1,1234))

例では v1は "url" のような存在しないフィールドはMissingConfigクラス、 "not an int" のような文字列をintにパースしたときはParseErrorクラスを内包したChainにスタックさせてInvalidで返します。

v2は"port" だけおかしいのでParseErrorクラスのみ、v3は検査を成功した例です。 このようにValidatedNecは検査エラーをスタックして返せるので、多段な検査が必要な場合のデバッグに重宝するはずです。

Eitherとの違いについて。

もちろん Validated という名前もあり、使用目的がわかりやすいというのもありますが、ドキュメントによると、

We’ve established that an error-accumulating data type such as Validated can’t have a valid Monad instance. Sometimes the task at hand requires error-accumulation. However, sometimes we want a monadic structure that we can use for sequential validation (such as in a for-comprehension). This leaves us in a bit of a conundrum.

Validatedなどのエラーを蓄積するデータ型には、有効なMonadインスタンスを含めることはできません。時々、手元のタスクはエラーの蓄積を必要とします。ただし、シーケンシャルバリデーション(for-comprehensionなど)に使用できるモナド構造が必要な場合があります。これは、私たちにちょっとした難問を残します。

Cats has decided to solve this problem by using separate data structures for error-accumulation (Validated) and short-circuiting monadic behavior (Either). Catsは、エラー累積(検証済み)と短絡モナド挙動(Either)に別々のデータ構造を使用することで、この問題を解決することにしました。

If you are trying to decide whether you want to use Validated or Either, a simple heuristic is to use Validated if you want error-accumulation and to otherwise use Either. ValidatedまたはBothのどちらを使用するかを決定しようとする場合、エラーを累積する場合はValidatedを使用し、それ以外の場合はBothを使用するのが簡単なヒューリスティックです。

要はモナド則を守りながらエラー蓄積をしたいときはValidated 、単純に2個どちらかの型を返すだけなら Either を使いましょう、とのことでした。

おわりに

なんとなーく雰囲気で使っていたcatsのValidatedの概要を知ることができました。 catsによる型やモナド則による強力な制約でバグを減らしていきましょう。