Kotlin Context Receivers

Posted by     "RubentxuDev" on Wednesday, January 3, 2024

Este artículo explorará una poderosa característica del lenguaje de programación Kotlin llamado receptores de contexto (Context receivers). Si eres un desarrollador de Kotlin que busca escribir código más limpio y expresivo, los receptores de contexto son una herramienta que querrás tener disponible.

En Kotlin, los receptores de contexto proporcionan una forma conveniente de acceder a las funciones y propiedades de múltiples receptores dentro de un alcance específico. Ya sea que trabaje con interfaces, clases o clases de tipo(Type Class), los receptores de contexto le permiten optimizar su código y mejorar su capacidad de mantenimiento.

1. Configuración

Siga estos pasos de configuración para aprovechar el poder de los receptores de contexto en su proyecto Kotlin. Habilite la opción de compilación de receptores de contexto. Asegúrese de tener instalada la versión 1.8.22 o posterior de Kotlin antes de continuar.

Los receptores de contexto estan previsto que dejen de ser experimentales a partir de la versión Kotlin 2.0. Por lo tanto, no están habilitados de forma predeterminada si usamos una versión posterior. Necesitamos modificar la configuración de Gradle. Añadir el kotlinOptions bloque dentro del tasks.withType<KotlinCompile> bloque en tu build.gradle.kts archivo:

tasks.withType<KotlinCompile>().configureEach {
    kotlinOptions {
        freeCompilerArgs = freeCompilerArgs + "-Xcontext-receivers"
    }
}

Para que nuestros ejemplos sean más identificables, consideremos una representación simplificada de los datos relacionados con personas utilizando clases de datos de Kotlin y clases en línea.

data class Person(
  val id: PersonId
  val name: String, 
  val age: Int
)

@JvmInline
value class PersonId(val value: Long)

En el fragmento de código anterior, tenemos una clase  Person clase de datos que representa una persona. Cada Person tiene un idname y age. El PersonId es una clase en línea que envuelven tipos primitivos para proporcionar seguridad de tipo de datos y significado semántico.

Finalmente, podemos definir un mapa de personas e imitar la implementación de una base de datos:

object PersonDatabase {
    private val persons = listOf(
        Person(PersonId(1L), "Alice", 30),
        Person(PersonId(2L), "Bob", 25),
        Person(PersonId(3L), "Carol", 28)
    )

    fun getAllPersons(): List<Person> = persons

    fun getPersonById(id: PersonId): Person? = persons.find { it.id == id }
}

Ahora que tenemos nuestros objetos de dominio configurados, podemos sumergirnos en cómo los receptores de contexto pueden simplificar nuestro código y hacer que nuestra aplicación de búsqueda de personas sea más eficiente.

2. Despachadores y Receptores

Para ilustrar la función de receptores de contexto en un escenario diferente, usaremos la clase Person de nuestra PersonDatabase. Imaginemos una función que necesita convertir una lista de personas en una representación de mapa (Map). Llamaremos a esta función convertToMapList:

fun convertToMapList(persons: List<Person>) =
    persons.map { it.toMap() }

Al intentar compilar este código, nos encontraremos con un error, ya que no existe una función toMap definida en la clase Person:

Unresolved reference: toMap

Para mantener la integridad de nuestro modelo de dominio, implementamos la función de extensión toMap para el objeto de dominio Person:

fun Person.toMap(): Map<String, Any> =
    mapOf(
        "id" to id.value,
        "name" to name,
        "age" to age
    )

Aquí, la clase Person actúa como el receptor de la función toMap. El receptor es el objeto sobre el cual se invoca la función de extensión, y está disponible en el cuerpo de la función como this.

Ahora, podemos compilar nuestro código y convertir una lista de personas en una lista de mapas:

fun main() {
    val mapList = PersonDatabase.getAllPersons().let(::convertToMapList)
    mapList.forEach { println(it) }
}

Queremos hacer que la función convertToMapList sea genérica, permitiendo convertir cualquier lista de objetos en una lista de mapas. Por lo tanto, la modificamos para que sea genérica:

fun <T> convertToMapList(objs: List<T>) =
    objs.map { it.toMap() }

Sin embargo, nos enfrentamos al mismo problema original: no tenemos una función toMap definida para el tipo T. No queremos modificar la clase Person ni otros tipos para agregar métodos toMap().

Por lo tanto, queremos ejecutar nuestra versión genérica de convertToMapList solo en un contexto donde sabemos que existe una función toMap definida para el tipo T. Comenzamos definiendo un alcance seguro, una interfaz que define la función toMap:

interface MapScope<T> {
    fun T.toMap(): Map<String, Any>
}

En Kotlin, MapScope<T> es el receptor despachador de la función toMap, limitando la visibilidad de la función toMap para que solo pueda ser llamada dentro de este alcance.

interface MapScope<T> {    // <- dispatcher receiver
    fun T.toMap(): Map<String, Any>  // <- extension function receiver
    // 'this' type in 'toMap' function is MapScope<T> & T
}

El MapScope<T> es un lugar seguro para llamar al convertToMapList función ya que sabemos que tenemos acceso a una implementación concreta de la toMap función. Definimos la función convertToMapList como una extensión en la interfaz MapScope:

fun <T> MapScope<T>.convertToMapList(objs: List<T>) =
    objs.map { it.toMap() }

A continuación, definimos la implementación de MapScope para el tipo Person como un objeto anónimo:

val personMapScope = object : MapScope<Person> {
    override fun Person.toMap(): Map<String, Any> {
        return mapOf(
            "id" to id.value,
            "name" to name,
            "age" to age
        )
    }
}

Finalmente, llamamos a la función convertToMapList en el contexto seguro de personMapScope utilizando la función with de Kotlin:

fun main() {
    with(personMapScope) {
        val mapList = convertToMapList(PersonDatabase.getAllPersons())
        mapList.forEach { println(it) }
    }
}

Este enfoque, al igual que con las Type Class en Scala o Haskell, utiliza la interfaz MapScope como una clase de tipo (Type Class), y personMapScope es una instancia de esta clase de tipo para el tipo Person.

3. Receptores de Contexto

El enfoque que hemos utilizado hasta ahora, aunque efectivo, presenta algunas limitaciones.

Primero, agregamos la función convertToMapList como una extensión de la interfaz MapScope. Sin embargo, esta función no está intrínsecamente relacionada con el tipo MapScope. La colocamos allí como la única solución técnica viable, lo cual puede resultar confuso ya que convertToMapList no es un método del tipo MapScope.

En segundo lugar, las funciones de extensión solo están disponibles en objetos específicos. Por ejemplo, no es deseable que los desarrolladores usen convertToMapList de esta manera:

personMapScope.convertToMapList(PersonDatabase.getAllPersons())

Además, estamos limitados a usar solo un receptor en funciones de extensión con ámbitos. Por ejemplo, imaginemos una interfaz Logger y su implementación que registra en consola:

interface Logger {
    fun info(message: String)
}

val consoleLogger = object : Logger {
    override fun info(message: String) {
        println("[INFO] $message")
    }
}

Supongamos que queremos agregar capacidades de registro a nuestra función convertToMapList. No podemos hacerlo directamente porque está definida como una extensión de la interfaz MapScope, y no podemos añadir un segundo receptor.

Para superar estas limitaciones, introducimos los receptores de contexto. En Kotlin 1.6.20, se presentaron como una característica experimental para resolver estos problemas, permitiendo un código más flexible (esta previsto que la versión 2.0 de kotlin ya sean una realidad).

Los receptores de contexto permiten agregar un contexto o alcance a una función sin pasar este contexto como argumento. Si revisamos cómo abordamos el problema con convertToMapList, veremos que pasamos el contexto como argumento. En cambio, con los receptores de contexto, Kotlin introduce la palabra clave context para especificar el contexto necesario para ejecutar una función. Por ejemplo, podemos redefinir convertToMapList:

context (MapScope<T>)
fun <T> convertToMapList(objs: List<T>) =
    objs.map { it.toMap() }

El context seguido por el tipo de receptor de contexto, permite acceder a funciones de extensión definidas en MapScope.

¿Cómo introducimos una instancia de MapScope en el alcance de convertToMapList? Podemos usar la función with como antes:

fun main() {
    with(personMapScope) {
        println(convertToMapList(PersonDatabase.getAllPersons()))
    }
}

Hemos resuelto el problema de disponibilidad de la función convertToMapList. Además, podemos tener más de un contexto para nuestra función, ya que la palabra clave context acepta varios tipos como argumentos. Por ejemplo, podemos definir convertToMapList que tome MapScope y Logger como receptores de contexto:

context (MapScope<T>, Logger)
fun <T> convertToMapList(objs: List<T>): List<Map<String, Any>> {
    info("Serializando la lista $objs como un mapa")
    return objs.map { it.toMap() }
}

Para llamar a esta versión de convertToMapList, proporcionamos ambos contextos usando with:

fun main() {
    with(personMapScope) {
        with(consoleLogger) {
            println(convertToMapList(PersonDatabase.getAllPersons()))
        }
    }
}

Con esto, hemos resuelto las limitaciones iniciales. Dentro de una función que usa context, podemos acceder directamente al contexto usando this. Sin embargo, para referenciar una función específica de un contexto en situaciones de múltiples contextos, usamos la notación @:

context (MapScope<T>, Logger)
fun <T> convertToMapList(objs: List<T>): List<Map<String, Any>> {
    this@Logger.info("Serializando la lista $objs como un mapa")
    return objs.map { it.toMap() }
}

Los receptores de contexto también se pueden aplicar a nivel de clase. Por ejemplo, si queremos definir un módulo que proporcione funciones para manejar Person, podría ser así:

interface Persons {
    fun findById(id: PersonId): Person?
}

context (Logger)
class LivePersons : Persons {
    override fun findById(id: PersonId): Person? {
        info("Buscando persona con id $id")
        return PersonDatabase.getPersonById(id)
    }
}

Para instanciar LivePersons, usamos with:

fun main() {
    with(consoleLogger) {
        val persons = LivePersons()
    }
}

Este enfoque abre un debate interesante sobre si los receptores de contexto deben usarse para implementar una forma idiomática de inyección de dependencias.

4. ¿Para Qué Son Adecuados los Receptores de Contexto?

Tras comprender los conceptos básicos de los receptores de contexto, cabe preguntarse: ¿para qué situaciones son más adecuados? En secciones anteriores, vimos cómo usarlos para implementar clases de tipo (Type Class). El último ejemplo, aplicado a nivel de clase, parece ajustarse muy bien al concepto de inyección de dependencias.

Exploraremos si los receptores de contexto son adecuados para la inyección de dependencias con un ejemplo relacionado con nuestra estructura Person. Imaginemos una clase PersonController que expone personas como mapas y utiliza un módulo Persons para recuperarlas. Podemos definirla de la siguiente manera:

context (Persons, MapScope<Person>, Logger)
class PersonController {
    suspend fun findPersonById(id: String): Map<String, Any>? {
        info("Buscando persona con id $id")
        val personId = PersonId(id.toLong())
        return findById(personId)?.let {
            info("Persona con id $id encontrada")
            return it.toMap()
        } ?: null
    }
}

Para utilizar la clase PersonController, debemos proporcionar los tres contextos requeridos usando la función with:

suspend fun main() {
    with(personMapScope) {
        with(consoleLogger) {
            with(LivePersons()) {
                PersonController().findPersonById("1")?.also(::println)
            }
        }
    }
}

La clase PersonController tiene tres receptores de contexto: Persons, MapScope<Person>, y Logger. Dentro del método findPersonById, los contextos se acceden de manera implícita. El método info y la función findById se llaman como parte de la clase PersonController.

Sin embargo, el código no es claro respecto a qué método pertenece a qué clase. Para mejorar la legibilidad y evitar confusiones de nombres en múltiples contextos, podemos hacer uso de la notación @:

context (Persons, MapScope<Person>, Logger)
class PersonController {
    suspend fun findPersonById(id: String): Map<String, Any>? {
        this@Logger.info("Buscando persona con id $id")
        val personId = PersonId(id.toLong())
        return this@Persons.findById(personId)?.let {
            this@Logger.info("Persona con id $id encontrada")
            return it.toMap()
        } ?: null
    }
}

Aunque esta notación hace el código más explícito, también puede hacerlo menos legible y más complejo. En Scala, por ejemplo, este enfoque se consideró un antipatrón.

Generalmente, las lógicas de negocio siempre deben pasarse de manera explícita. Una excepción podría ser para efectos comunes compartidos por muchos servicios en nuestra aplicación, como el contexto Logger en nuestro ejemplo. Esto ayuda a evitar la contaminación de las firmas de constructor con el contexto Logger en todas partes.

Así, nuestro PersonController debería reescribirse de la siguiente manera:

context (MapScope<Person>, Logger)
class PersonController(private val persons: Persons) {
    suspend fun findPersonById(id: String): Map<String, Any>? {
        info("Buscando persona con id $id")
        val personId = PersonId(id.toLong())
        return persons.findById(personId)?.let {
            info("Persona con id $id encontrada")
            return it.toMap()
        } ?: null
    }
}

De esta forma, el código es más claro y las responsabilidades de cada llamada de método son explícitas.

En conclusión, aunque es posible implementar la inyección de dependencias a través de receptores de contexto, la solución final puede tener varias complicaciones y debería evitarse en ciertos casos.

Los receptores de contexto también pueden ser útiles para mejorar el manejo de errores tipificados, como se muestra en la librería Arrow, que utiliza receptores de contexto para un manejo funcional y tipificado de errores.

Conclusiones

Después de analizar detalladamente los conceptos y aplicaciones de los receptores de contexto en Kotlin, utilizando la estructura de datos Person y PersonDatabase, llegamos a varias conclusiones importantes:

  1. Flexibilidad y Limpieza en el Código: Los receptores de contexto ofrecen una forma poderosa de agregar funcionalidades específicas a clases sin modificar su estructura interna. Esto es evidente en cómo las funciones toMap y convertToMapList se aplicaron a las instancias de Person, demostrando que se pueden extender las capacidades de las clases de manera flexible y limpia.

  2. Mejora en la Gestión de Dependencias: A través del uso de receptores de contexto, se ilustra cómo se puede manejar la inyección de dependencias de una manera más sofisticada que el enfoque tradicional. Esto se mostró en la implementación de PersonController, donde se integraron varios contextos (Persons, MapScope<Person>, y Logger) para gestionar distintas funcionalidades y responsabilidades de forma coherente y sin contaminar las firmas de constructor.

  3. Claridad vs. Complejidad: A pesar de las ventajas en flexibilidad y gestión de dependencias, también se observó que el uso de múltiples contextos puede llevar a un código menos legible y más complejo. La notación @ ayuda a clarificar qué método pertenece a qué contexto, pero puede aumentar la complejidad del código, lo cual debe equilibrarse cuidadosamente.

  4. Uso Limitado y Cuidadoso: La utilización de receptores de contexto, aunque poderosa, debe ser limitada y cuidadosa. Es preferible pasar las lógicas de negocio de manera explícita para mantener la claridad del código. Se recomienda reservar los receptores de contexto para efectos comunes y compartidos, como la funcionalidad de registro (Logger).

  5. Aplicaciones Prácticas y Teóricas: A través de la revisión de los conceptos y su aplicación práctica, se destaca cómo los receptores de contexto pueden ser una herramienta valiosa tanto para desarrolladores experimentados en Kotlin como para aquellos que buscan comprender mejor patrones avanzados de diseño y arquitectura de software.

En resumen, los receptores de contexto en Kotlin ofrecen un método avanzado y flexible para manejar funcionalidades y dependencias en el desarrollo de software, pero su uso debe ser medido y adecuado a las necesidades específicas del proyecto para evitar complicaciones innecesarias. La comprensión y aplicación correcta de estos conceptos puede llevar a un código más modular, limpio y mantenible.