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 id
, name
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:
-
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
yconvertToMapList
se aplicaron a las instancias dePerson
, demostrando que se pueden extender las capacidades de las clases de manera flexible y limpia. -
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>
, yLogger
) para gestionar distintas funcionalidades y responsabilidades de forma coherente y sin contaminar las firmas de constructor. -
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. -
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
). -
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.