Functores
⏱ 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.
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
.
Un functor permite transformar valores dentro de una estructura sin modificar su forma, facilitando código más seguro y componible en programación funcional.
Por ejemplo, en listas usamos map
para aplicar una función sin alterar la estructura:
listOf(1, 2, 3).map { it * 2 } // [2, 4, 6]
Veamos su definición formal.
Functor
Un functor es una abstracción matemática de la teoría de categorías aplicada en programación funcional. Se define como una estructura que soporta la operación , permitiendo aplicar funciones a sus elementos sin alterar su forma.
Corolario
Leyes de los functores
Para que una estructura sea considerada un functor, debe cumplir con las siguientes dos leyes fundamentales:
-
Ley de Identidad: Al aplicar la función identidad (), el functor no debe cambiar. Esto significa que mapear con la identidad debe producir el mismo functor con los mismos valores.
Intuición: Si no estamos transformando los valores dentro del functor, la estructura debe quedar intacta.
-
Ley de Composición: Aplicar la composición de dos funciones en un solo paso debe ser equivalente a aplicar cada función por separado, una tras otra, dentro del functor. Es decir, el resultado de mapear la composición de funciones debe ser el mismo que mapear las funciones individualmente, en el mismo orden.
Intuición: Primero mapear
g
y luego mapearf
debe ser equivalente a mapear la composición def
yg
en un solo paso.
Estas leyes garantizan que un functor preserve la estructura de las transformaciones aplicadas a sus valores, asegurando consistencia y previsibilidad en su comportamiento.
Implementando functores en Kotlin
Si quieres crear los archivos desde la terminal...
- Windows
- Windows (corto)
- Linux/Mac
$Group = 'com\github\username'
$FunctorsTestDir = "functors\src\test\kotlin\$Group\functors"
$FunctorsMainDir = "functors\src\main\kotlin\$Group\functors"
New-Item -Path $FunctorsDir, $FunctorsTestDir `
-ItemType Directory -Force
"$FunctorsTestDir\BoxFunctorTest.kt", "$FunctorsMainDir\Box.kt", `
"$FunctorsMainDir\BoxFunctor.kt" | ForEach-Object {
"package $Group.functors" -replace '\\', '.' |
Out-File -FilePath $_
}
$Group = 'com\github\username'
$FunctorsTestDir = "functors\src\test\kotlin\$Group\functors"
$FunctorsMainDir = "functors\src\main\kotlin\$Group\functors"
md $FunctorsDir $FunctorsTestDir
"$FunctorsTestDir\BoxFunctorTest.kt", "$FunctorsMainDir\Box.kt", `
"$FunctorsMainDir\BoxFunctor.kt" | % {
"package $Group.functors" -replace '\\', '.' > $_
}
GROUP="com/github/username"
FUNCTORS_DIR="functors/src/main/kotlin/$GROUP/functors"
FUNCTORS_TEST_DIR="functors/src/test/kotlin/$GROUP/functors"
mkdir -p "$FUNCTORS_DIR" "$FUNCTORS_TEST_DIR"
echo "package ${GROUP//\//.}.functors" > `
"$FUNCTORS_TEST_DIR/BoxFunctorTest.kt" `
"$FUNCTORS_DIR/Box.kt" "$FUNCTORS_DIR/BoxFunctor.kt"
Para ilustrar el concepto de functor, crearemos una clase Box
que encapsula un valor de tipo genérico. A continuación, implementaremos la funcionalidad de un functor para esta clase. Para propósitos didácticos, definiremos un functor como una estructura que contiene un método map
. Este método aplicará una función al valor almacenado en la Box
, transformándolo y devolviendo una nueva Box
con el resultado. Así, el functor nos permite realizar transformaciones sin extraer directamente el valor contenido.
Aunque aquí implementamos el functor de manera explícita para resaltar el concepto, en la práctica cualquier tipo que proporcione una función map
puede considerarse un functor, ya que map
es la operación esencial que define el comportamiento de un functor: aplicar una función a un valor encapsulado y devolver el resultado de forma encapsulada.
Especificación BDD
Para verificar que nuestro functor cumple con las leyes de los functores, primero definiremos las pruebas BDD correspondientes. Luego, implementaremos el functor y ejecutaremos las pruebas para confirmar que se cumplen las leyes de identidad y composición.
"Given a generic box" - {
"when mapping the identity function" - {
"should return the same box" {}
}
"when composing two functions" - {
"should be the same as applying the composed function once" {}
}
}
Implementando las pruebas
Con la especificación BDD en su lugar, ahora implementaremos las pruebas para verificar que nuestro functor cumple con las leyes de los functores.
- Código esencial
- Código completo
Ley de Identidad
checkAll(Arb.int()) { value ->
val box = Box(value)
with(BoxFunctor) {
box.map { it } shouldBe box
}
}
Ley de Composición
checkAll(Arb.int()) { value ->
val box = Box(value)
val f = { x: Int -> x + 1 }
val g = { x: Int -> x * 2 }
with(BoxFunctor) {
box.map(f).map(g) shouldBe box.map { g(f(it)) }
}
}
package com.github.username.functors
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 BoxFunctorTest : FreeSpec({
"Given a generic box" - {
"when mapping the identity function" - {
"should return the same box" {
checkAll(Arb.int()) { value ->
val box = Box(value)
with(BoxFunctor) {
box.map { it } shouldBe box
}
}
}
}
"when composing two functions" - {
"should be the same as applying the composed function once" {
checkAll(Arb.int()) { value ->
val box = Box(value)
val f = { x: Int -> x + 1 }
val g = { x: Int -> x * 2 }
with(BoxFunctor) {
box.map(f).map(g) shouldBe box.map { g(f(it)) }
}
}
}
}
}
})
- Ley de Identidad: Verificamos que mapear la función identidad sobre una caja genérica devuelva la misma caja.
- Ley de Composición: Comprobamos que mapear dos funciones
f
yg
sobre una caja genérica, en dos pasos separados, sea equivalente a mapear la composición def
yg
en un solo paso.
Creando la Clase Box
Consideremos la siguiente implementación de una caja (Box
) en Kotlin.
package com.github.username.functors
data class Box<out A>(val value: A)
Implementando el Functor para Box
Ahora, crearemos una clase para representar un functor para la caja (Box
).
package cl.ravenhill.functors
class BoxFunctor {
fun <A, B> Box<A>.map(f: (A) -> B) = Box(f(this.value))
}
map
: Es un método de extensión que toma una funciónf
y la aplica al valor contenido en la caja, devolviendo una nueva caja con el resultado de la función aplicada al valor original.
Higher-Kinded Types
En lenguajes como Haskell o Scala, es común utilizar higher-kinded types (HKT) para definir abstracciones genéricas como los functores. Los HKT permiten definir interfaces que operan sobre constructores de tipos, proporcionando una gran flexibilidad y reutilización de código.
Por ejemplo, en Haskell, un functor se define así:
class Functor f where
fmap :: (a -> b) -> f a -> f b
Aquí, f
es un constructor de tipos que puede ser aplicado a un tipo concreto a
para formar un nuevo tipo f a
.
Sin embargo, Kotlin no soporta higher-kinded types de forma nativa. Esto significa que no podemos directamente definir una interfaz Functor
que sea genérica sobre un constructor de tipos F
.
Para intentar superar esta limitación, algunxs desarrolladorxs utilizan patrones para simular HKT, como crear una interfaz Kind<F, A>
que actúa como un contenedor genérico:
interface Kind<F, A>
Y luego definir el functor de la siguiente manera:
interface Functor<F> {
fun <A, B> Kind<F, A>.map(f: (A) -> B): Kind<F, B>
}
- Complejidad Adicional: Introduce una capa extra de abstracción que puede hacer el código más difícil de entender y mantener.
- Seguridad de Tipos Reducida: El compilador de Kotlin no puede verificar completamente los tipos en estas estructuras simuladas, lo que puede llevar a errores en tiempo de ejecución.
- Código Verboso: Requiere escribir código adicional para cada tipo que se quiera usar con el functor, incluyendo conversiones y comprobaciones de tipos manuales.
Relación con la Implementación sin Higher-Kinded Types
Dado que la simulación de HKT en Kotlin introduce complejidad y reduce la seguridad de tipos, optamos por una implementación sin higher-kinded types, definiendo funciones map
específicas para cada estructura de datos que queremos tratar como functor.
En lugar de intentar crear una interfaz genérica para todos los tipos, podemos definir funciones de extensión map
directamente sobre nuestras clases. Por ejemplo, para nuestra clase Box
:
data class Box<out A>(val value: A)
fun <A, B> Box<A>.map(f: (A) -> B): Box<B> {
return Box(f(this.value))
}
Este enfoque presenta varias mejoras sobre el diseño original que intentaba simular HKT:
- Simplicidad: El código es más simple y fácil de leer, ya que no necesitamos introducir abstracciones adicionales como
Kind
. - Seguridad de Tipos Mejorada: Aprovechamos al máximo el sistema de tipos de Kotlin, evitando casting y comprobaciones manuales.
- Facilidad de Uso: Lxs desarrolladorxs pueden utilizar la función
map
directamente sobre las instancias deBox
, sin necesidad de envolver o desempaquetar los valores.
Al implementar los functores sin HKT, mantenemos la capacidad de mapear funciones sobre nuestras estructuras de datos, cumpliendo con las leyes de los functores y aprovechando las características del lenguaje de manera más eficiente.
Ejercicio: Functor para Pares
Implementa un functor para la clase Pair
en Kotlin que mapee la función solo sobre el segundo elemento del par.
Solución
object PairFunctor {
fun <A, B, C> Pair<A, B>.map(f: (B) -> C) =
this.first to f(this.second)
}
Bibliografías Recomendadas
- 📚 "11. Monads and Functors". (2021). M. Vermeulen, R. Bjarnason, & P. Chiusano, en Functional Programming in Kotlin, (pp. 231-257.) Manning Publications Co. LLC.