Skip to main content

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

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.

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 map\textrm{map}, permitiendo aplicar funciones a sus elementos sin alterar su forma.

Corolario

Si una estructura como una lista, un árbol o una caja permite aplicar una función a cada elemento sin cambiar su forma, entonces es un functor.

Leyes de los functores

Para que una estructura sea considerada un functor, debe cumplir con las siguientes dos leyes fundamentales:

  1. Ley de Identidad: Al aplicar la función identidad (id\textrm{id}), el functor no debe cambiar. Esto significa que mapear con la identidad debe producir el mismo functor con los mismos valores.

    map(id)=id \text{map}(\text{id}) = \text{id}

    Intuición: Si no estamos transformando los valores dentro del functor, la estructura debe quedar intacta.

  2. 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.

    map(fg)=map(f)map(g) \text{map}(f \circ g) = \text{map}(f) \circ \text{map}(g)

    Intuición: Primero mapear g y luego mapear f debe ser equivalente a mapear la composición de f y g 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...

$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 $_
}

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.

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)) }
}
}
¿Qué acabamos de hacer?
  • 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 y g sobre una caja genérica, en dos pasos separados, sea equivalente a mapear la composición de f y g en un solo paso.

Creando la Clase Box

Consideremos la siguiente implementación de una caja (Box) en Kotlin.

functors/src/main/kotlin/com/github/username/functors/Box.kt
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).

functors/src/main/kotlin/com/github/username/functors/BoxFunctor.kt
package cl.ravenhill.functors

class BoxFunctor {
fun <A, B> Box<A>.map(f: (A) -> B) = Box(f(this.value))
}
¿Qué acabamos de hacer?
  • map: Es un método de extensión que toma una función f 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>
}
Limitaciones
  1. Complejidad Adicional: Introduce una capa extra de abstracción que puede hacer el código más difícil de entender y mantener.
  2. 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.
  3. 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 de Box, 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.