Skip to main content

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

Be lazy...

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.

convention-plugins/src/main/kotlin/bounds.gradle.kts
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:

with(repository) {
entities.shouldBeEmpty()
save(entity)
entities shouldHaveSize 1
entities.last() shouldBe entity
serializeAll() shouldBe "[MockEntity(id=1)]"
}
¿Qué acabamos de hacer?

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.

class Repository<T : Entity>
¿Qué acabamos de hacer?
  • Entity es la cota superior.
  • T : Entity significa que T debe ser Entity o una clase que herede de Entity.

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.

class Repository<T> where T : Entity,
T : Serializable
¿Qué acabamos de hacer?
  • En este caso, T debe ser un subtipo de Entity y también implementar la interfaz Serializable. 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.
¿Cuál elegir?

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étodoVentajasCuándo Usarlo
Declaración directa (T : UpperBound)Simple y fácil de entenderCuando solo hay una cota superior
Cláusula whereMás legible cuando hay múltiples restriccionesCuando 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.

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.

bounds/src/main/kotlin/com/github/username/notifications/EmailNotificationSystem.kt
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
}
}
¿Qué acabamos de hacer?
  • EmailNotificationHandler es un alias para NotificationHandler<in EmailNotification>, que representa un handler que acepta notificaciones de correo electrónico o cualquier supertipo de EmailNotification.
  • registerHandler acepta un EmailNotificationHandler, que puede ser un NotificationHandler<EmailNotification> o un supertipo de EmailNotification.

💡 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

  1. Cotas superiores
    • Permiten restringir el tipo genérico a un subtipo específico.
    • Se pueden declarar directamente (T : UpperBound) o mediante where cuando hay múltiples restricciones.
    • Aseguran que las operaciones se realicen sobre tipos compatibles, evitando errores en tiempo de ejecución.
  2. 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.
  3. 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.
  4. 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?

CasoRecomendación
Necesitas restringir un tipo genérico a una clase base o interfaz específicaUsa cotas superiores (T : BaseType)
Un tipo debe cumplir con múltiples restriccionesUsa la cláusula where
Necesitas aceptar supertipos en una API genéricaUsa contravarianza (in) para emular cotas inferiores
Quieres diseñar una API flexible sin perder seguridad de tiposEvalú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.