Skip to main content

Programación genérica

⏱ Dedicación recomendada: 0 minutos
Esto considera el contenido visible y relevante, e ignora texto colapsado o marcado como opcional.


r8vnhill/generic-programming-kt
Si quieres seguir el código del tutorial puedes comenzar desde este punto

Si tienes gh instalado, puedes obtener el código haciendo:

gh repo clone r8vnhill/generic-programming-kt
cd generic-programming-kt || exit
git checkout base

Si quieres tener tu propia copia del código, puedes hacer un fork del repositorio y clonarlo desde tu cuenta de GitHub.

gh repo fork r8vnhill/generic-programming-kt
cd generic-programming-kt || exit
git checkout --track origin/base
Cambia la propiedad group en gradle.properties
Recuerda cambiar la propiedad generic-programming.group en el archivo gradle.properties por tu nombre de dominio.

Be lazy...

Puedes ejecutar el siguiente comando para crear el módulo

./gradlew setupIntroModule

Mientras se crean los archivos necesarios, puedes leer el código para saber qué está pasando.

Primero definiremos una tarea reutilizable para esta unidad, no te dejes intimidar por la cantidad de código, es solo una plantilla para crear módulos en el proyecto que ya viene incluido en el repositorio. Sin embargo, comprender cómo funciona puede ser útil como práctica y repaso sobre tareas de Gradle.

abstract class ModuleSetupTask @Inject constructor(
private val layout: ProjectLayout
) : DefaultTask() {
init {
group = "setup"
}
}
¿Qué acabamos de hacer?

Esta clase define una tarea personalizada que hereda de DefaultTask, como es habitual en Gradle.

Justo después del nombre de la clase, aparece una sección que empieza con @Inject constructor(...). Esto indica que la tarea necesita ciertos datos para funcionar, y que Gradle se encargará de proporcionarlos automáticamente. A esto se le llama inyección de dependencias.

En este caso, lo que Gradle entrega es una instancia de ProjectLayout, un objeto que ofrece una forma segura de acceder a carpetas y archivos dentro del proyecto.

Usar ProjectLayout en lugar de acceder directamente con project.file(...) es importante porque mejora la compatibilidad con el configuration cache, una característica de Gradle que permite acelerar las compilaciones.

Finalmente, la línea group = "setup" simplemente indica que esta tarea forma parte del grupo de tareas de configuración, lo cual ayuda a organizarla mejor cuando se listan las tareas con gradle tasks.

Luego, podemos utilizar esta tarea para crear el módulo de la lección:

import tasks.ModuleSetupTask

tasks.register<ModuleSetupTask>("setupIntroModule") {
description = "Creates the base module and files for the intro lesson"
module.set("intro")

doLast {
createFiles(
"id",
main to "Identity.kt",
test to "IdentityTest.kt"
)
createFiles(
"box",
main to "Box.kt",
test to "BoxTest.kt"
)
createFiles(
"repo",
main to "Repository.kt",
main to "User.kt",
main to "UserRepository.kt",
test to "UserRepositoryTest.kt"
)
}
}

Preocúpate de que el plugin intro esté aplicado en el archivo build.gradle.kts de tu proyecto.

./gradlew setupIntroModule

Preocúpate de que el nuevo módulo esté incluido en el archivo settings.gradle.kts.

La programación genérica es un paradigma clave en el diseño de software moderno que permite escribir código más flexible, reutilizable y robusto. Este enfoque permite trabajar con tipos abstractos, sin la necesidad de especificar los tipos concretos de antemano, facilitando el manejo de diferentes tipos de datos con un solo conjunto de funciones o estructuras.

En esencia, la programación genérica es la aplicación práctica del polimorfismo paramétrico en la definición de funciones, clases e interfaces que operan sobre tipos genéricos, brindando una solución más abstracta que se adapta a cualquier tipo de dato.

El polimorfismo paramétrico es un concepto fundamental en la programación de tipos que permite que las funciones y los tipos sean definidos sin especificar todos los tipos concretos. En lugar de operar sobre tipos concretos, el polimorfismo paramétrico permite que las funciones y los tipos trabajen de manera abstracta con uno o más tipos, lo que proporciona mayor flexibilidad y reutilización de código.

Polimorfismo Paramétrico


Capacidad de definir una función o una estructura de datos de forma que funcione para cualquier tipo, sin estar limitada a un tipo específico. Se dice que una función es paramétricamente polimórfica cuando puede operar sobre cualquier tipo de entrada sin hacer suposiciones sobre las propiedades de ese tipo.

Ejemplo de Polimorfismo Paramétrico

Consideremos la función de identidad, que devuelve su argumento sin cambiarlo:

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

Esta función es polimórfica porque no importa el tipo de x: puede ser un número, una cadena, un objeto, etc. En lenguajes con soporte de polimorfismo paramétrico, podemos definir la función id para que funcione con cualquier tipo:

id :: a -> a
id x = x

En este ejemplo, a es un parámetro de tipo. En lugar de fijar el tipo de x, a puede ser cualquier tipo, y id funcionará de manera genérica.

Un poco de historia: Polimorfismo paramétrico en ML

El polimorfismo paramétrico fue introducido y popularizado por el lenguaje ML (MetaLanguage) en la década de 1970, convirtiéndose en una piedra angular para muchos lenguajes modernos.

Orígenes en ML

ML fue desarrollado por Robin Milner y sus colegas en los Laboratorios de Investigación de la Universidad de Cambridge como un lenguaje de programación para la inteligencia artificial y el razonamiento formal. Una de las contribuciones más significativas de ML fue la introducción de un sistema de tipos robusto que soporta polimorfismo paramétrico, permitiendo una mayor abstracción y reutilización de código.

Características del Polimorfismo Paramétrico en ML

  • Generics: Permite definir funciones y estructuras de datos genéricas que pueden trabajar con cualquier tipo. Por ejemplo, la función de identidad en ML se define de manera que puede aceptar y devolver cualquier tipo.

    (* Función de identidad en ML *)
    let identity x = x

    Aquí, identity tiene el tipo 'a -> 'a, donde 'a es un tipo genérico que puede ser cualquier tipo concreto.

  • Reutilización de Código: Gracias al polimorfismo paramétrico, es posible escribir código más abstracto y reutilizable, evitando la duplicación de funciones para diferentes tipos.

  • Seguridad de Tipos: El sistema de tipos de ML verifica que las operaciones realizadas sobre los tipos sean seguras, previniendo errores comunes como los de tipos incompatibles en tiempo de compilación.

Ejemplo de Uso

Un ejemplo clásico de polimorfismo paramétrico es la implementación de listas en ML:

list.ml
(* Definición de una lista genérica en ML *)
type 'a list =
| Nil
| Cons of 'a * 'a list

Esta definición permite crear listas de cualquier tipo, como int list, string list, etc., manteniendo la consistencia y seguridad del sistema de tipos.

Influencia y Evolución

El polimorfismo paramétrico de ML influyó en el diseño de muchos otros lenguajes de programación, incluyendo Haskell, OCaml, Scala, y Rust. Esta característica ha sido fundamental para el desarrollo de la programación genérica y ha mejorado la capacidad de los lenguajes para abstraer sobre tipos, aumentando la expresividad y la seguridad del código.

Además, el polimorfismo paramétrico es una piedra angular en el diseño de tipos algebraicos y sistemas de tipos avanzados, que permiten la creación de abstracciones poderosas y seguras en la programación moderna.

Polimorfismo Paramétrico en Kotlin

Kotlin soporta el polimorfismo paramétrico a través de tipos genéricos. Puedes definir funciones, clases e interfaces que operen sobre tipos genéricos, lo que les permite ser reutilizados con cualquier tipo específico.

Diferencia con Java: Tipos Crudos

A diferencia de Java, Kotlin siempre requiere que los argumentos de tipo sean especificados explícitamente o inferidos por el compilador. Esto se debe a que los genéricos fueron introducidos en Java a partir de la versión 1.5, lo que obligó al lenguaje a mantener la compatibilidad con el código anterior. Por esta razón, Java permite el uso de un tipo genérico sin especificar los argumentos de tipo, conocido como tipo crudo (raw type).

Por ejemplo, en Java puedes declarar una variable de tipo List sin especificar qué tipo de elementos contiene:

List myList = new ArrayList();  // Uso de tipo crudo en Java

Este tipo de declaración es posible en Java por razones de compatibilidad histórica, pero no está presente en Kotlin. Dado que Kotlin ha tenido soporte para genéricos desde su inicio, no permite el uso de tipos crudos, promoviendo así una mayor seguridad de tipos.

En Kotlin, siempre debes especificar los argumentos de tipo o permitir que el compilador los infiera:

val myList: List<Int> = listOf(1, 2, 3)  // En Kotlin los tipos deben ser especificados

Esto garantiza que el tipo de los elementos en la lista esté claro y seguro en tiempo de compilación.

Funciones Genéricas

La función identity es una implementación clásica y sencilla que devuelve exactamente el valor que recibe como argumento, sin realizar ninguna modificación. En Kotlin, podemos implementarla fácilmente utilizando un parámetro de tipo genérico, lo que permite que funcione con cualquier tipo de dato.

Especificación de la función identity

Primero, definimos una especificación BDD para verificar el comportamiento esperado de la función identity con diferentes tipos de datos:

"Given an identity function" - {
"when calling it with a string" - {
"should return the same string" {}
}

"when calling it with an integer" - {
"should return the same integer" {}
}

"when calling it with a boolean" - {
"should return the same boolean" {}
}
}

Implementación de los casos de prueba

Ahora, implementamos los detalles de los casos de prueba, utilizando Kotest para generar valores de prueba aleatorios:

checkAll(Arb.string()) { s ->
identity(s) shouldBe s
}
checkAll(Arb.int()) { i ->
identity(i) shouldBe i
}

Implementación de la función identity

Finalmente, la función identity en Kotlin es simple y genérica. La definimos utilizando un parámetro de tipo T:

type-fundamentals/src/main/kotlin/com/github/username/id/Identity.kt
package com.github.username.generics

fun <T> identity(value: T): T = value
¿Qué acabamos de hacer?
  • <T> es un parámetro de tipo genérico que indica que la función identity puede aceptar y devolver cualquier tipo de dato.
  • La función identity simplemente devuelve el valor que recibe como argumento, sin realizar modificaciones.
  • El compilador determinará el tipo T en tiempo de compilación en función del tipo de dato que se pase como argumento a la función.

Clases Genéricas

El polimorfismo paramétrico también puede ser aplicado en la definición de clases genéricas. Por ejemplo, podemos definir una clase Box que almacene un valor de cualquier tipo T.

Especificación de la clase Box

Primero, definimos una especificación BDD para verificar el comportamiento esperado de la clase Box con diferentes tipos de datos:

"Given a Box" - {
"when creating it with an integer" - {
"should store the integer value" {}
}

"when creating it with a string" - {
"should store the string value" {}
}

"when creating it with a boolean" - {
"should store the boolean value" {}
}
}

Implementación de los casos de prueba

Luego, implementamos los detalles de los casos de prueba, utilizando Kotest para generar valores de prueba aleatorios:

checkAll(Arb.int()) { i ->
Box(i).value shouldBe i
}
checkAll(Arb.string()) { s ->
Box(s).value shouldBe s
}

Implementación de la clase Box

Finalmente, la clase Box en Kotlin es genérica y flexible. La definimos utilizando un parámetro de tipo T:

type-fundamentals/src/main/kotlin/com/github/username/box/Box.kt
package com.github.username.box

class Box<T>(val value: T)
¿Qué acabamos de hacer?
  • <T> es un parámetro de tipo genérico que indica que la clase Box puede almacenar cualquier tipo de dato.
  • La clase Box tiene un atributo value que almacena el valor de tipo T proporcionado al crear una instancia de Box.
  • El compilador determinará el tipo T en tiempo de compilación en función del tipo de dato que se pase al constructor de Box.

Interfaces Genéricas

Kotlin permite el uso de interfaces genéricas, lo que proporciona flexibilidad para definir contratos que pueden adaptarse a múltiples tipos de datos. Por ejemplo, podemos crear una interfaz genérica Repository que describa operaciones comunes para cualquier entidad:

type-fundamentals/src/main/kotlin/com/github/username/repo/Repository.kt
package com.github.username.repo

interface Repository<T, K> {
fun save(item: T)
fun findByKey(key: K): T?
}
¿Qué acabamos de hacer?
  • <T> y <K> son parámetros de tipo genérico, lo que significa que la interfaz Repository puede trabajar con cualquier tipo de entidad (T) y cualquier tipo de clave (K).
  • El método save almacena una entidad del tipo T, mientras que findByKey busca una entidad en base a una clave del tipo K.
  • Al utilizar tipos genéricos, esta interfaz puede ser reutilizada para implementar repositorios de cualquier tipo de entidad, lo que promueve la reutilización del código.

Especificación BDD

Con esta interfaz en mente, podemos escribir una especificación de comportamiento para un repositorio de usuarios utilizando un enfoque de BDD:

"A user repository" - {
"when attempting to find a user by username" - {
"should return the user if it was saved" {}

"should return null if the user was not saved" {}
}
}

Este enfoque nos permite verificar de manera clara y precisa el comportamiento esperado de un repositorio de usuarios, garantizando que devuelve un usuario existente o null si no fue encontrado.

Implementación de los casos de prueba

A continuación, se presentan los detalles de los casos de prueba para verificar el comportamiento del repositorio de usuarios.

checkAll(arbUser()) { user ->
val repository = UserRepository()
repository.findByUsername(user.username).shouldBeNull()
repository.save(user)
repository.findByUsername(user.username)
.shouldNotBeNull()
.shouldBe(user)
}
checkAll(arbUser()) { user ->
val repository = UserRepository()
repository.findByUsername(user.username).shouldBeNull()
}
private fun arbUser(): Arb<User> = Arb.name()
.flatMap { name ->
Arb.usernames().map { username ->
User("$username", "$name")
}
}
¿Qué acabamos de hacer?
  • Pruebas de propiedad con Kotest:
    • En el caso de la prueba "should return the user if it was saved", se genera un usuario utilizando el generador arbUser(). Se guarda el usuario en el repositorio y luego se verifica que pueda ser encontrado usando su id. La primera llamada a findById asegura que el usuario no esté guardado inicialmente (shouldBeNull), mientras que la segunda llamada después de guardarlo asegura que el usuario sea encontrado (shouldNotBeNull y shouldBe(user)).
    • La prueba "should return null if the user was not saved", por otro lado, verifica que un usuario no guardado no pueda ser encontrado en el repositorio. Se genera un usuario y se verifica que no esté guardado inicialmente (shouldBeNull).
  • Generador personalizado arbUser(): El generador arbUser() utiliza el generador de nombres (Arb.name()) y nombres de usuario (Arb.usernames()) para crear instancias de User. La función flatMap combina ambos generadores, generando un nombre y un nombre de usuario para construir un objeto User. Esto permite probar el repositorio con una variedad de datos generados de manera automática.

Este enfoque garantiza que el repositorio funcione correctamente para distintos tipos de usuarios y escenarios de almacenamiento.

Implementación de la interfaz Repository

Luego, podemos implementar esta interfaz para el caso específico de un repositorio de usuarios:

type-fundamentals/src/main/kotlin/com/github/username/repo/UserRepository.kt
package com.github.username.repo

class User(val username: String, val name: String)
type-fundamentals/src/main/kotlin/com/github/username/repo/UserRepository.kt
package com.github.username.repo

class UserRepository : Repository<User, String> {
private val users = mutableMapOf<String, User>()

override fun save(item: User) {
users[item.username] = item
}

override fun findByKey(key: String) = users[key]
}

Beneficios y limitaciones

Beneficios

  • Reutilización de código: Permite escribir funciones, clases e interfaces que se pueden usar con diferentes tipos de datos sin duplicar código, mejorando la mantenibilidad.
  • Flexibilidad: Al permitir trabajar con cualquier tipo de dato, el código se adapta a múltiples escenarios, proporcionando una mayor versatilidad.
  • Seguridad de tipos: Los parámetros de tipo aseguran que las funciones y clases trabajen de manera segura con cualquier tipo especificado, lo que previene errores en tiempo de compilación.
  • Abstracción: Facilita la creación de soluciones abstractas y generales que funcionan en múltiples contextos, lo que reduce la necesidad de implementar soluciones específicas para cada caso.
  • Compatibilidad con otros paradigmas: La programación genérica se integra bien con otros paradigmas como la programación funcional, permitiendo un código más expresivo.

Limitaciones

  • Complejidad adicional: El uso de tipos genéricos puede incrementar la complejidad del código, lo que puede dificultar su comprensión, especialmente para personas no familiarizadas con el concepto.
  • Errores de inferencia: En algunos casos, la inferencia de tipos puede no ser suficiente o comportarse de manera inesperada, lo que lleva a la necesidad de especificar explícitamente los tipos.
  • Limitaciones del sistema de tipos: No todos los lenguajes soportan características avanzadas de tipos genéricos como los límites superiores e inferiores o la varianza, lo que puede reducir su poder expresivo en algunos casos.
  • Sobrecarga de compilación: En lenguajes con un sistema de tipos estricto, el uso intensivo de genéricos puede aumentar el tiempo de compilación, especialmente en proyectos grandes.
  • Curva de aprendizaje: Para desarrolladorxs nuevos en programación genérica, puede ser más difícil de entender y aplicar correctamente, lo que podría llevar a errores conceptuales o mal uso de los genéricos.

¿Qué aprendimos?

En esta lección, exploramos la programación genérica y el polimorfismo paramétrico, conceptos clave para escribir código más flexible y reutilizable. Vimos cómo aplicar genéricos en funciones, clases e interfaces en Kotlin, y cómo estos permiten trabajar con diferentes tipos de datos sin duplicar código ni comprometer la seguridad de tipos.

Puntos clave

  1. Polimorfismo paramétrico: Es la capacidad de definir funciones y estructuras de datos de manera abstracta para que operen con cualquier tipo sin especificar un tipo concreto. Esto incrementa la flexibilidad del código.
  2. Genéricos en Kotlin: Vimos cómo Kotlin admite genéricos en funciones y clases, lo que nos permite crear soluciones altamente reutilizables, como una función de identidad o un repositorio genérico.
  3. Clases e interfaces genéricas: Aprendimos a crear clases e interfaces que pueden operar con cualquier tipo de dato, facilitando la abstracción y reutilización del código, como el caso de un Repository genérico.
  4. Beneficios y limitaciones: Si bien los genéricos proporcionan grandes ventajas como la reutilización de código y la seguridad de tipos, también pueden agregar complejidad y sobrecarga de compilación, por lo que es importante usarlos con cuidado.

En resumen, la programación genérica es una herramienta poderosa que nos permite escribir código más abstracto y reutilizable, mejorando la mantenibilidad y adaptabilidad de las soluciones. Sin embargo, también es importante entender las posibles complicaciones que conlleva para aprovechar todo su potencial.

Bibliografías Recomendadas

  • 📚 "Generics". (2017). Dmitry Jemerov & Svetlana Isakova, en Kotlin in Action, (pp. 223–253.) Manning Publications Co.

Bibliografías Adicionales