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 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
group
en gradle.properties
generic-programming.group
en el archivo gradle.properties
por tu nombre de dominio.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.
- Parte 1
- Parte 2
- Parte 3
- Parte 4
- Parte 5
- Parte 6
- Parte 7
- Código completo
abstract class ModuleSetupTask @Inject constructor(
private val layout: ProjectLayout
) : DefaultTask() {
init {
group = "setup"
}
}
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
.
private val capturedProjectName: String = project.name
private val capturedGroup: String = project.rootProject.group.toString()
@get:Input
abstract val module: Property<String>
@get:Internal
val main: File
get() = baseDir.resolve("src/main/kotlin")
@get:Internal
val test: File
get() = baseDir.resolve("src/test/kotlin")
private val baseDir: File
get() = layout.projectDirectory.file(module.get().replace(":", "/")).asFile
Esta sección define las propiedades que la tarea necesita para generar la estructura de un submódulo:
capturedProjectName
ycapturedGroup
almacenan el nombre del proyecto actual y del grupo raíz, respectivamente. Estos valores se capturan en el momento en que se crea la instancia de la tarea, lo cual es importante porque acceder directamente aproject.*
en tiempo de ejecución es incompatible con el configuration cache de Gradle. Al capturarlos de forma anticipada, evitamos errores futuros y mejoramos la compatibilidad.module
es una propiedad de entrada (@get:Input
). Esto significa que su valor puede cambiar entre ejecuciones, y Gradle debe tenerlo en cuenta al decidir si necesita volver a ejecutar la tarea.main
ytest
son rutas internas calculadas que apuntan a los directoriossrc/main/kotlin
ysrc/test/kotlin
dentro del módulo. Se marcan con@get:Internal
porque su valor depende de otras propiedades (module
) y no necesitan ser consideradas directamente por Gradle como entradas externas al momento de evaluar si la tarea puede ser cacheada.baseDir
calcula el directorio raíz del módulo a partir demodule
y del layout del proyecto. Se accede usandolayout.projectDirectory
, una forma segura y declarativa de trabajar con rutas en Gradle moderna, evitando los métodos imperativos comoproject.file(...)
.
En conjunto, esta configuración busca asegurar compatibilidad con el configuration cache y mantener un diseño declarativo y limpio de la tarea.
private fun createSettingsFile() {
val settingsFile: File = layout.projectDirectory.file("settings.gradle.kts").asFile
if (!settingsFile.exists()) {
settingsFile.writeText(
"""
rootProject.name = "$capturedProjectName"
include("${module.get()}")
""".trimIndent()
)
} else if (module.get() !in settingsFile.readText()) {
settingsFile.appendText("\ninclude(\"${module.get()}\")")
}
println("The module ${module.get()} was added to the settings file")
}
Esta función agrega el nuevo módulo al archivo settings.gradle.kts
, que Gradle usa para registrar todos los subproyectos del build.
Primero se obtiene una referencia segura al archivo de configuración usando layout.projectDirectory.file(...)
, que es la forma recomendada en tareas compatibles con el configuration cache.
Luego, se evalúan dos casos:
- Si el archivo no existe, se crea con el nombre del proyecto (
rootProject.name
) y elinclude(...)
correspondiente al nuevo módulo. - Si el archivo ya existe pero aún no incluye el nuevo módulo, se agrega la línea
include(...)
al final.
El valor module.get()
se refiere al nombre del módulo proporcionado como entrada a la tarea. Al final, se imprime un mensaje indicando que el módulo fue agregado al archivo.
Esta lógica garantiza que el módulo será reconocido por Gradle la próxima vez que se ejecute el build.
private fun createModuleDirectory() = baseDir.run {
when {
exists() -> printError("Directory already exists: $absolutePath")
mkdirs() -> println("Directory created: $absolutePath")
else -> printError("Failed to create directory: $absolutePath")
}
}
Creamos una función privada createModuleDirectory
que se encarga de crear el directorio del módulo. Si el directorio ya existe, se imprime un mensaje de error. Si el directorio se crea correctamente, se imprime un mensaje de éxito. Si falla la creación del directorio, se imprime un mensaje de error.
private fun createBuildFile() = baseDir.resolve("build.gradle.kts").run {
if (exists()) printError("The build file already exists")
else {
writeText("// Intentionally left blank\n")
println("The build file was created successfully")
}
}
Creamos una función privada createBuildFile
que se encarga de crear el archivo de configuración build.gradle.kts
en el directorio del módulo. Si el archivo ya existe, se imprime un mensaje de error. Si el archivo se crea correctamente, se imprime un mensaje de éxito.
fun createFiles(packageName: String, vararg files: Pair<File, String>) {
files.forEach { (dir, name) ->
val packageDir = dir.resolve("$capturedGroup/$packageName".replace(".", "/"))
val file = packageDir.resolve(name)
if (file.exists()) {
printError("The file $name already exists")
} else {
packageDir.mkdirs()
file.writeText("package $capturedGroup.$packageName\n\n")
println("The file $name was created successfully")
}
}
}
Esta función crea uno o más archivos de Kotlin dentro de un paquete específico, organizándolos según la convención de directorios basada en el nombre del grupo (capturedGroup
) y el nombre del paquete recibido como argumento.
Para cada par (dir, name)
:
- Se construye el path final resolviendo el directorio base
dir
con la estructura del paquete (capturedGroup.packageName
), reemplazando los puntos por barras para reflejar la estructura de carpetas. - Si el archivo ya existe, se imprime un mensaje de error.
- Si no existe, se crean los directorios necesarios (
mkdirs()
) y se escribe un archivo con una declaraciónpackage
adecuada en la primera línea.
Esto es útil para generar archivos fuente organizados automáticamente dentro de la estructura estándar de Kotlin.
@TaskAction
fun setupSubmodule() {
createSettingsFile()
createModuleDirectory()
createBuildFile()
}
Creamos una función setupSubmodule
que se encarga de configurar el módulo. Esta función llama a las funciones createSettingsFile
, createModuleDirectory
y createBuildFile
para agregar el módulo al archivo de configuración, crear el directorio del módulo y crear el archivo de configuración del módulo, respectivamente.
package tasks
import org.gradle.api.DefaultTask
import org.gradle.api.file.ProjectLayout
import org.gradle.api.provider.Property
import org.gradle.api.tasks.Input
import org.gradle.api.tasks.Internal
import org.gradle.api.tasks.TaskAction
import java.io.File
import javax.inject.Inject
abstract class ModuleSetupTask @Inject constructor(
private val layout: ProjectLayout
) : DefaultTask() {
// Aquí guardamos el nombre del proyecto de forma inmutable
// en tiempo de configuración, para NO llamarlo en tiempo de ejecución.
private val capturedProjectName: String = project.name
private val capturedGroup: String = project.rootProject.group.toString()
@get:Input
abstract val module: Property<String>
@get:Internal
val main: File
get() = baseDir.resolve("src/main/kotlin")
@get:Internal
val test: File
get() = baseDir.resolve("src/test/kotlin")
private val baseDir: File
get() = layout.projectDirectory.file(module.get().replace(":", "/")).asFile
init {
group = "setup"
}
@TaskAction
fun setupSubmodule() {
createSettingsFile()
createModuleDirectory()
createBuildFile()
}
private fun createSettingsFile() {
val settingsFile: File = layout.projectDirectory.file("settings.gradle.kts").asFile
if (!settingsFile.exists()) {
settingsFile.writeText(
"""
rootProject.name = "$capturedProjectName"
include("${module.get()}")
""".trimIndent()
)
} else if (module.get() !in settingsFile.readText()) {
settingsFile.appendText("\ninclude(\"${module.get()}\")")
}
println("The module ${module.get()} was added to the settings file")
}
private fun createModuleDirectory() = baseDir.run {
when {
exists() -> printError("Directory already exists: $absolutePath")
mkdirs() -> println("Directory created: $absolutePath")
else -> printError("Failed to create directory: $absolutePath")
}
}
private fun createBuildFile() = baseDir.resolve("build.gradle.kts").run {
if (exists()) printError("The build file already exists")
else {
writeText("// Intentionally left blank\n")
println("The build file was created successfully")
}
}
fun createFiles(packageName: String, vararg files: Pair<File, String>) {
files.forEach { (dir, name) ->
val packageDir = dir.resolve("$capturedGroup/$packageName".replace(".", "/"))
val file = packageDir.resolve(name)
if (file.exists()) {
printError("The file $name already exists")
} else {
packageDir.mkdirs()
file.writeText("package $capturedGroup.$packageName\n\n")
println("The file $name was created successfully")
}
}
}
private fun printError(message: String) {
logger.error(message)
}
}
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:
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 = xAquí,
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:
(* 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:
- Código esencial
- Código completo
checkAll(Arb.string()) { s ->
identity(s) shouldBe s
}
checkAll(Arb.int()) { i ->
identity(i) shouldBe i
}
package com.username.github.id
import io.kotest.core.spec.style.FreeSpec
import io.kotest.matchers.booleans.shouldBeFalse
import io.kotest.matchers.booleans.shouldBeTrue
import io.kotest.matchers.shouldBe
import io.kotest.property.Arb
import io.kotest.property.arbitrary.int
import io.kotest.property.arbitrary.string
import io.kotest.property.checkAll
class IdentityTest : FreeSpec({
"Given an identity function" - {
"when calling it with a string" - {
"should return the same string" {
checkAll(Arb.string()) { s ->
identity(s) shouldBe s
}
}
}
"when calling it with an integer" - {
"should return the same integer" {
checkAll(Arb.int()) { i ->
identity(i) shouldBe i
}
}
}
"when calling it with a boolean" - {
"should return the same boolean" {
identity(true).shouldBeTrue()
identity(false).shouldBeFalse()
}
}
}
})
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
:
package com.github.username.generics
fun <T> identity(value: T): T = value
<T>
es un parámetro de tipo genérico que indica que la funciónidentity
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:
- Código esencial
- Código completo
checkAll(Arb.int()) { i ->
Box(i).value shouldBe i
}
checkAll(Arb.string()) { s ->
Box(s).value shouldBe s
}
package com.username.github.box
import io.kotest.core.spec.style.FreeSpec
import io.kotest.matchers.shouldBe
import io.kotest.property.Arb
import io.kotest.property.arbitrary.int
import io.kotest.property.arbitrary.string
import io.kotest.property.checkAll
class BoxTest : FreeSpec({
"Given a Box" - {
"when creating it with an integer" - {
"should store the integer value" {
checkAll(Arb.int()) { i ->
Box(i).value shouldBe i
}
}
}
"when creating it with a string" - {
"should store the string value" {
checkAll(Arb.string()) { s ->
Box(s).value shouldBe s
}
}
}
"when creating it with a boolean" - {
"should store the boolean value" {
Box(true).value shouldBe true
Box(false).value shouldBe false
}
}
}
})
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
:
package com.github.username.box
class Box<T>(val value: T)
<T>
es un parámetro de tipo genérico que indica que la claseBox
puede almacenar cualquier tipo de dato.- La clase
Box
tiene un atributovalue
que almacena el valor de tipoT
proporcionado al crear una instancia deBox
. - El compilador determinará el tipo
T
en tiempo de compilación en función del tipo de dato que se pase al constructor deBox
.
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:
package com.github.username.repo
interface Repository<T, K> {
fun save(item: T)
fun findByKey(key: K): T?
}
<T>
y<K>
son parámetros de tipo genérico, lo que significa que la interfazRepository
puede trabajar con cualquier tipo de entidad (T
) y cualquier tipo de clave (K
).- El método
save
almacena una entidad del tipoT
, mientras quefindByKey
busca una entidad en base a una clave del tipoK
. - 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.
- Código esencial
- Código completo
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")
}
}
package com.github.username.repo
import io.kotest.core.spec.style.FreeSpec
import io.kotest.matchers.nulls.shouldBeNull
import io.kotest.matchers.nulls.shouldNotBeNull
import io.kotest.matchers.shouldBe
import io.kotest.property.Arb
import io.kotest.property.arbitrary.flatMap
import io.kotest.property.arbitrary.map
import io.kotest.property.arbs.name
import io.kotest.property.arbs.usernames
import io.kotest.property.checkAll
class UserRepositoryTest : FreeSpec({
"A user repository" - {
"when attempting to find a user by username" - {
"should return the user if it was saved" {
checkAll(arbUser()) { user ->
val repository = UserRepository()
repository.findByUsername(user.username).shouldBeNull()
repository.save(user)
repository.findByUsername(user.username)
.shouldNotBeNull()
.shouldBe(user)
}
}
"should return null if the user was not saved" {
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")
}
}
- 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 generadorarbUser()
. Se guarda el usuario en el repositorio y luego se verifica que pueda ser encontrado usando suid
. La primera llamada afindById
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
yshouldBe(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
).
- En el caso de la prueba
- Generador personalizado
arbUser()
: El generadorarbUser()
utiliza el generador de nombres (Arb.name()
) y nombres de usuario (Arb.usernames()
) para crear instancias deUser
. La funciónflatMap
combina ambos generadores, generando un nombre y un nombre de usuario para construir un objetoUser
. 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:
package com.github.username.repo
class User(val username: String, val name: String)
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
- 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.
- 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.
- 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. - 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
- 🌐 "Generics: In, out, where." Accedido: 26 de octubre de 2024. [En línea]. Disponible en: https://kotlinlang.org/docs/generics.html