Kotlin

Kotlin

Tutorial Kotlin: Tipos genéricos y varianza

Descubre cómo los tipos genéricos y la varianza en Kotlin optimizan tu código. Aprende a aplicar conceptos de covarianza y contravarianza en tus proyectos.

Aprende Kotlin GRATIS y certifícate

Definición y uso de tipos genéricos

En Kotlin, los tipos genéricos permiten definir clases y funciones que pueden operar con cualquier tipo de dato, proporcionando mayor flexibilidad y reutilización del código. Los genéricos ayudan a crear estructuras de datos y algoritmos que funcionan independientemente del tipo específico de los elementos con los que trabajan.

Para declarar una clase genérica, se utilizan parámetros de tipo entre ángulos < y > después del nombre de la clase. Por ejemplo:

class Contenedor<T>(private val valor: T) {
    fun obtenerValor(): T {
        return valor
    }
}

En este ejemplo, Contenedor es una clase genérica con un parámetro de tipo T. Esto permite crear instancias de Contenedor con distintos tipos:

val contenedorEntero = Contenedor(10)
val contenedorCadena = Contenedor("Kotlin")

La variable contenedorEntero es de tipo Contenedor<Int>, mientras que contenedorCadena es de tipo Contenedor<String>. Gracias a los genéricos, el tipo de dato se mantiene seguro en tiempo de compilación, evitando errores de conversión en tiempo de ejecución.

Las funciones genéricas también son posibles en Kotlin. Se declaran añadiendo parámetros de tipo antes del nombre de la función:

fun <T> imprimirElemento(elemento: T) {
    println(elemento)
}

Esta función imprimirElemento puede recibir un argumento de cualquier tipo, lo que la hace muy versátil:

imprimirElemento(5)
imprimirElemento("Hola")
imprimirElemento(3.14)

Los métodos genéricos dentro de clases no genéricas también son útiles. Por ejemplo:

class Calculadora {
    fun <T : Number> sumar(a: T, b: T): Double {
        return a.toDouble() + b.toDouble()
    }
}

Aquí, el método sumar acepta parámetros de tipo T que deben ser subclases de Number, garantizando que se pueden realizar operaciones numéricas.

Los genéricos en Kotlin son especialmente útiles al trabajar con colecciones. Las interfaces como List<T>, Set<T> y Map<K, V> utilizan genéricos para asegurar la consistencia de los tipos de sus elementos:

val listaStrings: List<String> = listOf("Kotlin", "Java", "Swift")
val mapaEdades: Map<String, Int> = mapOf("Ana" to 28, "Luis" to 34)

Gracias a los genéricos, el compilador puede verificar que solo se añadan elementos del tipo correcto a las colecciones, mejorando la seguridad de tipos.

Es importante tener en cuenta que los genéricos en Kotlin son inertes, es decir, la información de los tipos genéricos no está disponible en tiempo de ejecución debido a la eliminación de tipos de Java. Sin embargo, Kotlin proporciona soluciones como reified en funciones inline para acceder al tipo genérico en tiempo de ejecución, aunque esto se trata en temas más avanzados.

El uso adecuado de los tipos genéricos conduce a un código más reutilizable y robusto, facilitando el desarrollo de aplicaciones más mantenibles y evitando errores relacionados con tipos incorrectos.

Varianza: in y out

La varianza en Kotlin es un concepto clave que define cómo se relacionan los tipos genéricos cuando existe una jerarquía de herencia. En otras palabras, indica si un tipo genérico puede sustituirse por otro tipo de manera segura sin provocar errores de tipo.

En Kotlin, las clases y interfaces genéricas son invariantes por defecto. Esto significa que, aunque C sea una subclase de D, List<C> no es una subclase de List<D>, ni viceversa. Para controlar esta relación, Kotlin utiliza los modificadores de varianza out y in.

El modificador out se utiliza para declarar covarianza. Una clase genérica declarada con out en su parámetro de tipo permite que sus subtipos sean sustituidos por su supertypo. Por ejemplo:

interface Fuente<out T> {
    fun obtener(): T
}

class Productor : Fuente<String> {
    override fun obtener(): String {
        return "Producto"
    }
}

fun usarFuente(fuente: Fuente<Any>) {
    println(fuente.obtener())
}

fun main() {
    val fuenteString: Fuente<String> = Productor()
    usarFuente(fuenteString)
}

En este ejemplo, Fuente<out T> es covariante, lo que permite asignar Fuente<String> a una variable de tipo Fuente<Any>. Gracias a la palabra clave out, se garantiza la seguridad en tiempo de compilación al extraer valores del tipo covariante.

Por otro lado, el modificador in se utiliza para declarar contravarianza. Una clase genérica declarada con in en su parámetro de tipo permite que sus supertypos sean sustituidos por sus subtipos. Veamos un ejemplo:

interface Sumidero<in T> {
    fun enviar(item: T)
}

class Consumidor : Sumidero<Any> {
    override fun enviar(item: Any) {
        println("Consumiendo $item")
    }
}

fun usarSumidero(sumidero: Sumidero<String>) {
    sumidero.enviar("Dato")
}

fun main() {
    val sumideroAny: Sumidero<Any> = Consumidor()
    usarSumidero(sumideroAny)
}

En este caso, Sumidero<in T> es contravariante, permitiendo que Sumidero<Any> se pueda utilizar donde se espera un Sumidero<String>. El modificador in asegura que es seguro pasar valores al tipo contravariante.

Es importante tener en cuenta que el modificador out solo permite usar el tipo genérico como tipo de retorno en funciones, mientras que el modificador in solo permite usar el tipo genérico como parámetro. Intentar usar el tipo genérico de manera contraria resultará en errores de compilación. Por ejemplo:

class Caja<out T>(private val contenido: T) {
    fun obtenerContenido(): T {
        return contenido
    }

    // Error: los parámetros no pueden usar T cuando está declarado con 'out'
    // fun actualizarContenido(nuevoContenido: T) {
    //     contenido = nuevoContenido
    // }
}

Aquí, al declarar T con out, no es posible usar T como parámetro de función dentro de la clase. Esto garantiza la seguridad de tipos al impedir operaciones que podrían conducir a inconsistencias.

Además de la varianza en la declaración de tipos, Kotlin también permite especificar varianza en el uso de tipos mediante proyecciones de tipo. Si no se tiene control sobre la declaración de una clase genérica, se puede usar in o out al consumirla.

La varianza es esencial para trabajar con colecciones y otros tipos genéricos de manera segura y flexible. Comprender cómo y cuándo utilizar los modificadores in y out permite escribir código más robusto y evitar errores de tipos que pueden surgir en sistemas de tipos genéricos.

Restricciones en tipos genéricos

En Kotlin, los tipos genéricos ofrecen flexibilidad al permitir que clases y funciones trabajen con cualquier tipo de dato. Sin embargo, en ocasiones es necesario restringir los tipos que pueden utilizarse como argumentos genéricos para garantizar la seguridad y coherencia del código. Estas restricciones se establecen mediante limitaciones de tipos (type constraints), que especifican las condiciones que deben cumplir los tipos genéricos.

Una forma común de restringir un tipo genérico es mediante una cota superior. Esto se logra usando el signo dos puntos : tras el nombre del parámetro de tipo, seguido del tipo superior que actúa como límite. Por ejemplo:

fun <T : Number> duplicarValor(numero: T): Double {
    return numero.toDouble() * 2
}

En esta función, el tipo genérico T está restringido a ser una subclase de Number. Esto garantiza que cualquier objeto pasado a duplicarValor tendrá los métodos y propiedades disponibles en la clase Number, como toDouble(). De esta manera, evitamos errores al asegurar que solo los tipos numéricos pueden utilizarse como argumento.

Las restricciones no se limitan solo a clases; también pueden incluir interfaces. Imaginemos que tenemos una interfaz Comestible:

interface Comestible {
    fun comer()
}

Podemos definir una clase genérica restringida a tipos que implementen Comestible:

class CajaComida<T : Comestible>(private val elemento: T) {
    fun consumir() {
        elemento.comer()
    }
}

Al establecer T : Comestible, garantizamos que cualquier objeto almacenado en CajaComida tendrá el método comer(), lo que permite utilizarlo sin verificar cada vez si el método existe.

Kotlin también permite establecer múltiples restricciones en un tipo genérico utilizando la palabra clave where. Esto es útil cuando necesitamos que el tipo genérico cumpla con varias condiciones. Por ejemplo:

fun <T> procesarElemento(elemento: T) where T : Comestible, T : Serializable {
    elemento.comer()
    // Operaciones relacionadas con la serialización
}

En este caso, T debe ser tanto Comestible como Serializable, asegurando que el elemento pasado a procesarElemento puede ser consumido y también serializado. Las múltiples restricciones nos permiten crear funciones más específicas y seguras.

Es importante destacar que si se utiliza una clase concreta como cota superior, esa clase debe ser el primer límite, seguido de las interfaces. Por ejemplo:

fun <T> transformar(dato: T): String where T : Number, T : Comparable<T> {
    return dato.toString()
}

Aquí, T está limitado a ser una subclase de Number y también debe implementar Comparable.

Otra característica relevante es que las restricciones genéricas también se aplican a las clases. Si definimos una clase genérica con una restricción, todas las funciones y propiedades dentro de ella pueden asumir que el tipo genérico cumple con dicha restricción. Por ejemplo:

class GeneradorInforme<T : Any> {
    fun generar(dato: T): String {
        return dato.toString()
    }
}

Al usar T : Any, especificamos que T no puede ser un tipo nulo, lo que nos permite trabajar con dato sin preocuparnos por la nulabilidad.

En situaciones donde necesitamos asegurar que un tipo genérico es una clase con constructor sin parámetros, podemos utilizar una restricción especial con Any y el operador constructor(). Sin embargo, en Kotlin, no se permite restringir directamente a tipos con constructores específicos, pero podemos abordar este caso con patrones de diseño o funciones auxiliares.

Las restricciones también son esenciales al trabajar con funciones de extensión genéricas. Si queremos extender un tipo genérico solo cuando cumple cierta condición, aplicamos una restricción:

fun <T : CharSequence> T.imprimirLongitud() {
    println("La longitud es ${this.length}")
}

Esta función de extensión solo estará disponible para tipos que implementen CharSequence, como String o StringBuilder. Así, proporcionamos funcionalidades adicionales a tipos específicos, manteniendo la flexibilidad genérica.

Por último, es crucial entender que las restricciones en tipos genéricos permiten aprovechar al máximo el sistema de tipos de Kotlin, ofreciendo un equilibrio entre generalidad y seguridad. Al limitar los tipos genéricos a aquellos que cumplen ciertas condiciones, podemos escribir código más robusto y minimizar errores en tiempo de compilación, mejorando la calidad y mantenibilidad de nuestras aplicaciones.

Funciones y clases genéricas

Las funciones genéricas en Kotlin permiten escribir funciones que pueden operar con diferentes tipos de datos sin duplicar el código para cada tipo. Al utilizar parámetros de tipo genéricos, se aumenta la flexibilidad y reutilización de las funciones.

Para declarar una función genérica, se coloca el parámetro de tipo entre los símbolos < y > antes del nombre de la función:

fun <T> identidad(valor: T): T {
    return valor
}

En este ejemplo, identidad es una función genérica que acepta un parámetro valor de cualquier tipo T y devuelve un valor del mismo tipo. La flexibilidad de esta función permite utilizarla con distintos tipos:

val numero = identidad(42)
val texto = identidad("Kotlin")
val lista = identidad(listOf(1, 2, 3))

Las clases genéricas permiten definir estructuras de datos que pueden manejar diferentes tipos de elementos manteniendo la seguridad de tipos. Para declarar una clase genérica, se especifica el parámetro de tipo tras el nombre de la clase:

class Par<A, B>(val primero: A, val segundo: B)

Con esta definición, es posible crear instancias de Par con diferentes combinaciones de tipos:

val parEnteroCadena = Par(1, "Uno")
val parCadenaBooleano = Par("¿Es Kotlin genial?", true)

La seguridad de tipos garantiza que parEnteroCadena.primero es de tipo Int y parEnteroCadena.segundo es de tipo String, evitando errores en tiempo de ejecución.

Los constructores genéricos también pueden beneficiarse de parámetros de tipo. Por ejemplo, al crear una clase que contiene una lista mutable de elementos genéricos:

class Almacen<T>(vararg items: T) {
    private val elementos = items.toMutableList()

    fun agregarElemento(elemento: T) {
        elementos.add(elemento)
    }

    fun obtenerElementos(): List<T> {
        return elementos
    }
}

Al utilizar vararg en el constructor, se permite pasar varios argumentos del tipo genérico T. La clase Almacen puede utilizarse con diferentes tipos:

val almacenEnteros = Almacen(1, 2, 3)
almacenEnteros.agregarElemento(4)

val almacenCadenas = Almacen("A", "B")
almacenCadenas.agregarElemento("C")

Es importante destacar que los parámetros de tipo pueden tener restricciones. Si una función genérica necesita que el tipo cumpla ciertas condiciones, se pueden imponer límites:

fun <T : Comparable<T>> maximo(a: T, b: T): T {
    return if (a > b) a else b
}

En este ejemplo, T debe implementar la interfaz Comparable, lo que permite comparar los valores utilizando los operadores > y <. Así, se asegura que la función maximo funcione correctamente con tipos comparables.

Las funciones genéricas de extensión amplían las posibilidades al permitir agregar comportamiento genérico a tipos existentes:

fun <T> List<T>.intercambiar(indice1: Int, indice2: Int): List<T> {
    val mutableList = this.toMutableList()
    val temp = mutableList[indice1]
    mutableList[indice1] = mutableList[indice2]
    mutableList[indice2] = temp
    return mutableList.toList()
}

Esta función de extensión intercambiar permite intercambiar elementos en una lista de cualquier tipo T. Su uso es sencillo:

val listaOriginal = listOf(1, 2, 3)
val listaIntercambiada = listaOriginal.intercambiar(0, 2)

La reificación de tipos genéricos es una característica especial en Kotlin que permite acceder al tipo genérico en tiempo de ejecución dentro de funciones inline. Al marcar un tipo genérico como reified, se puede utilizar para realizar comprobaciones de tipo:

inline fun <reified T> esDelTipo(objeto: Any): Boolean {
    return objeto is T
}

val resultado = esDelTipo<String>("Kotlin") // Devuelve true

La palabra clave reified proporciona acceso al tipo T en tiempo de ejecución, lo cual no es posible en funciones genéricas comunes debido al borrado de tipos (type erasure).

Las clases anidadas genéricas permiten combinar genéricos con estructuras internas:

class Contenedor<T>(val valor: T) {
    inner class Contenido {
        fun obtenerValor(): T {
            return valor
        }
    }
}

La clase interna Contenido puede acceder al tipo genérico T de la clase externa Contenedor. Esto permite crear estructuras más complejas y flexibles:

val contenedor = Contenedor(100)
val contenido = contenedor.Contenido()
val valor = contenido.obtenerValor() // Devuelve 100

La inferencia de tipos de Kotlin facilita el trabajo con funciones y clases genéricas, ya que el compilador puede deducir el tipo genérico a partir del contexto, reduciendo la necesidad de especificar explícitamente el tipo:

val resultado = identidad("Aprendiendo Kotlin") // El compilador infiere que T es String

Al trabajar con clases genéricas especializadas, es posible crear variantes específicas de clases genéricas para tipos concretos, optimizando el rendimiento o añadiendo funcionalidades específicas:

class ListaEnteros : ArrayList<Int>() {
    fun sumarTodos(): Int {
        return this.sum()
    }
}

En este caso, ListaEnteros es una clase que hereda de ArrayList<Int> y añade una función para sumar todos los elementos.

Es fundamental comprender que los tipos genéricos en Kotlin proporcionan una manera poderosa de escribir código flexible y seguro. Al utilizar funciones y clases genéricas adecuadamente, se promueve la reutilización del código y se minimizan los errores relacionados con los tipos.

La coherencia en el uso de genéricos es clave para mantener un código limpio y fácil de mantener. Es recomendable nombrar los parámetros de tipo de manera significativa cuando sea posible, especialmente en clases y funciones complejas:

class Mapeador<Entrada, Salida> {
    fun mapear(entrada: Entrada): Salida {
        // Implementación del mapeo
    }
}

Utilizar nombres como Entrada y Salida en lugar de T o R mejora la legibilidad y comprensión del código.

En resumen, las funciones y clases genéricas son herramientas esenciales en Kotlin que permiten escribir código más generalizado y adaptable, facilitando el desarrollo de aplicaciones robustas y eficientes.

Aprende Kotlin GRATIS online

Ejercicios de esta lección Tipos genéricos y varianza

Evalúa tus conocimientos de esta lección Tipos genéricos y varianza con nuestros retos de programación de tipo Test, Puzzle, Código y Proyecto con VSCode, guiados por IA.

Todas las lecciones de Kotlin

Accede a todas las lecciones de Kotlin y aprende con ejemplos prácticos de código y ejercicios de programación con IDE web sin instalar nada.

Introducción A Kotlin

Kotlin

Introducción Y Entorno

Instalación Y Primer Programa De Kotlin

Kotlin

Introducción Y Entorno

Tipos De Datos, Variables Y Constantes

Kotlin

Sintaxis

Operadores Y Expresiones

Kotlin

Sintaxis

Cadenas De Texto Y Manipulación

Kotlin

Sintaxis

Estructuras De Control

Kotlin

Sintaxis

Funciones Y Llamada De Funciones

Kotlin

Sintaxis

Clases Y Objetos

Kotlin

Programación Orientada A Objetos

Herencia Y Polimorfismo

Kotlin

Programación Orientada A Objetos

Interfaces Y Clases Abstractas

Kotlin

Programación Orientada A Objetos

Data Classes Y Destructuring

Kotlin

Programación Orientada A Objetos

Tipos Genéricos Y Varianza

Kotlin

Programación Orientada A Objetos

Listas, Conjuntos Y Mapas

Kotlin

Estructuras De Datos

Introducción A La Programación Funcional

Kotlin

Programación Funcional

Funciones De Primera Clase Y De Orden Superior

Kotlin

Programación Funcional

Inmutabilidad Y Datos Inmutables

Kotlin

Programación Funcional

Composición De Funciones

Kotlin

Programación Funcional

Monads Y Manejo Funcional De Errores

Kotlin

Programación Funcional

Operaciones Funcionales En Colecciones

Kotlin

Programación Funcional

Transformaciones En Monads Y Functors

Kotlin

Programación Funcional

Funciones Parciales Y Currificación

Kotlin

Programación Funcional

Introducción A Las Corutinas

Kotlin

Coroutines Y Asincronía

Asincronía Con Suspend, Async Y Await

Kotlin

Coroutines Y Asincronía

Concurrencia Funcional

Kotlin

Coroutines Y Asincronía

Evaluación

Kotlin

Evaluación

Accede GRATIS a Kotlin y certifícate

Certificados de superación de Kotlin

Supera todos los ejercicios de programación del curso de Kotlin y obtén certificados de superación para mejorar tu currículum y tu empleabilidad.

En esta lección

Objetivos de aprendizaje de esta lección

  1. Comprender qué son los tipos genéricos en Kotlin y cómo se declaran.
  2. Implementar clases y funciones genéricas para mejorar la reutilización de código.
  3. Aplicar los conceptos de covarianza y contravarianza utilizando los modificadores out e in.
  4. Manejar restricciones en tipos genéricos para asegurar seguridad de tipos.
  5. Utilizar reificación en funciones inline para acceder a tipos genéricos en tiempo de ejecución.
  6. Optimizar el uso de colecciones genéricas para mantener la consistencia del tipo.