#初めに
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
について説明終わりです!
ありがとうございました!!🙇♂️