Índice de contenidos
- 1. Introducción
- 2. Definición
- 3. Uso básico
- 4. Uso avanzado
- 5. Manejo de errores con Either
- 6. Extra: OrNull
- 7. Conclusión
1. Introducción
Either es un tipo de datos comúnmente usado en programación funcional que se emplea para almacenar un valor de uno de dos posibles tipos. Por ejemplo, si quisiésemos almacenar en una variable una pera o una manzana, pero solamente una de estas dos, el tipo de esta podría ser un Either de peras o manzanas:
val either: Either<Pear, Apple>
Una variable de este tipo contendría o bien una pera o bien una manzana, pero nunca las dos.
2. Definición
Es un estándar que los tipos que pueden representar un Either se llamen Left y Right. Por ejemplo, con el Either<Pear, Apple> antes mencionado, el Left sería una pera y el Right una manzana.
¿Cómo construimos este tipo de datos? Vemos que el Either es una abstracción de dos tipos: Left y Right; además, únicamente de estos dos, pues no queremos que Either tenga ningún otro tipo hijo. Así que una clase sellada se ajusta perfectamente a nuestras necesidades:
sealed class Either<out L, out R> {
data class Left<out L>(val l: L) : Either<L, Nothing>()
data class Right<out R>(val r: R) : Either<Nothing, R>()
}
Either admite dos genéricos L y R, que serán los dos tipos posibles del valor que queremos almacenar (peras o manzanas, siguiendo el ejemplo). La clase hija Left admitirá uno de estos tipos, L, y Right el otro, R. Y como hemos dicho que el Either actúa como contenedor de datos, pues Left almacenará un valor de tipo L (val l: L, una pera) y Right uno de tipo R (val r: R, una manzana).
3. Uso básico
Un caso sencillo de cómo una función podría devolver un Either sería el siguiente:
fun getOnePieceOfFruit(user: User): Either<Pear, Apple> {
if (user.lovesPears()) {
return Either.Left(Pear())
} else {
return Either.Right(Apple())
}
}
El Either se genera en esta función y puede ir viajando por diferentes capas sin tratarlo, pero en algún punto hay que controlar qué ocurre cuando es Left y qué ocurre cuando es Right:
val pieceOfFruit: Either<Pear, Apple> = getOnePieceOfFruit(user)
when (pieceOfFruit) {
is Left -> {
val pear: Pear = pieceOfFruit.l
eat(pear)
}
is Right -> {
val apple: Apple = pieceOfFruit.r
store(apple)
}
}
El mismo código haciendo inline de las variables sería así:
when (pieceOfFruit) {
is Left -> eat(pieceOfFruit.l)
is Right -> store(pieceOfFruit.r)
}
Por cierto, en estos ejemplos el Left y el Right están importados estáticamente.
4. Uso avanzado
4.1. Tratar Either con fold
En el ejemplo anterior, el código de las ramas del when era bastante sencillo pero, si fuese complicado, lo suyo sería sacarlo a métodos auxiliares:
when (pieceOfFruit) {
is Left -> handlePear(pieceOfFruit.l)
is Right -> handleApple(pieceOfFruit.r)
}
fun handlePear(pear: Pear) {
// ...
}
fun handleApple(apple: Apple) {
// ...
}
Para estos casos, podemos mejorar nuestro código si definimos el siguiente método en la clase Either:
sealed class Either<out L, out R> {
// ...
fun <T> fold(fnL: (L) -> T, fnR: (R) -> T): T {
return when (this) {
is Left -> fnL(l)
is Right -> fnR(r)
}
}
}
y lo utilizamos de la siguiente manera:
pieceOfFruit.fold(::handlePear, ::handleApple)
Es decir, estamos encapsulando el when en un método llamado fold y acercándonos a una programación más funcional y declarativa, ya que expresamos qué queremos hacer (tratar el Either) sin importarnos el cómo (usando un when).
4.2. Convertir Either con map
Siguiendo con el ejemplo de las peras y las manzanas, podría llegar un punto en el que la reina Grimhilde recibiese un Either de estas frutas y, en el caso de tener una manzana, convirtiese ésta a una manzana envenenada (¡celooosa!). Pues bien, la bruja podría hacer uso del método map que podemos definir en la clase Either:
sealed class Either<out L, out R> {
// ...
fun <R2> map(fn: (R) -> R2): Either<L, R2> {
return when (this) {
is Left -> this
is Right -> Right(fn(r))
}
}
}
y pasar de un Either<Pear, Apple> a un Either<Pear, PoisonedApple>:
val surprise: Either<Pear, PoisonedApple> = pieceOfFruit.map(::poison)
fun poison(apple: Apple) : PoisonedApple {
// ...
}
5. Manejo de errores con Either
Hemos visto que el Either se emplea para almacenar o bien un valor de un tipo o bien un valor de otro. Normalmente esto no se da en la lógica de nuestros programas y podemos pensar que es raro que vayamos a necesitar este tipo de datos. Pero ahora piensa en llamadas a funciones que pueden devolvernos o bien el valor esperado o bien un error conocido, ¿a que ya no suena raro?
Tradicionalmente hemos manejado los errores con excepciones, pero deberíamos dejar las excepciones para casos ¡excepcionales! Que yo en mi backend vaya a obtener de una base de datos una entidad dado su ID y no la encuentre no es una excepción, es un error conocido y esperado por cualquier programador. Que yo desde un dispositivo móvil haga una petición al backend y no funcione porque no hay conexión a Internet tampoco es una excepción, sino un error esperado. Y deberíamos modelar esos errores con tipos en lugar de ir lanzando excepciones. Además, las excepciones son costosas debido a la creación de la traza de la pila de ejecución.
Por tanto, podemos hacer que las funciones que sabemos que pueden fallar devuelvan un Either con el valor esperado o con uno de esos errores. Y es estándar emplear el Left para los errores y el Right para el éxito, debido al doble significado de la palabra inglesa right: derecha y correcto.
5.1. Ejemplo
Imaginemos que lanzamos una petición a un microservicio que nos devuelve un libro dado un ISBN. Lo que esperamos recibir es el libro, pero también es posible que nos hayamos inventado el ISBN y no haya ningún libro asociado a él o que, al hacer la petición, el microservicio esté caído y obtengamos un timeout, o que recibamos cualquier otro error HTTP. Como vimos en el tutorial de clases selladas en Kotlin, podríamos modelar nuestro error utilizando este tipo de clases:
sealed class GetBookFailure {
data class BookDoesntExist(val isbn: String) : GetBookFailure()
object Timeout : GetBookFailure()
object Unknown : GetBookFailure()
}
El método que llamaría al microservicio podría tener la siguiente firma:
fun getBook(isbn: String): Either<GetBookFailure, Book> {
// ...
}
En su cuerpo devolveríamos un Right del libro conseguido o manejaríamos los errores para devolver el Left correspondiente.
De esta manera, cada vez que llamemos al método getBook() ya sabemos qué esperar y no tenemos la incertidumbre de qué va a ocurrir en caso de error.
6. Extra: OrNull
Me encanta el manejo de la nulabilidad de Kotlin y cómo se puede aprovechar para hacer nuestro código más legible en ciertos casos.
Por ejemplo, es un estándar en las bibliotecas de Kotlin encontrar métodos como String.toInt() que tienen una variante llamada igual pero terminada en OrNull: String.toIntOrNull(). Mientras que el primer método lanza una excepción en caso de que el texto no sea un número, el segundo devuelve null. Esto hace que podamos manejar el error utilizando, por ejemplo, el cómodo operador Elvis:
fun String.toPercentage(): String {
val number: Int = this.toIntOrNull() ?: 0
return "$number %"
}
Por cierto, la misma función en una línea:
fun String.toPercentage() = "${toIntOrNull() ?: 0} %"
Dicho esto, podemos definir en la clase Either un método para obtener su valor en caso de ser un Left u obtener null si es un Right, y otro método para lo contrario:
sealed class Either<out L, out R> {
// ...
fun toLeftValueOrNull(): L? = when (this) {
is Left -> l
is Right -> null
}
fun toRightValueOrNull(): R? = when (this) {
is Left -> null
is Right -> r
}
}
Estos métodos se podrían utilizar cuando solo quisiésemos el valor del Left o el valor del Right y nos diese igual el valor del otro tipo:
fun getAssistanceNumber() : Either<Failure, String> {
// ...
}
val assistanceNumber: String = getAssistanceNumber().toRightValueOrNull() ?: DEFAULT_NUMBER
7. Conclusión
En mi opinión, el Either es excelente para manejar errores, ya que hace que el código sea más declarativo, legible y robusto. Además, el uso de métodos como fold y map aumenta dicha legibilidad, ya que potenciamos el qué queremos hacer y escondemos el cómo lo conseguimos.
Con Kotlin y sus posibilidades, podemos partir de una programación orientada a objetos que pasito a pasito se vaya acercando a una programación más funcional, abrazando las ventajas de ésta y combinándolas con las de la POO.