Skip to main content

El functor Result

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


r8vnhill/

En la programación funcional y en lenguajes modernos como Kotlin, el manejo de errores y excepciones puede abordarse de manera más segura y expresiva mediante el uso de estructuras de datos como el functor Result. Este enfoque permite representar computaciones que pueden fallar, evitando el uso de excepciones y facilitando la composición y transformación de resultados.

En esta lección, exploraremos qué es el functor Result, cómo utilizarlo en Kotlin y cuáles son sus ventajas en el manejo de errores y computaciones que pueden fallar.

¿Qué es el functor Result?

El functor Result es una estructura de datos que representa un valor que puede ser exitoso o contener un error. En Kotlin, Result es una clase sellada que tiene dos posibles estados:

  • Éxito (Success): Contiene un valor de tipo T.
  • Falla (Failure): Contiene una excepción (Throwable).

Esta estructura permite modelar computaciones que pueden fallar sin recurrir a excepciones tradicionales, facilitando el manejo explícito de errores y promoviendo un código más seguro y fácil de razonar.

Leyes de un functor

Si quieres crear los archivos desde la terminal...

$Group = 'com\github\username'
$FunctorsTestDir = "functors\src\test\kotlin\$Group\functors"
$ResultTestDir = "$FunctorsTestDir\result"
New-Item -Path $ResultTestDir -ItemType Directory -Force
"package $Group.functors.result" -replace '\\', '.' |
Out-File -FilePath "$ResultTestDir\ResultTest.kt"

Para que una estructura de datos sea considerada un functor, debe cumplir dos leyes fundamentales: la ley de identidad y la ley de composición. Estas leyes aseguran que la estructura respete los principios del cálculo funcional y se comporte de manera predecible cuando aplicamos funciones a sus valores internos.

Ley de Identidad

La ley de identidad establece que si aplicamos la función identidad (id) a un functor, el resultado debe ser el mismo functor. En otras palabras, mapear la función identidad no debe alterar el contenido del Result:

checkAll(
Arb.choice(
Arb.int().map { Success(it) },
arbThrowable().map { Failure(it) }
)
) { result ->
with(ResultFunctor<Int>()) {
result.map { it } shouldBe result
}
}

Ley de Composición

La ley de composición dice que si mapeamos dos funciones f y g sobre un functor, esto debe ser equivalente a mapear la composición de f y g en una sola operación. Es decir, la siguiente igualdad debe mantenerse:

checkAll(
Arb.choice(
Arb.int().map { Success(it) },
arbThrowable().map { Failure(it) }
)
) { result ->
val f = { x: Int -> x + 1 }
val g = { x: Int -> x * 2 }

with(ResultFunctor<Int>()) {
result.map(f).map(g) shouldBe result.map { g(f(it)) }
}
}
¿Qué acabamos de hacer?
  • Ley de Identidad: Cuando mapeamos la función identidad (id) sobre un Result, este no debería cambiar. El contenido del Success o el Failure sigue siendo el mismo.
  • Ley de Composición: Mapear dos funciones f y g en secuencia debería ser igual a mapear la composición de ambas en una sola operación, lo que garantiza que el comportamiento del functor respete las reglas de la composición de funciones.

Implementación de Result en Kotlin

Si quieres crear los archivos desde la terminal...

$FunctorMainDir = "functors\src\main\kotlin\$Group\functors"
$ResultMainDir = "$FunctorMainDir\result"
New-Item -Path $ResultMainDir -ItemType Directory -Force
"package $Group.functors.result" -replace '\\', '.' |
Out-File -FilePath "$ResultMainDir\Result.kt"
"package $Group.functors.result" -replace '\\', '.' |
Out-File -FilePath "$ResultMainDir\Success.kt"
"package $Group.functors.result" -replace '\\', '.' |
Out-File -FilePath "$ResultMainDir\Failure.kt"
"package $Group.functors.result" -replace '\\', '.' |
Out-File -FilePath "$ResultMainDir\ResultFunctor.kt"

A continuación, presentamos una implementación básica de la estructura Result en Kotlin, la cual modela dos estados: éxito (Success) y fallo (Failure). Esta estructura es común en lenguajes funcionales para representar operaciones que pueden fallar.

sealed interface Result<out T>

data class Success<out T>(val value: T) : Result<T>

data class Failure(val exception: Throwable) : Result<Nothing>

Una vez que hemos definido los estados Success y Failure, podemos implementar el functor para la estructura Result. Esto nos permitirá aplicar transformaciones a los valores encapsulados dentro de Success sin modificar el estado Failure.

La siguiente implementación permite mapear una función sobre un Result, incluyendo una función contraMap que transforma la excepción en caso de fallo sin modificar el valor encapsulado en un Success (esto no es común en todos los functors, pero es útil en este caso para mantener la consistencia de los errores).

functors/src/main/kotlin/com/github/username/functors/result/ResultFunctor.kt
package com.github.username.functors.result

class ResultFunctor<T> {
fun <R> Result<T>.map(f: (T) -> R): Result<R> = when(this) {
is Failure -> this
is Success -> Success(f(value))
}

fun <R: Throwable> Result<T>.contraMap(f: (Throwable) -> R): Result<T> = when(this) {
is Failure -> Failure(f(exception))
is Success -> this
}
}
¿Qué acabamos de hacer?
  • Identidad en el Failure: Cuando el Result es un Failure, la función map no aplica la transformación, garantizando que los errores no se modifiquen.
  • Transformación en Success: En el caso de un Success, la función map transforma el valor encapsulado, permitiendo operaciones seguras sobre el contenido sin riesgo de alterar los errores.
  • Seguridad en tiempo de compilación: El uso de genéricos en la función map asegura que tanto la entrada como la salida del Result se manejen de manera segura, previniendo errores de tipo.
  • Manejo de errores: La función contraMap permite transformar la excepción en un Failure sin modificar el valor encapsulado en un Success.

Ahora podemos ejecutar las pruebas unitarias para verificar que nuestra implementación de Result cumple con las leyes de un functor.

Ejemplo de uso: Cálculo de área de un círculo

Vamos a implementar una función que calcule el área de un círculo. La función recibirá el radio como un Double y devolverá el área como un Result. Si el radio es negativo, devolveremos un Failure con un mensaje de error.

Especificación de la función calculateArea

Comencemos definiendo una especificación para la función calculateArea que calcula el área de un círculo:

"Given a radius" - {
"when calculating the area of a circle" - {
"should return a success if the radius is non-negative" - {}
"should return a failure if the radius is negative" {}
}
}

Implementación de las pruebas unitarias

Ahora, completaremos los detalles de los casos de prueba para verificar el comportamiento de la función calculateArea, utilizaremos Data-Driven testing para definir casos de prueba con radios positivos y property-based testing para radios negativos.

"should return a success if the radius is non-negative" - {
withData(
0 to 0.0,
1 to 3.141592653589793,
2 to 12.566370614359172,
3 to 28.274333882308138
) { (radius, expected) ->
with(ResultFunctor<Double>()) {
circleArea(radius)
.map { it shouldBe expected }
.shouldBeInstanceOf<Success<Double>>()
.contraMap { it }
.shouldBeInstanceOf<Success<Double>>()
}
}
}

"should return a failure if the radius is negative" {
checkAll(Arb.negativeInt()) { radius ->
with(ResultFunctor<Double>()) {
circleArea(radius)
.map { it }
.shouldBeInstanceOf<Failure>()
.contraMap {
it shouldHaveMessage "Radius must be non-negative"
it
}.shouldBeInstanceOf<Failure>()
}
}
}
¿Qué acabamos de hacer?
  • Cálculo del área: Utilizamos Data-Driven testing para probar el cálculo del área con radios positivos y comparamos el resultado esperado con el valor obtenido.
  • Manejo de errores: Verificamos que la función circleArea devuelva un Failure con el mensaje correcto cuando el radio es negativo.
  • Verificación de tipos: Utilizamos shouldBeInstanceOf para asegurarnos de que el resultado sea del tipo esperado (Success o Failure).
  • Mapeo de errores: Aplicamos la función contraMap para verificar que el mensaje de error sea el correcto en caso de fallo.

Implementación de la función circleArea

package com.github.username.functors.result

import kotlin.math.PI

fun circleArea(radius: Int): Result<Double> = if (radius < 0) {
Failure(IllegalArgumentException("Radius must be non-negative"))
} else {
Success(PI * radius * radius)
}
Ejercicio

Implementa las siguientes funciones para el functor Result:

  1. fold: Result<T>.(onSuccess: (T) -> R, onFailure: (Throwable) -> R): R: Esta función permite manejar los casos de éxito y fallo de manera más flexible y expresiva. Debe aplicar la función onSuccess al valor encapsulado en un Success y la función onFailure a la excepción en un Failure.
  2. runCatching: (() -> T): Result<T>: Esta función ejecuta una operación que puede lanzar una excepción y devuelve un Result con el resultado o el error capturado.
Solución
fun <R> Result<T>.fold(
onFailure: (Throwable) -> R,
onSuccess: (T) -> R
): R = when(this) {
is Failure -> onFailure(exception)
is Success -> onSuccess(value)
}

fun runCatching(block: () -> T): Result<T> = try {
Success(block())
} catch (e: Throwable) {
Failure(e)
}

Functor Result en la librería estándar de Kotlin

En la librería estándar de Kotlin, existe una clase Result que es muy similar a la que hemos implementado. El propósito es encapsular el éxito o fracaso de una operación y manejar los errores de manera funcional sin depender de excepciones tradicionales. La clase Result estándar tiene varias características útiles:

  • getOrNull(): Permite acceder al valor en caso de éxito o devuelve null en caso de falla.
  • exceptionOrNull(): Permite acceder a la excepción en caso de falla o devuelve null en caso de éxito.
  • onSuccess y onFailure: Son funciones que permiten aplicar una operación solo en caso de éxito o fallo, respectivamente.

Ejemplo de uso de Result estándar de Kotlin:

fun divide(a: Int, b: Int): Result<Int> =
if (b != 0) Result.success(a / b)
else Result.failure(IllegalArgumentException("División por cero"))

fun main() {
val result = divide(10, 0)

result
.onSuccess { value -> println("Resultado: $value") }
.onFailure { error -> println("Error: ${error.message}") }
}

En este ejemplo, la operación de división devuelve un Result y dependiendo del éxito o fallo, se ejecuta el código correspondiente sin la necesidad de capturar excepciones de manera explícita.

El Result estándar de Kotlin también ofrece otras utilidades como map, fold y recover, que permiten transformar el resultado de una operación o manejar el error de manera declarativa.

Diferencias entre el Result personalizado y el estándar

  • Disponibilidad: El Result estándar de Kotlin es parte de la librería estándar y es preferible usarlo en la mayoría de los casos para aprovechar su integración con las funciones y la API de Kotlin.
  • Flexibilidad: El Result personalizado que hemos creado permite agregar cualquier comportamiento adicional o modificar cómo se manejan los errores, lo cual puede ser útil en proyectos con necesidades específicas.

Beneficios y limitaciones del functor Result

Beneficios

  • Manejo explícito de errores: El uso de Result promueve un manejo de errores más explícito y controlado, evitando las excepciones tradicionales que pueden ser difíciles de rastrear.
  • Composición fluida: El functor Result facilita la composición de operaciones seguras. Funciones como map y flatMap permiten encadenar operaciones sin romper el flujo del programa.
  • Seguridad en tiempo de compilación: Al tratar los errores como un estado explícito dentro del tipo Result, Kotlin fuerza a que los errores se manejen en tiempo de compilación, previniendo fallos en tiempo de ejecución.
  • Transformaciones seguras: La función map aplicada sobre Success permite transformar el valor encapsulado sin afectar el estado de Failure, garantizando la inmutabilidad y seguridad de los errores.
  • Legibilidad y claridad: Utilizar Result hace que el código sea más legible, ya que las funciones que pueden fallar lo indican de manera explícita en su tipo de retorno, haciendo que los flujos de éxito y fallo sean claros.

Limitaciones

  • Sobrecarga en operaciones simples: En algunos casos, utilizar Result para operaciones sencillas puede generar una sobrecarga adicional en el código, ya que requiere manejar ambos casos (Success y Failure), incluso cuando los errores son poco frecuentes.
  • Curva de aprendizaje: Aunque el uso de Result es beneficioso, quienes provienen de lenguajes que utilizan excepciones tradicionales pueden encontrar una pequeña curva de aprendizaje al acostumbrarse a la manipulación explícita de errores.
  • Composición limitada: Aunque el Result permite la composición de funciones, su capacidad para componer múltiples operaciones puede ser limitada en casos donde el manejo de errores es muy complejo, requiriendo estructuras más avanzadas.
  • Uso inconsistente: Si no se sigue de manera coherente en todo el proyecto, la mezcla de excepciones tradicionales y Result puede generar inconsistencias y dificultar el mantenimiento del código.
  • Verboso en casos simples: En comparación con el manejo de excepciones mediante try-catch, el Result puede parecer más verboso en situaciones donde solo se requiere una simple captura de error.

¿Qué aprendimos?

En esta lección, aprendimos a utilizar el functor Result en Kotlin para manejar computaciones que pueden fallar de forma más controlada y clara. Exploramos cómo Result nos permite modelar tanto el éxito como el fallo en una operación sin utilizar excepciones tradicionales, y cómo aplicar transformaciones seguras mediante funciones como map. También vimos cómo las leyes del functor, como la identidad y la composición, garantizan el comportamiento predecible y seguro de estas operaciones.

Puntos clave

  • Manejo explícito de errores: Result proporciona una manera clara de gestionar errores y resultados exitosos, evitando la dependencia en excepciones.
  • Composición funcional: Funciones como map permiten transformar valores de manera segura, sin afectar el estado del error.
  • Seguridad en tiempo de compilación: Kotlin fuerza a manejar los casos de error, lo que reduce fallos en tiempo de ejecución.
  • Ley de identidad: Mapear la función identidad sobre un Result debe devolver el mismo Result sin modificaciones.
  • Ley de composición: Mapear dos funciones consecutivamente debe ser equivalente a mapear la composición de ambas en una sola operación.

El uso de Result en Kotlin es una herramienta poderosa para desarrollar programas más robustos y seguros, especialmente en el manejo de computaciones que pueden fallar. Al utilizar Result, promovemos un estilo de programación funcional, con un enfoque más claro y seguro en la gestión de errores, mejorando la legibilidad y el mantenimiento del código.

Bibliografías Recomendadas