初めに
Eitherとは…LeftとRightの2つの具体的な値が存在するデータ型です。大抵の場合、Leftはエラー値が格納されているときのEitherの型、Rightは正常な値が格納されているときのEitherの型とみなすことが多いです。
Scala with CatsのEitherについて公式ドキュメントを自分なりに翻訳して説明していきます!!
※ところどころ、分かりやすいように文を付け加えてます!
モチベーション
日々プログラミングをしていて、失敗する可能性のある関数を書いていることに気がつくことがあるでしょう。 たとえば、サービスにクエリを実行すると、接続の問題や予期しないJSON応答が発生する可能性があります。
これらのエラーを伝達するために、例外をスローすることが一般的です。ただし、例外は、Scalaコンパイラによって、いかなる方法、形状、形式でも追跡されません。関数がスローする可能性のある例外の種類を確認するには、ソースコードを詳しく調べる必要があります。次に、これらの例外を処理するために、呼び出し元でそれらを確実にキャッチする必要があります。例外をスローする手順を作成しようとすると、これはさらに扱いにくくなります。
val throwsSomeStuff: Int => Double = ???
val throwsOtherThings: Double => String = ???
val moreThrowing: String => List[Char] = ???
val magic = throwsSomeStuff.andThen(throwsOtherThings).andThen(moreThrowing)
コードで例外を喜んでスローするとします。型を見てみると、これらの関数はどれも例外を複数スローする可能性がありますが、調べないと分かりません。生成する際にいずれかの生成する関数から例外がスローされる可能性もあるし、同じ種類の例外(IllegalArgumentExceptionなど)をスローする可能性もあります。そのため、その例外がどこから発生したかを正確に追跡するのは難しい場合があります。
では、どのようにしてエラーを伝えましょうか…
データ型で明示的に表しましょう!
Either
Either vs Validated
一般的に、Validatedはエラーを累積するために使用され、Eitherは最初のエラー時に計算するために使用されます。詳細については、Validatedのドキュメントの「Validated vs Either」セクションを参照してください。
構文
Scala2.10.xおよび2.11.xでは、Eitherはバイアスがありません。つまり、flatMapやmapなどの通常のコンビネータが欠落しています。その代わりに、.rightまたは.leftを呼び出して、コンビネータを持つRightProjectionまたはLeftProjectionを(それぞれ)取得します。プロジェクションの向きはバイアスの向きを示します。例えば、RightProjectionでmapを呼び出すと、EitherのRightに作用します。
val e1: Either[String, Int] = Right(5)
// e1: Either[String, Int] = Right(5)
e1.right.map(_ + 1)
// res0: Either[String, Int] = Right(6)
val e2: Either[String, Int] = Left("hello")
// e2: Either[String, Int] = Left("hello")
e2.right.map(_ + 1)
// res1: Either[String, Int] = Left("hello")
戻り値の型自体がEitherに戻ることに注意してください。したがって、flatMapまたはmapをまた呼び出したい場合は、もう一度leftまたはrightを呼び出す必要があります。
しかし大抵の場合、Eitherはrightにバイアスを掛けます。実際にScala2.12.xでは、Eitherはデフォルトでrightにバイアスを掛けています。
通常、私たちは片側にバイアスを掛け、終わりにしたいですが、慣例により、大抵の場合、rightが選択されます。(筆者「Eitherは主にRightで値を扱うよ!ってことだと思います!Eitherを使う場合、Left値をエラー値、Right値を正常な値とみなすことが多いです。」)
Scala2.12.xでは、この規則は標準ライブラリに実装されています。 Catsは2.10.xと2.11.xに基づいて構築されているため、cats.syntax.either._またはcats.implicits._で利用可能な構文拡張によってギャップが埋められています。
import cats.implicits._
val right: Either[String, Int] = Right(5)
// right: Either[String, Int] = Right(5)
right.map(_ + 1)
// res2: Either[String, Int] = Right(6)
val left: Either[String, Int] = Left("hello")
// left: Either[String, Int] = Left("hello")
left.map(_ + 1)
// res3: Either[String, Int] = Left("hello")
このチュートリアルの残りの部分では、構文拡張がrightにバイアスを掛けたEitherと他の便利なコンビネータ(Eitherとコンパニオンオブジェクトの両方)を提供している範囲内であると仮定します。
Eitherはrightにバイアスを掛けているため、モナドインスタンスを定義することができます。Rightの場合にのみ計算を続行する必要があるため、leftの型パラメータを修正し、rightのパラメーターを空けておきます。
注:以下の例は、kind-projectorコンパイラプラグインの使用を想定しており、プロジェクトでこちらを使用されていない場合はコンパイルされません。
import cats.Monad
implicit def eitherMonad[Err]: Monad[Either[Err, ?]] =
new Monad[Either[Err, ?]] {
def flatMap[A, B](fa: Either[Err, A])(f: A => Either[Err, B]): Either[Err, B] =
fa.flatMap(f)
def pure[A](x: A): Either[Err, A] = Either.right(x)
@annotation.tailrec
def tailRecM[A, B](a: A)(f: A => Either[Err, Either[A, B]]): Either[Err, B] =
f(a) match {
case Right(Right(b)) => Either.right(b)
case Right(Left(a)) => tailRecM(a)(f)
case l@Left(_) => l.rightCast[B] // Cast the right type parameter to avoid allocation
}
}
使用例:ラウンド1
実行例として、文字列を整数に変換し、逆数を取得してから、逆数を文字列に変換する一連の関数があります。
例外をスローするコードでは、次のようになります。
object ExceptionStyle {
def parse(s: String): Int =
if (s.matches("-?[0-9]+")) s.toInt
else throw new NumberFormatException(s"${s} is not a valid integer.")
def reciprocal(i: Int): Double =
if (i == 0) throw new IllegalArgumentException("Cannot take reciprocal of 0.")
else 1.0 / i
def stringify(d: Double): String = d.toString
}
これをEitherを用いて、関数の一部が戻り値の型で明示的に失敗する可能性があるという事実を作りましょう。
object EitherStyle {
def parse(s: String): Either[Exception, Int] =
if (s.matches("-?[0-9]+")) Either.right(s.toInt)
else Either.left(new NumberFormatException(s"${s} is not a valid integer."))
def reciprocal(i: Int): Either[Exception, Double] =
if (i == 0) Either.left(new IllegalArgumentException("Cannot take reciprocal of 0."))
else Either.right(1.0 / i)
def stringify(d: Double): String = d.toString
}
これで、flatMapやmapなどのコンビネータを使用して、関数を一緒に構成できます。
import EitherStyle._
def magic(s: String): Either[Exception, String] =
parse(s).flatMap(reciprocal).map(stringify)
合成関数を使用すると、文字列を渡してから、例外でパターンマッチングを行うことができます。Eitherはsealed型(代数的データ型またはADT(抽象データ型)と呼ばれることが多い)であるため、LeftとRightの両方のケースをチェックしないと、コンパイラは文句を言います。
magic("123") match {
case Left(_: NumberFormatException) => println("not a number!")
case Left(_: IllegalArgumentException) => println("can't take reciprocal of 0!")
case Left(_) => println("got unknown exception")
case Right(s) => println(s"Got reciprocal: ${s}")
}
// Got reciprocal: 0.008130081300813009
これは悪くは無いですね。これらの句を1つでも省略すると、コンパイラは必要に応じて怒ります。ただし、Left(_)句に注意してください。Either[Exception, String]型を指定していると、NumberFormatExceptionでもIllegalArgumentExceptionでもないLeft値が存在する可能性があるため、これを省略するとコンパイラは文句を言います。ただし、ソースコードを調べることで、スローされる例外はそれらだけであることが分かるため、他の例外を考慮する必要があるのは奇妙に思えます。これは、まだ改善の余地があることってことですね。
使用例:ラウンド2
エラー値として例外を使用する代わりに、プログラムで問題が発生する可能性のあるものを明示的に列挙しましょう。
import cats.implicits._
object EitherStyle {
sealed abstract class Error
final case class NotANumber(string: String) extends Error
case object NoZeroReciprocal extends Error
def parse(s: String): Either[Error, Int] =
if (s.matches("-?[0-9]+")) Either.right(s.toInt)
else Either.left(NotANumber(s))
def reciprocal(i: Int): Either[Error, Double] =
if (i == 0) Either.left(NoZeroReciprocal)
else Either.right(1.0 / i)
def stringify(d: Double): String = d.toString
def magic(s: String): Either[Error, String] =
parse(s).flatMap(reciprocal).map(stringify)
}
このモジュールでは、発生する可能性のあるすべてのエラーを列挙します。次に、エラー値として例外クラスを使用する代わりに、列挙されたケースの1つを使用します。これで、パターンマッチングを行うとより適切なマッチングが得られます。さらに、Errorはsealedであるため、外部コードは、処理に失敗する可能性のある派生型を追加できません。
import EitherStyle._
magic("123") match {
case Left(NotANumber(_)) => println("not a number!")
case Left(NoZeroReciprocal) => println("can't take reciprocal of 0!")
case Right(s) => println(s"Got reciprocal: ${s}")
}
// Got reciprocal: 0.008130081300813009
Eitherを使用するときの問題
すべてのエラー処理にEitherを使い始めると、2つの別々のモジュールを呼び出す必要があり、別々の種類のエラーが返されるという問題が発生する可能性があります。
sealed abstract class DatabaseError
trait DatabaseValue
object Database {
def databaseThings(): Either[DatabaseError, DatabaseValue] = ???
}
sealed abstract class ServiceError
trait ServiceValue
object Service {
def serviceThings(v: DatabaseValue): Either[ServiceError, ServiceValue] = ???
}
データベースの処理を実行してから、データベースの値を取得してサービスの処理を実行するアプリケーションがあるとします。型を見ると、flatMapがそれを行うように見えます。
def doApp = Database.databaseThings().flatMap(Service.serviceThings)
このコードは、2.12またはそれ以前のバージョンのScalaを使用しているかどうかに関係なく、期待どおりにコンパイルおよび機能します。ここで取得するflatMap(Scala2.10および2.11のCatsのEither構文によって提供されるか、Scala 2.12ではEitherのメソッドによって提供される)には、次の署名があります。
def flatMap[AA >: A, Y](f: (B) => Either[AA, Y]): Either[AA, Y]
このflatMapは、ListやOptionにあるものとは異なります。例えば、2つの型パラメータがあり、別のAAパラメータを使用すると、leftに異なる型のEitherにflatMapすることができます。この動作はEitherの共分散と一致しており、便利な場合もありますが、オブジェクトがleftの型として推測されるなど、厄介な分散の問題が発生しやすくなります。
解決策1:アプリケーション全体のエラー
そんなとき、アプリケーション全体でエラーデータ型を共有させたくなるかもしれません。
sealed abstract class AppError
case object DatabaseError1 extends AppError
case object DatabaseError2 extends AppError
case object ServiceError1 extends AppError
case object ServiceError2 extends AppError
trait DatabaseValue
object Database {
def databaseThings(): Either[AppError, DatabaseValue] = ???
}
object Service {
def serviceThings(v: DatabaseValue): Either[AppError, ServiceValue] = ???
}
def doApp = Database.databaseThings().flatMap(Service.serviceThings)
これは正しく機能します。もしくは、少なくともコンパイルされます。ただし、別のモジュールがデータベースのみを使用して、Either[AppError, DatabaseValue]を取得する場合を考えてみます。エラーを検査する場合は、データベースがDatabaseError1またはDatabaseError2を使用することのみを目的としていたとしても、全てのAppErrorケースを検査する必要があります。
解決策2:ADT(抽象データ型)をさらに深くへ
全てのエラーを1つの大きなADTにまとめる代わりに、それらを各モジュールに対してローカルに保ち、必要な各エラーADTをラップするアプリケーション全体のエラーADTを作成できます。
sealed abstract class DatabaseError
trait DatabaseValue
object Database {
def databaseThings(): Either[DatabaseError, DatabaseValue] = ???
}
sealed abstract class ServiceError
trait ServiceValue
object Service {
def serviceThings(v: DatabaseValue): Either[ServiceError, ServiceValue] = ???
}
sealed abstract class AppError
object AppError {
final case class Database(error: DatabaseError) extends AppError
final case class Service(error: ServiceError) extends AppError
}
これで、外部アプリケーションで、各モジュール固有のエラーをAppErrorにラップ/リフトしてから、通常どおりコンビネータを呼び出すことができます。Eitherは、 Either.leftMapと呼ばれる、これを助ける便利なメソッドを提供しています。これは、mapと同じと考えることができますが、Leftに対して行います。
def doApp: Either[AppError, ServiceValue] =
Database.databaseThings().leftMap[AppError](AppError.Database).
flatMap(dv => Service.serviceThings(dv).leftMap(AppError.Service))
やった!各モジュールは、本来あるべき独自のエラーのみを考慮し、さらに複合モジュールには、各構成モジュールのエラーADTをカプセル化する独自のエラーADTを持つようになりました!これを行うことで、個々のエラーのパターンマッチを行う代わりに、エラーのクラス全体に対してアクションを実行することもできます。
def awesome =
doApp match {
case Left(AppError.Database(_)) => "something in the database went wrong"
case Left(AppError.Service(_)) => "something in the service went wrong"
case Right(_) => "everything is alright!"
}
exception-yコードの操作
あなたの素敵なEitherコードが例外をスローするコードと相互作用しなければならない時が必然的に来るでしょう。このような状況への対処は簡単です。
val either: Either[NumberFormatException, Int] =
try {
Either.right("abc".toInt)
} catch {
case nfe: NumberFormatException => Either.left(nfe)
}
// either: Either[NumberFormatException, Int] = Left(
// java.lang.NumberFormatException: For input string: "abc"
// )
ただし、これはすぐに面倒になる可能性があります。Eitherには、コンパニオンオブジェクトに(構文エンリッチメントを介して)catchOnlyメソッドがあり、キャッチする例外の型とともに関数を渡すことができ、上記を実行します。
val either: Either[NumberFormatException, Int] =
Either.catchOnly[NumberFormatException]("abc".toInt)
// either: Either[NumberFormatException, Int] = Left(
// java.lang.NumberFormatException: For input string: "abc"
// )
全ての(致命的ではない)スローアブルをキャッチしたい場合は、 catchNonFatalを使用できます。
val either: Either[Throwable, Int] = Either.catchNonFatal("abc".toInt)
// either: Either[Throwable, Int] = Left(
// java.lang.NumberFormatException: For input string: "abc"
// )
以上で公式ドキュメントよりScala with CatsのEitherについて説明終わりです!
ありがとうございました!!🙇♂️