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
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.
"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.
- Código esencial
- Código completo
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)
}
}
package com.github.username.either
import io.kotest.core.spec.style.FreeSpec
import io.kotest.matchers.shouldBe
import io.kotest.property.Arb
import io.kotest.property.arbitrary.int
import io.kotest.property.checkAll
class EitherMonadTest : FreeSpec({
"Given a right" - {
// Left identity
"when a value is wrapped in the monadic context" - {
("then chaining a function should yield the same result as " +
"directly applying the function") {
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)
}
}
}
}
// Right identity
"when chaining with a function that returns the original context" - {
"then the result should remain unchanged in the monadic context" {
checkAll(Arb.int()) { a ->
EitherMonad.run {
pure<Throwable, Int>(a).flatMap { pure(it) } shouldBe pure(a)
}
}
}
}
// Associativity
"when chaining two functions sequentially" - {
("then the result should be consistent with first chaining one " +
"function and then the next") {
checkAll(
Arb.int(),
Arb.int(),
Arb.int()
) { a, b, c ->
EitherMonad.run {
val f: (Int) -> Either<Throwable, Int> = { pure(it * b) }
val g: (Int) -> Either<Throwable, Int> = { pure(it + c) }
pure<Throwable, Int>(a).flatMap(f).flatMap(g) shouldBe
pure<Throwable, Int>(a).flatMap { f(it).flatMap(g) }
}
}
}
}
}
"Given a left" - {
"when mapping a function" - {
"then the result should be the same left" {
checkAll(Arb.int()) { a ->
EitherMonad.run {
val f: (Int) -> Int = { it * 2 }
Left(a).flatMap { Right(f(it)) } shouldBe Left(a)
}
}
}
}
}
})
- 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
):
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>()