Cotas
⏱ Dedicación recomendada: 0 minutos
Esto considera el contenido visible y relevante, e ignora texto colapsado o marcado como opcional.
r8vnhill/generic-programming-kt
Puedes ejecutar el siguiente comando para crear el módulo
./gradlew setupBoundsModule
Mientras se crean los archivos necesarios, puedes leer el código para saber qué está pasando.
import tasks.ModuleSetupTask
tasks.register<ModuleSetupTask>("setupBoundsModule") {
description = "Creates the base module and files for the bounds lesson"
module.set("bounds")
doLast {
createFiles(
"repo",
main to "Repository.kt",
main to "Entity.kt",
main to "Serializable.kt",
test to "RepositoryTest.kt"
)
createFiles(
"notifications",
main to "EmailNotificationSystem.kt",
main to "NotificationHandler.kt",
main to "Notification.kt",
test to "NotificationSystemTest.kt"
)
}
}
Preocúpate de que el plugin bounds
esté aplicado en el archivo build.gradle.kts
de tu proyecto.
./gradlew setupBoundsModule
Preocúpate de que el nuevo módulo esté incluido en el archivo settings.gradle.kts
.
La programación genérica en Kotlin nos permite crear clases y funciones que pueden trabajar con cualquier tipo, proporcionando una gran flexibilidad y reutilización de código. Sin embargo, a veces necesitamos restringir los tipos que pueden ser utilizados con un genérico. Aquí es donde entran las cotas superiores y cotas inferiores.
En esta lección, exploraremos:
- Cómo declarar cotas superiores en genéricos de Kotlin.
- Las dos formas de declarar cotas superiores y cómo utilizar
where
para múltiples cotas. - Notas sobre cotas inferiores, cómo Kotlin no las soporta nativamente, pero cómo podemos emularlas hasta cierto punto usando varianza en sitio de uso.
- Ejemplos contextualizados en el desarrollo de bibliotecas de software.
📌 Cotas superiores en genéricos
Una cota superior restringe el tipo genérico a un subtipo específico. Esto significa que el tipo genérico debe ser la cota superior o un subtipo de ella.
Imaginemos que estamos desarrollando una biblioteca de persistencia y queremos crear una clase genérica Repository
que solo acepte tipos que implementen la interfaz Entity
. Esto garantiza que todas las entidades manejadas por la biblioteca sigan un contrato común, facilitando la manipulación y persistencia de datos.
Queremos que el comportamiento de nuestra clase Repository
sea el siguiente:
- Código esencial
- Código completo
with(repository) {
entities.shouldBeEmpty()
save(entity)
entities shouldHaveSize 1
entities.last() shouldBe entity
serializeAll() shouldBe "[MockEntity(id=1)]"
}
package com.github.username.repo
import io.kotest.core.spec.style.FreeSpec
import io.kotest.matchers.collections.shouldBeEmpty
import io.kotest.matchers.collections.shouldHaveSize
import io.kotest.matchers.shouldBe
private class MockEntity(override val id: Int) : Entity, Serializable {
override fun serialize() = "MockEntity(id=$id)"
override fun deserialize(serialized: String) = serialized
.substringAfter("id=").toInt()
.let(::MockEntity)
}
class RepositoryTest : FreeSpec({
"A repository" - {
"when saving an entity" - {
"should contain the entity" {
val repository = Repository<MockEntity>()
val entity = MockEntity(1)
with(repository) {
entities.shouldBeEmpty()
save(entity)
entities shouldHaveSize 1
entities.last() shouldBe entity
serializeAll() shouldBe "[MockEntity(id=1)]"
}
}
}
}
})
En este test, estamos verificando que el repositorio pueda almacenar una entidad correctamente. Después de guardar la entidad, comprobamos que la lista de entidades tenga un tamaño de 1
y que la última entidad en la lista sea la misma que hemos guardado. Este comportamiento es esencial para asegurar que la biblioteca de persistencia maneja las entidades de manera consistente y segura.
Primera forma: declaración directa en el parámetro genérico
La forma más común de declarar una cota superior es directamente en la declaración del parámetro genérico usando T : UpperBound
.
- Código esencial
- Código completo
class Repository<T : Entity>
package com.github.username.repo
interface Entity {
val id: Int
}
package com.github.username.repo
class Repository<T : Entity> {
private val _entities: MutableList<T> = mutableListOf()
val entities: List<T> = _entities
fun save(entity: T) {
_entities += entity
}
}
Entity
es la cota superior.T : Entity
significa queT
debe serEntity
o una clase que herede deEntity
.
Segunda forma: Usar la cláusula where
para múltiples cotas
Cuando es necesario aplicar múltiples restricciones a un tipo genérico en Kotlin, la cláusula where
proporciona una forma clara y organizada de hacerlo. Esta técnica es útil cuando un tipo debe cumplir con más de una condición, garantizando que todas las restricciones se definan de manera explícita y comprensible.
- Código esencial
- Código completo
class Repository<T> where T : Entity,
T : Serializable
package com.github.username.repo
class Repository<T> where T : Entity,
T : Serializable {
private val _entities: MutableList<T> = mutableListOf()
val entities: List<T> = _entities
fun save(entity: T) {
_entities += entity
}
fun serializeAll() = entities
.joinToString(prefix = "[", postfix = "]") {
it.serialize()
}
}
- En este caso,
T
debe ser un subtipo deEntity
y también implementar la interfazSerializable
. De esta manera, cualquier tipo que se utilice con esta clase cumplirá ambas restricciones. - La cláusula
where
se coloca después de la lista de parámetros genéricos y antes del cuerpo de la función o clase, proporcionando una estructura clara que facilita la lectura y comprensión de las restricciones aplicadas.
La elección entre usar la declaración directa y la cláusula where
depende de la complejidad de las restricciones y de la claridad del código. Para restricciones simples, la declaración directa es más concisa y fácil de entender. En cambio, cuando hay múltiples restricciones o condiciones más complejas, la cláusula where
mejora la legibilidad y organiza mejor las relaciones entre tipos.
Por ejemplo, el siguiente código que utiliza la declaración directa para establecer cotas superiores puede resultar menos legible:
interface Evolver<T, F : Feature<T, F>, R : Representation<T, F>, S : EvolutionState<T, F, R, S>>
En comparación con el uso de la cláusula where
, que distribuye las restricciones de forma más clara:
interface Evolver<T, F, R, S>
where F : Feature<T, F>,
R : Representation<T, F>,
S : EvolutionState<T, F, R, S>
En última instancia, la elección se basa en las preferencias de lx desarrolladorx y en el enfoque que maximice la claridad y mantenibilidad del código.
Método | Ventajas | Cuándo Usarlo |
---|---|---|
Declaración directa (T : UpperBound ) | Simple y fácil de entender | Cuando solo hay una cota superior |
Cláusula where | Más legible cuando hay múltiples restricciones | Cuando se requieren varias cotas superiores |
⚖️ Beneficios y limitaciones de las cotas superiores
Beneficios
- Seguridad de tipos en tiempo de compilación: Las cotas superiores aseguran que solo se acepten tipos que cumplan con ciertas restricciones, lo que permite detectar errores en tiempo de compilación y garantiza que las operaciones solo se realicen sobre tipos compatibles.
- Flexibilidad y reutilización: Al definir cotas superiores, se puede crear código genérico flexible que funcione con cualquier subtipo que cumpla las restricciones, permitiendo reutilizar clases y funciones sin necesidad de reescribirlas para cada caso específico.
- Clara intención de uso: Especificar cotas superiores hace explícitas las expectativas y restricciones de una clase o función, lo que mejora la legibilidad y el mantenimiento del código, ya que otrxs desarrolladorxs pueden entender rápidamente qué tipos se esperan.
- Integridad de la API: En el contexto de bibliotecas de software, las cotas superiores ayudan a asegurar que solo los tipos adecuados interactúen con la API, previniendo usos incorrectos que podrían llevar a errores en tiempo de ejecución.
Limitaciones
- Complejidad adicional: Usar cotas superiores y múltiples restricciones puede hacer que las definiciones de funciones y clases genéricas sean más complicadas, lo que puede ser intimidante para desarrolladorxs menos experimentados.
- Rigidez: Aunque las cotas superiores proporcionan flexibilidad dentro de ciertos límites, también imponen restricciones que podrían no ser necesarias en todos los contextos. Esto puede hacer que el código sea menos adaptable en casos que no cumplan exactamente con las restricciones impuestas.
- Mensajes de error difíciles de interpretar: Los errores relacionados con restricciones genéricas y cotas superiores pueden ser complicados de diagnosticar y entender, lo que puede ralentizar el proceso de depuración y desarrollo.
📉 Cotas inferiores
En Kotlin, las cotas inferiores no son compatibles de forma nativa, lo que significa que no se pueden declarar directamente en la definición de un genérico. Sin embargo, es posible emular las cotas inferiores utilizando varianza en sitio de uso.
🔄 Emulando cotas inferiores con varianza en sitio de uso
La varianza en Kotlin permite especificar cómo los subtipos y supertipos se relacionan entre sí en el contexto de una clase genérica. Al utilizar la varianza de manera estratégica, podemos emular el comportamiento de las cotas inferiores.
Por ejemplo, supongamos que estamos desarrollando una biblioteca de notificaciones y queremos crear un sistema que pueda manejar diferentes tipos de notificaciones.
Imaginemos que tenemos una clase NotificationHandler
que puede manejar notificaciones de diferentes tipos. Para poder reusar un mismo handler para múltiples notificaciones, definimos un campo mutable notification
que puede ser de cualquier tipo de notificación, o nulo.
package com.github.username.notifications
class NotificationHandler<N: Notification> {
var notification: N? = null
}
Supongamos que queremos crear un sistema que maneje exclusivamente notificaciones de correos electrónicos. Para ello, definiremos una clase EmailNotificationSystem
que registre un NotificationHandler
diseñado específicamente para notificaciones de correos electrónicos. Dado que un correo electrónico es un subtipo de notificación, esperaríamos poder utilizar un NotificationHandler<Notification>
(que maneja cualquier tipo de notificación) para procesar notificaciones de correo electrónico sin problemas.
Esto quiere decir que necesitamos un método registerHandler
en EmailNotificationSystem
que acepte un NotificationHandler<T>
donde T
es un supertipo de EmailNotification
. Es decir, necesitamos una cota inferior en el parámetro genérico de registerHandler
.
- Código esencial
- Código completo
val system = EmailNotificationSystem()
val generalHandler = NotificationHandler<Notification>()
with(system) {
handlers.shouldBeEmpty()
registerHandler(generalHandler)
handlers shouldHaveSize 1
handlers.last() shouldBe generalHandler
}
package cl.ravenhill.notifications
import io.kotest.core.spec.style.FreeSpec
import io.kotest.matchers.collections.shouldBeEmpty
import io.kotest.matchers.collections.shouldHaveSize
import io.kotest.matchers.shouldBe
class NotificationSystemTest : FreeSpec({
"A notification system" - {
"when registering a handler" - {
"should contain the handler" {
val system = EmailNotificationSystem()
val generalHandler = NotificationHandler<Notification>()
with(system) {
handlers.shouldBeEmpty()
registerHandler(generalHandler)
handlers shouldHaveSize 1
handlers.last() shouldBe generalHandler
}
}
}
}
})
Del concepto de contravarianza, sabemos que si A
es un subtipo de B
, entonces Consumer<B>
es un subtipo de Consumer<A>
. Aplicando esto, si EmailNotification
es un subtipo de Notification
, entonces un NotificationHandler<Notification>
puede ser utilizado en cualquier lugar donde se espere un NotificationHandler<EmailNotification>
. Esto nos permite aceptar un NotificationHandler<Notification>
en contextos que requieren un NotificationHandler<EmailNotification>
, emulando efectivamente una cota inferior en Kotlin.
package com.github.username.notifications
typealias EmailNotificationHandler =
NotificationHandler<in EmailNotification>
class EmailNotificationSystem {
private val _handlers = mutableListOf<EmailNotificationHandler>()
val handlers: List<EmailNotificationHandler> = _handlers
fun registerHandler(handler: EmailNotificationHandler) {
_handlers += handler
}
}
EmailNotificationHandler
es un alias paraNotificationHandler<in EmailNotification>
, que representa un handler que acepta notificaciones de correo electrónico o cualquier supertipo deEmailNotification
.registerHandler
acepta unEmailNotificationHandler
, que puede ser unNotificationHandler<EmailNotification>
o un supertipo deEmailNotification
.
💡 Beneficios y limitaciones de emular cotas inferiores
Beneficios
- Flexibilidad y reutilización de código: Emular cotas inferiores mediante la contravarianza permite que los mismos handlers genéricos puedan ser reutilizados en múltiples contextos, ampliando la flexibilidad de la biblioteca y evitando la duplicación de código.
- Compatibilidad con jerarquías de tipos: Esta técnica permite que sistemas o clases que trabajan con jerarquías de tipos puedan aceptar handlers que funcionen con tipos más generales. Esto es especialmente útil cuando se manejan entidades o eventos que tienen múltiples subtipos.
- Seguridad de tipos en tiempo de compilación: A pesar de no soportar cotas inferiores nativamente, el uso de contravarianza sigue siendo seguro en tiempo de compilación, garantizando que solo se acepten tipos válidos según las restricciones establecidas.
Limitaciones
- Complejidad conceptual: La emulación de cotas inferiores usando varianza en sitio de uso puede ser difícil de entender para desarrolladorxs que no están familiarizadxs con la contravarianza y sus implicaciones, lo que puede llevar a errores o malentendidos en el diseño de la API.
- Limitaciones en la flexibilidad: A diferencia de las cotas inferiores nativas, esta técnica tiene limitaciones, ya que solo se aplica en situaciones específicas donde es posible usar contravarianza. En casos más complejos, puede que no sea suficiente o se requiera un enfoque alternativo.
- Mensajes de error difíciles de diagnosticar: Los mensajes de error del compilador relacionados con la contravarianza y las restricciones de tipos pueden ser difíciles de interpretar, lo que puede hacer que la depuración y el desarrollo sean más complicados y menos intuitivos.
🎯 Conclusiones
A lo largo de esta lección, hemos explorado el uso de cotas superiores y la emulación de cotas inferiores en Kotlin, entendiendo cómo estas técnicas afectan la seguridad y flexibilidad de los tipos en programación genérica. En el contexto del desarrollo de bibliotecas de software, estas herramientas permiten definir APIs más expresivas y seguras, restringiendo los tipos aceptados sin comprometer la reutilización del código.
🔑 Puntos clave
- Cotas superiores
- Permiten restringir el tipo genérico a un subtipo específico.
- Se pueden declarar directamente (
T : UpperBound
) o mediantewhere
cuando hay múltiples restricciones. - Aseguran que las operaciones se realicen sobre tipos compatibles, evitando errores en tiempo de ejecución.
- Cotas inferiores (emulación en Kotlin)
- Kotlin no soporta cotas inferiores de manera nativa.
- Se pueden emular mediante contravarianza (
in
), permitiendo aceptar supertipos en contextos específicos. - Son útiles para diseñar APIs que trabajen con jerarquías de tipos, pero tienen limitaciones y pueden ser más difíciles de entender.
- Casos de uso en bibliotecas de software
- En bibliotecas de persistencia, las cotas superiores permiten definir repositorios genéricos que solo acepten tipos compatibles con la persistencia de datos.
- En sistemas de eventos y notificaciones, la emulación de cotas inferiores facilita la reutilización de handlers sin romper la seguridad de tipos.
- Beneficios y limitaciones
- Las cotas superiores mejoran la seguridad de tipos y reutilización, pero pueden hacer que la API sea más rígida.
- La emulación de cotas inferiores permite más flexibilidad, pero tiene restricciones y mensajes de error difíciles de interpretar.
⚖️ ¿Cómo elegir la mejor opción?
Caso | Recomendación |
---|---|
Necesitas restringir un tipo genérico a una clase base o interfaz específica | Usa cotas superiores (T : BaseType ) |
Un tipo debe cumplir con múltiples restricciones | Usa la cláusula where |
Necesitas aceptar supertipos en una API genérica | Usa contravarianza (in ) para emular cotas inferiores |
Quieres diseñar una API flexible sin perder seguridad de tipos | Evalúa si la restricción aporta más claridad o si genera más complejidad |
🚀 Reflexión final
El uso de cotas en Kotlin es fundamental para diseñar APIs sólidas y mantenibles en bibliotecas de software. Las cotas superiores proporcionan una manera clara de definir restricciones, asegurando que el código se mantenga seguro y reutilizable. Mientras que las cotas inferiores no son nativas en Kotlin, la contravarianza permite lograr efectos similares en ciertos casos. Sin embargo, su uso debe evaluarse cuidadosamente, ya que puede introducir complejidad innecesaria.
Al diseñar una API, la clave está en encontrar un balance entre restricciones claras y flexibilidad, asegurando que la biblioteca sea fácil de usar sin comprometer la seguridad de tipos. 🚀
Bibliografías Recomendadas
- 📚 "Generics". (2017). Dmitry Jemerov & Svetlana Isakova, en Kotlin in action, (pp. 223–253.) Manning Publications Co..
Bibliografías Adicionales
- 📚 "Generics". (2018). Joshua Bloch, en Effective Java, (pp. 117–155.) Addison-Wesley.