Skip to main content

La mónada Either

⏱ Dedicación recomendada: 0 minutos
Esto considera el contenido visible y relevante, e ignora texto colapsado o marcado como opcional.


r8vnhill/functional-programming-kt

Be lazy...

Puedes ejecutar el siguiente comando para crear el módulo

./gradlew setupEitherModule

Mientras se crean los archivos necesarios, puedes leer el código para saber qué está pasando.

import tasks.ModuleSetupTask

tasks.register<ModuleSetupTask>("setupEitherModule") {
description = "Creates the base module and files for the either lesson"
module.set("monads:either")

doLast {
createFiles(
"either",
main to "Either.kt",
main to "EitherMonad.kt",
main to "validatePassword.kt",
test to "EitherMonadTest.kt"
)
}
}

Preocúpate de que el plugin either esté aplicado en el archivo build.gradle.kts de tu proyecto.

./gradlew setupEitherModule

Preocúpate de que el nuevo módulo esté incluido en el archivo settings.gradle.kts.

Como vimos en la lección sobre tipos suma, Either es una construcción que representa un valor que puede tener una de dos formas: Left o Right. Este tipo es especialmente útil cuando una operación puede fallar o tener éxito, ya que permite capturar explícitamente ambos casos en el tipo de retorno.

En la práctica funcional, Either se usa para modelar funciones que pueden devolver un resultado válido (Right) o un error (Left) sin recurrir a excepciones, facilitando así un código más seguro, predecible y composable.

Nemotécnica

"Right is right, Left is what's left."

📉 El problema con las excepciones

"¿Pero por qué consideramos que lanzar excepciones es algo negativo? ¿Por qué no es el efecto deseado? La respuesta tiene mucho que ver con la pérdida de control." — Vermeulen, et al. (2021)

El problema con las excepciones radica en que no son referencialmente transparentes. Esto significa que no se pueden reemplazar por su valor sin alterar el comportamiento del programa. Veamos un ejemplo:

fun failingFn(): Int {
val y: Int = throw Exception("boom")
return try {
val x = 420
x + y
} catch (e: Exception) {
0
}
}

En este caso, y no es referencialmente transparente, porque no podemos sustituir su valor por throw Exception("boom") dentro de la expresión x + y sin modificar el resultado del programa. Si lo hacemos, la excepción se lanzará inmediatamente dentro del bloque try-catch, cambiando el flujo de ejecución y, por ende, el comportamiento del código.

Comparación con checked exceptions en Java

En Java, las checked exceptions imponen que cualquier función que pueda lanzar una excepción verificada debe manejarse explícitamente con un bloque try-catch o declararla en la firma del método usando throws. Este enfoque introduce complicaciones cuando se utilizan funciones de orden superior, ya que no es posible determinar si estas funciones, que pueden aceptar otras funciones como parámetros o devolverlas, lanzarán una excepción verificada. Esto se debe a que el contexto donde se llama a la función no puede prever ni manejar las excepciones que podrían ser arrojadas. A continuación, un ejemplo típico de checked exceptions en Java:

public String readFile(String path) throws IOException {
if (path == null) {
throw new IOException("Invalid file path");
}
return "File content";
}

public void processFile(String path) {
try {
String content = readFile(path);
System.out.println(content);
} catch (IOException e) {
System.out.println("Error: " + e.getMessage());
}
}

En este ejemplo, el método readFile lanza una IOException, que es una excepción verificada, y el llamador de este método, processFile, debe manejar explícitamente la excepción con try-catch.

Problema con las Funciones de Orden Superior

El uso de checked exceptions en combinación con funciones de orden superior se vuelve problemático porque no es posible saber si una función pasará una excepción verificada cuando se llame desde un contexto distinto. Por ejemplo, si intentamos pasar una función que arroja una excepción verificada a un método de orden superior, el compilador no puede inferir si ese método debe declarar la excepción o manejarla. Esto limita la flexibilidad de la programación funcional en Java y otros lenguajes con excepciones verificadas.

@FunctionalInterface
interface FileProcessor {
void process(String path) throws IOException;
}

public void handleFile(FileProcessor processor) {
// How does the compiler know that "processor" throws a checked exception?
}

El compilador no puede verificar si FileProcessor lanzará una excepción verificada, lo que obliga a manejar manualmente las excepciones, lo que rompe la fluidez de la composición de funciones.

Alternativa en Kotlin sin Excepciones Verificadas

En Kotlin, no existen checked exceptions, lo que permite un enfoque más flexible y funcional. Sin embargo, esto significa que se deben manejar las excepciones de manera explícita, por ejemplo, usando patrones de error como Either.

📜 Leyes de las Mónadas

Para validar que nuestra implementación cumple con las leyes de las mónadas, definimos pruebas para las tres leyes: identidad izquierda, identidad derecha, y asociatividad. Estas pruebas nos aseguran que la estructura monádica se comporta como se espera en cualquier caso.

Ley de Asociatividad

checkAll(Arb.int(), Arb.int()) { a, b ->
EitherMonad.run {
val f: (Int) -> Either<Throwable, Int> = { pure(it * b) }
val g: (Int) -> Either<Throwable, Int> = { pure(it + a) }
pure<Throwable, Int>(a).flatMap(f).flatMap(g) shouldBe
pure<Throwable, Int>(a).flatMap { f(it).flatMap(g) }
}
}

Ley de Identidad Derecha

checkAll(Arb.int()) { a ->
EitherMonad.run {
pure<Throwable, Int>(a).flatMap { pure(it) } shouldBe pure(a)
}
}

Ley de Identidad Izquierda

checkAll(Arb.int(), Arb.int()) { a, b ->
EitherMonad.run {
val f: (Int) -> Either<Throwable, Int> = { pure(it * b) }
pure<Throwable, Int>(a).flatMap(f) shouldBe f(a)
}
}
¿Qué acabamos de hacer?
  • Ley de identidad izquierda: Asegura que envolver un valor y luego aplicarle una función es lo mismo que aplicar la función directamente al valor.
  • Ley de identidad derecha: Garantiza que aplicar una función que simplemente envuelve el valor no cambia el contexto original.
  • Ley de asociatividad: Verifica que la secuencia de aplicación de funciones encadenadas produce el mismo resultado, independientemente del orden de agrupación de las funciones.

🏗️ Implementación

⚖️ Implementando Either

Para implementar Either en Kotlin podemos utilizar una clase sellada para representar los dos posibles casos (Left y Right):

monads/src/main/kotlin/com/github/username/either/Either.kt
package com.github.username.either

sealed class Either<out L, out R>

data class Left<L>(val value: L) : Either<L, Nothing>()

data class Right<R>(val value: R) : Either<Nothing, R>()

🧩 Implementando la mónada Either

monads/src/main/kotlin/com/github/username/either/EitherMonad.kt
package com.github.username.either

object EitherMonad {
fun <L, R> pure(r: R): Either<L, R> = Right(r)

fun <L, R, T> Either<L, R>.flatMap(f: (R) -> Either<L, T>): Either<L, T> = when (this) {
is Left -> this
is Right -> f(value)
}
}

🛠️ Ejemplo de uso

En este ejemplo, vamos a implementar un caso sencillo de Either para manejar operaciones que pueden fallar, utilizando las funciones pure y flatMap para trabajar con la mónada Either.

Imaginemos que tenemos dos funciones de validación: una que verifica la longitud de una contraseña y otra que valida si la contraseña contiene al menos un número. Nuestro objetivo es encadenar estas validaciones de manera que, si una falla, la otra no se ejecute. En este caso, devolveremos un Left con el mensaje de error apropiado. Si todas las validaciones se superan, obtendremos un Right con la contraseña válida.

package com.github.username.either

private fun validateLength(password: String) =
if (password.length >= 8) Right(password)
else Left("Password is too short")

private fun validateContainsNumber(password: String) =
if (password.any { it.isDigit() }) Right(password)
else Left("Password must contain a number")

fun validatePassword(password: String) = EitherMonad.run {
pure<String, _>(password)
.flatMap(::validateLength)
.flatMap(::validateContainsNumber)
}

fun main() {
println(validatePassword("1234567")) // Left(value=Password is too short)
println(validatePassword("12345678")) // Right(value=12345678)
}

Este patrón es particularmente útil cuando queremos manejar flujos de operaciones secuenciales que pueden fallar, como validaciones, operaciones de entrada/salida, o cualquier proceso en el que preferimos evitar el uso de excepciones. Con Either, podemos modelar el éxito o el fracaso de cada paso de forma segura y estructurada.

¿Qué acabamos de hacer?

En este ejemplo, utilizamos la mónada Either para validar una contraseña, aplicando dos funciones de validación secuenciales. Si alguna de las validaciones falla, se devuelve un Left con un mensaje de error. Si todas las validaciones son exitosas, se devuelve un Right con la contraseña válida.

✅ Beneficios y ❌ limitaciones de Either

Beneficios

  • Manejo explícito de errores: Permite modelar los errores de manera clara y estructurada, evitando el uso de excepciones y haciendo que los errores sean parte del tipo de retorno.
  • Mejor composición: Either facilita la composición funcional de operaciones que pueden fallar, utilizando flatMap para encadenar transformaciones sin afectar el flujo del programa.
  • Referencialmente transparente: A diferencia de las excepciones, Either mantiene la predictibilidad de las funciones, ya que no altera el flujo de control de forma oculta.
  • No propaga excepciones inesperadas: Los errores se manejan explícitamente mediante Left, lo que evita sorpresas en tiempo de ejecución debido a excepciones no controladas.
  • Facilita el testing: Al hacer que los errores sean valores explícitos, las pruebas pueden centrarse en los diferentes resultados posibles sin necesidad de depender de excepciones.

Limitaciones

  • Mayor verbosidad: Requiere envoltura explícita en Left y Right, lo que puede hacer que el código sea más extenso en comparación con el uso de excepciones o Result.
  • Curva de aprendizaje: Para quienes están acostumbradxs a excepciones, puede tomar tiempo adoptar Either y acostumbrarse a su uso con funciones como map y flatMap.
  • Puede ser innecesario en casos simples: Para operaciones donde solo se necesita representar la ausencia de un valor, Option puede ser más adecuado y menos verboso.
  • No previene la omisión del manejo de errores: Aunque obliga a manejar ambos casos (Left y Right), sigue siendo posible ignorar el error.

⚖️ Comparación con otras estructuras

🔍 Comparación con Option

Tanto Either como Option son tipos algebraicos de datos en Kotlin que nos permiten manejar de manera segura operaciones que pueden no devolver un resultado exitoso. Aunque ambos sirven para modelar la ausencia o presencia de un valor, Either se utiliza principalmente cuando queremos capturar más información sobre el fallo, mientras que Option es más simple y se usa cuando el error no importa tanto.

Option

  • Propósito: Modela la presencia o ausencia de un valor. Se usa en casos donde puede no haber un resultado, pero no nos importa mucho el por qué.
  • Construcción: Puede ser Some (cuando el valor está presente) o None (cuando no hay valor).
  • Uso típico: Se utiliza cuando el fallo o la ausencia de valor no es el punto central y no se necesita más información sobre por qué no se devolvió un valor.
  • Manejo de la ausencia: La falta de un valor en Option no contiene información adicional, simplemente indica que no hay nada.

Either

  • Propósito: Modela dos resultados posibles: un éxito o un fallo, representados por Right y Left, respectivamente.
  • Construcción: Se construye con dos variantes: Left para el fallo y Right para el éxito.
  • Uso típico: Se usa cuando es importante conocer la razón por la cual no se obtuvo un resultado exitoso, proporcionando un mensaje de error o un código que explique el fallo.
  • Manejo del fallo: El uso de Left permite contener información útil sobre el error, lo que lo convierte en una opción más poderosa para manejar fallos explícitamente.

⚡ Comparación con Result

Al comparar Either y Result, encontramos que ambos tipos son útiles para modelar resultados que pueden tener éxito o fallar, pero existen diferencias clave en cuanto a su propósito, uso y cómo gestionan los errores.

Either

  • Generalidad: Either es un tipo genérico que puede representar cualquier tipo de resultado, no solo éxito o fallo. En el contexto de fallos, Left puede contener un error o una situación inesperada, mientras que Right contiene el resultado exitoso.
  • Similitud con excepciones verificadas: Either se puede comparar con las excepciones verificadas en lenguajes como Java, donde se requiere que los errores sean manejados explícitamente. Al usar Either, debes manejar tanto el caso de Left como el de Right, obligando a quien desarrolla a tratar los errores de manera explícita.

Result

  • Especialización: Result está diseñado específicamente para modelar operaciones que pueden tener éxito o fallo. Al usar Result, los errores se manejan mediante excepciones, lo que hace que sea más adecuado para capturar fallos inesperados, como errores de tiempo de ejecución o fallos no planeados.
  • Similitud con excepciones no verificadas: Result se asemeja a las excepciones no verificadas (unchecked exceptions) en lenguajes como Kotlin y Java, donde los errores pueden ser propagados sin ser manejados explícitamente. Esto permite que el código que usa Result sea más fluido, pero también significa que los errores pueden ser ignorados si no se manejan correctamente.

📊 Resumen comparativo

CaracterísticaEitherOptionResult
PropósitoModela éxito (Right) o fallo (Left)Modela presencia (Some) o ausencia (None)Modela éxito (Success) o fallo (Failure)
Captura de erroresExplícita (Left contiene error)Implícita (None indica ausencia)Implícita (Failure encapsula excepción)
Información del falloRica (permite mensajes detallados)Nula (solo indica que no hay valor)Rica (contiene la excepción lanzada)
Similitud con excepcionesSimilar a excepciones verificadas (checked)No modela errores explícitosSimilar a excepciones no verificadas (unchecked)
Casos de usoValidaciones, errores controladosValores opcionales, ausencia esperadaOperaciones propensas a fallos inesperados

📝 Ejercicio: Extendiendo Either

Ejercicio

Implementa las funciones:

  • fold: (Either<A, B>, (A) -> C, (B) -> C): C: Aplica una función a cada caso de Either y devuelve el resultado. Si es Left, aplica la primera función, si es Right, aplica la segunda.
  • swap: (Either<L, R>) -> Either<R, L>: Intercambia los valores de Left y Right. Utiliza la función fold para implementar swap.
Solución
monads/src/main/kotlin/com/github/username/either/EitherMonad.kt
fun <A, B, C> Either<A, B>.fold(
ifLeft: (A) -> C,
ifRight: (B) -> C
) = when (this) {
is Left -> ifLeft(value)
is Right -> ifRight(value)
}
monads/src/main/kotlin/com/github/username/either/EitherMonad.kt
fun <A, B> Either<A, B>.swap() = fold(
ifLeft = ::Right,
ifRight = ::Left
)

🎯 Conclusiones

La mónada Either proporciona una alternativa clara y funcional para el manejo de errores en Kotlin sin recurrir a excepciones. Su enfoque basado en Left y Right permite representar tanto fallos como éxitos de forma explícita, facilitando la composición de funciones y garantizando mayor seguridad en el flujo del programa.

A lo largo de la lección, hemos explorado su implementación, las leyes monádicas que cumple, y su comparación con estructuras similares como Option y Result. También hemos analizado sus beneficios y limitaciones, así como ejemplos prácticos de uso.

🔑 Puntos clave

  • Manejo explícito de errores: Either encapsula tanto el éxito como el fallo dentro de su tipo de retorno, evitando excepciones inesperadas.
  • Composición funcional: Facilita la aplicación secuencial de operaciones mediante flatMap, permitiendo encadenar transformaciones de forma segura.
  • Referencialmente transparente: A diferencia de las excepciones, el uso de Either no interrumpe el flujo del programa ni introduce efectos laterales inesperados.
  • Comparación con otras estructuras: Mientras que Option modela la presencia o ausencia de un valor y Result encapsula excepciones, Either es más flexible y adecuado para manejar fallos de manera detallada.

🧰 ¿Qué nos llevamos?

El uso de Either en Kotlin nos invita a repensar la forma en que manejamos los errores y estructuramos nuestro código. En lugar de depender de excepciones, que pueden ser difíciles de rastrear y manejar de manera segura, Either nos permite modelar explícitamente tanto el éxito como el fallo dentro del tipo de retorno de una función. Esta aproximación no solo mejora la predictibilidad y seguridad del código, sino que también promueve una composición más clara y fluida.

Si bien Either puede parecer más verboso en comparación con otros enfoques, su capacidad para hacer que los errores sean explícitos y tratables justifica su uso en contextos donde la confiabilidad y la claridad del flujo del programa son esenciales. Adoptarlo implica un cambio de mentalidad hacia un diseño más declarativo, donde los errores no son interrupciones inesperadas, sino valores manejables dentro del propio lenguaje de nuestro código.

Al finalizar esta lección, queda claro que Either es más que una alternativa a las excepciones: es una herramienta que nos permite escribir software más robusto, predecible y alineado con los principios de la programación funcional. Su uso consciente nos lleva a diseñar aplicaciones en las que el manejo de errores no es un obstáculo, sino una parte integral del flujo de ejecución.

📖 Referencias

🔥 Recomendadas

📚 Handling errors without exceptions. (2021). En Marco Vermeulen, Rúnar Bjarnason, & Paul Chiusano, Functional Programming in Kotlin (pp. 56–76). Manning Publications Co. LLC.

🔹 Adicionales

🌐 Mark Seemann. (2022, mayo 9). An Either monad. https://blog.ploeh.dk/2022/05/09/an-either-monad/

📄 Dylus, S., Christiansen, J., & Teegen, F. (2019). One Monad to Prove Them All. The Art, Science, and Engineering of Programming, 3(3), 8. https://doi.org/10.22152/programming-journal.org/2019/3/8