Evitar cascadas de opción anidadas profundamente en Scala
nested option (4)
Supongamos que tengo tres funciones de acceso a la base de datos foo
, bar
y baz
que pueden devolver la Option[A]
donde A
es una clase de modelo, y las llamadas dependen unas de otras.
Me gustaría llamar a las funciones secuencialmente y en cada caso, devolver un mensaje de error apropiado si el valor no se encuentra ( None
).
Mi código actual se ve así:
Input is a URL: /x/:xID/y/:yID/z/:zID
foo(xID) match {
case None => Left(s"$xID is not a valid id")
case Some(x) =>
bar(yID) match {
case None => Left(s"$yID is not a valid id")
case Some(y) =>
baz(zID) match {
case None => Left(s"$zID is not a valid id")
case Some(z) => Right(process(x, y, z))
}
}
}
Como se puede ver, el código está mal anidado.
Si, en cambio, uso una for
comprensión, no puedo dar mensajes de error específicos, porque no sé qué paso falló:
(for {
x <- foo(xID)
y <- bar(yID)
z <- baz(zID)
} yield {
Right(process(x, y, z))
}).getOrElse(Left("One of the IDs was invalid, but we do not know which one"))
Si uso map
y getOrElse
, termino con el código casi tan anidado como el primer ejemplo.
¿Es esta una mejor forma de estructurar esto para evitar el anidamiento al tiempo que permite mensajes de error específicos?
En lugar de usar una Option
, usaría un Try
. De esta forma, tiene la composición Monadic que le gustaría mezclar con la capacidad de retener el error.
def myDBAccess(..args..) =
thingThatDoesStuff(args) match{
case Some(x) => Success(x)
case None => Failure(new IdError(args))
}
Asumo en lo anterior que no controlas las funciones y no puedes refactorizarlas para darte una Option
no sea Option
. Si lo hiciste, simplemente sustituye Try
.
Puede hacer funcionar su loop for
usando proyecciones correctas.
def ckErr[A](id: String, f: String => Option[A]) = (f(id) match {
case None => Left(s"$id is not a valid id")
case Some(a) => Right(a)
}).right
for {
x <- ckErr(xID, foo)
y <- ckErr(yID, bar)
z <- ckErr(zID, baz)
} yield process(x,y,z)
Esto todavía es un poco torpe, pero tiene la ventaja de ser parte de la biblioteca estándar.
Las excepciones son otro camino por recorrer, pero desaceleran mucho las cosas si los casos de falla son comunes. Solo usaría eso si el fracaso fuera realmente excepcional.
También es posible utilizar devoluciones no locales, pero es un poco incómodo para esta configuración en particular. Creo que las proyecciones correctas de Either
son el camino a seguir. Si realmente te gusta trabajar de esta manera pero no .right
gusta poner " .right
todos lados, hay varios lugares en los que puedes encontrar un "O bien polarizado" que actuará como la proyección correcta por defecto (por ejemplo, ScalaUtils, Scalaz, etc.).
Se me ocurrió esta solución (basada en la solución de @ Rex y sus comentarios):
def ifTrue[A](boolean: Boolean)(isFalse: => A): RightProjection[A, Unit.type] =
Either.cond(boolean, Unit, isFalse).right
def none[A](option: Option[_])(isSome: => A): RightProjection[A, Unit.type] =
Either.cond(option.isEmpty, Unit, isSome).right
def some[A, B](option: Option[A])(ifNone: => B): RightProjection[B, A] =
option.toRight(ifNone).right
Ellos hacen lo siguiente:
-
ifTrue
se usa cuando una función devuelve unBoolean
, siendotrue
el caso de "éxito" (por ejemplo:isAllowed(userId)
). De hecho, devuelveUnit
por lo que debe usarse como_ <- ifTrue(...) { error }
en afor
comprensión. -
none
se usa cuando una función devuelve unaOption
conNone
como el caso de "éxito" (por ejemplo:findUser(email)
para crear cuentas con direcciones de correo electrónico únicas). En realidad, devuelveUnit
por lo que debe usarse como_ <- none(...) { error }
en afor
comprensión. -
some
se usan cuando una función devuelve unaOption
conSome()
siendo el caso de "éxito" (por ejemplo:findUser(userId)
para unGET /users/userId
). Devuelve el contenido deSome
:user <- some(findUser(userId)) { s"user $userId not found" }
.
Se usan en a for
comprensión:
for {
x <- some(foo(xID)) { s"$xID is not a valid id" }
y <- some(bar(yID)) { s"$yID is not a valid id" }
z <- some(baz(zID)) { s"$zID is not a valid id" }
} yield {
process(x, y, z)
}
Esto devuelve un Either[String, X]
donde la String
es un mensaje de error y la X
es el resultado del process
de llamada.
Sé que esta pregunta fue respondida hace un tiempo, pero quería dar una alternativa a la respuesta aceptada.
Dado que, en su ejemplo, las tres Option
son independientes, puede tratarlas como Funcionadores Aplicativos y usar ValidatedNel
de Gatos para simplificar y agregar el manejo de la ruta infeliz.
Dado el código:
import cats.data.Validated.{invalidNel, valid}
def checkOption[B, T](t : Option[T])(ifNone : => B) : ValidatedNel[B, T] = t match {
case None => invalidNel(ifNone)
case Some(x) => valid(x)
def processUnwrappedData(a : Int, b : String, c : Boolean) : String = ???
val o1 : Option[Int] = ???
val o2 : Option[String] = ???
val o3 : Option[Boolean] = ???
A continuación, puede replicar para obtener lo que desea con:
//import cats.syntax.cartesian._
(
checkOption(o1)(s"First option is not None") |@|
checkOption(o2)(s"Second option is not None") |@|
checkOption(o3)(s"Third option is not None")
) map (processUnwrappedData)
Este enfoque le permitirá agregar fallas, lo que no fue posible en su solución (dado que el uso de las comprensiones impone la evaluación secuencial). Se pueden encontrar más ejemplos y documentación aquí y aquí .
Finalmente, esta solución usa los gatos Validated
pero podría traducirse fácilmente a la Validation
Scalaz