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 tipoT
. - 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...
- Windows
- Windows (corto)
- Linux/Mac
$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"
$Group = 'com\github\username'
$FunctorsTestDir = "functors\src\test\kotlin\$Group\functors"
$ResultTestDir = "$FunctorsTestDir\result"
md $ResultTestDir
"package $Group.functors.result" -replace '\\', '.' > `
"$ResultTestDir\ResultTest.kt"
GROUP=com/github/username
FUNCTORS_TEST_DIR="functors/src/test/kotlin/$GROUP/functors"
RESULT_TEST_DIR="$FUNCTORS_TEST_DIR/result"
mkdir -p "$RESULT_TEST_DIR"
echo "package ${GROUP//\//.}.functors.result" > \
"$RESULT_TEST_DIR/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.
- Código esencial
- Código completo
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)) }
}
}
package com.github.username.functors.result
import io.kotest.core.spec.style.FreeSpec
import io.kotest.matchers.shouldBe
import io.kotest.property.Arb
import io.kotest.property.arbitrary.choice
import io.kotest.property.arbitrary.int
import io.kotest.property.arbitrary.map
import io.kotest.property.arbitrary.string
import io.kotest.property.checkAll
class ResultTest : FreeSpec({
"Given a result" - {
"when mapping the identity function" - {
"should return the same result" {
checkAll(
Arb.choice(
Arb.int().map { Success(it) },
arbThrowable().map { Failure(it) }
)
) { result ->
with(ResultFunctor<Int>()) {
result.map { it } shouldBe result
}
}
}
}
"when composing two functions" - {
"should be the same as applying the composed function once" {
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)) }
}
}
}
}
}
})
private fun arbThrowable() = Arb.string().map { Throwable(it) }
- Ley de Identidad: Cuando mapeamos la función identidad (
id
) sobre unResult
, este no debería cambiar. El contenido delSuccess
o elFailure
sigue siendo el mismo. - Ley de Composición: Mapear dos funciones
f
yg
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...
- Windows
- Windows (corto)
- Linux/Mac
$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"
$FunctorMainDir = "functors\src\main\kotlin\$Group\functors"
$ResultMainDir = "$FunctorMainDir\result"
md $ResultMainDir
"package $Group.functors.result" -replace '\\', '.' > `
"$ResultMainDir\Result.kt"
"package $Group.functors.result" -replace '\\', '.' > `
"$ResultMainDir\Success.kt"
"package $Group.functors.result" -replace '\\', '.' > `
"$ResultMainDir\Failure.kt"
"package $Group.functors.result" -replace '\\', '.' > `
"$ResultMainDir\ResultFunctor.kt"
FUNCTOR_MAIN_DIR="functors/src/main/kotlin/$GROUP/functors"
RESULT_MAIN_DIR="$FUNCTOR_MAIN_DIR/result"
mkdir -p "$RESULT_MAIN_DIR"
echo "package ${GROUP//\//.}.functors.result" > \
"$RESULT_MAIN_DIR/Result.kt"
echo "package ${GROUP//\//.}.functors.result" > \
"$RESULT_MAIN_DIR/Success.kt"
echo "package ${GROUP//\//.}.functors.result" > \
"$RESULT_MAIN_DIR/Failure.kt"
echo "package ${GROUP//\//.}.functors.result" > \
"$RESULT_MAIN_DIR/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.
- Código esencial
- Código completo
sealed interface Result<out T>
data class Success<out T>(val value: T) : Result<T>
data class Failure(val exception: Throwable) : Result<Nothing>
package com.github.username.functors.result
sealed interface Result<out T>
package com.github.username.functors.result
data class Success<out T>(val value: T) : Result<T>
package com.github.username.functors.result
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).
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
}
}
- Identidad en el
Failure
: Cuando elResult
es unFailure
, la funciónmap
no aplica la transformación, garantizando que los errores no se modifiquen. - Transformación en
Success
: En el caso de unSuccess
, la funciónmap
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 delResult
se manejen de manera segura, previniendo errores de tipo. - Manejo de errores: La función
contraMap
permite transformar la excepción en unFailure
sin modificar el valor encapsulado en unSuccess
.
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.
- Código esencial
- Código completo
"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>()
}
}
}
package cl.ravenhill.functors.result
import io.kotest.core.spec.style.FreeSpec
import io.kotest.datatest.withData
import io.kotest.matchers.shouldBe
import io.kotest.matchers.throwable.shouldHaveMessage
import io.kotest.matchers.types.shouldBeInstanceOf
import io.kotest.property.Arb
import io.kotest.property.arbitrary.negativeInt
import io.kotest.property.checkAll
class CircleAreaTest : FreeSpec({
"Given a radius" - {
"when calculating the area of a circle" - {
"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>()
}
}
}
}
}
})
- 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 unFailure
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
oFailure
). - 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
:
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ónonSuccess
al valor encapsulado en unSuccess
y la funciónonFailure
a la excepción en unFailure
.runCatching: (() -> T): Result<T>
: Esta función ejecuta una operación que puede lanzar una excepción y devuelve unResult
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
Result
en la librería estándar de KotlinEn 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 devuelvenull
en caso de falla.exceptionOrNull()
: Permite acceder a la excepción en caso de falla o devuelvenull
en caso de éxito.onSuccess
yonFailure
: 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 comomap
yflatMap
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 sobreSuccess
permite transformar el valor encapsulado sin afectar el estado deFailure
, 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
yFailure
), 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
, elResult
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 mismoResult
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
- 🌐 "Result—Kotlin Programming Language." Accedido: 29 de septiembre de 2024. [En línea]. Disponible en: https://kotlinlang.org/api/latest/jvm/stdlib/kotlin/-result/index.html