Capacitación de Kotlin para programadores 5.2: Genéricos

Este codelab es parte del curso Capacitación de Kotlin para programadores. Aprovecharás al máximo este curso si trabajas con los codelabs en secuencia. Según tus conocimientos, es posible que puedas cambiar algunas secciones. Este curso está dirigido a los programadores que conocen un lenguaje orientado a objetos y desean aprender Kotlin.

Introducción

En este codelab, conocerás clases, funciones y métodos genéricos, así como la forma en que funcionan en Kotlin.

En lugar de compilar una sola app de muestra, las lecciones de este curso están diseñadas para aumentar tu conocimiento, pero son semiindependientes entre sí a fin de que puedas leer las secciones con las que estás familiarizado. Para vincularlos, muchos de los ejemplos usan un tema de acuario. Si deseas ver la historia completa del acuario, consulta el curso de Udacity Capacitación de Kotlin para programadores.

Conocimientos que ya deberías tener

  • La sintaxis de las funciones, las clases y los métodos de Kotlin
  • Cómo crear una clase nueva en IntelliJ IDEA y cómo ejecutar un programa

Qué aprenderás

  • Cómo trabajar con funciones, métodos y clases genéricas

Actividades

  • Crea una clase genérica y agrega restricciones
  • Crear tipos in y out
  • Crea funciones, métodos y funciones genéricas de extensión

Introducción a los genéricos

Kotlin, al igual que muchos lenguajes de programación, tiene tipos genéricos. Un tipo genérico te permite hacer que una clase sea genérica y, por lo tanto, una clase mucho más flexible.

Imagina que estás implementando una clase MyList que contiene una lista de elementos. Sin los elementos genéricos, debes implementar una versión nueva de MyList para cada tipo: una para Double, otra para String y otra para Fish. Con ella, puede hacer que la lista sea genérica para que contenga cualquier tipo de objeto. Es como hacer que el tipo sea un comodín que se adapte a muchos tipos.

Para definir un tipo genérico, coloca T entre corchetes angulares <T> después del nombre de la clase. (podría usar otra letra o un nombre más largo, pero la convención para un tipo genérico es T).

class MyList<T> {
    fun get(pos: Int): T {
        TODO("implement")
    }
    fun addItem(item: T) {}
}

Puedes hacer referencia a T como si fuera un tipo normal. El tipo de datos que se muestra para get() es T y el parámetro para addItem() es de tipo T. Por supuesto, las listas genéricas son muy útiles, por lo que la clase List está integrada en Kotlin.

Paso 1: Crea una jerarquía de tipos

En este paso, crearás algunas clases para usar en el paso siguiente. En una codelab anterior, se trataron las subclases, pero aquí te ofrecemos una breve revisión.

  1. Para mantener el ejemplo ordenado, crea un paquete nuevo en src y llámalo generics.
  2. En el paquete generics, crea un archivo Aquarium.kt nuevo. De esta manera, puedes redefinir las cosas con los mismos nombres sin conflictos, por lo que el resto del código de este codelab se incluye en este archivo.
  3. Haz una jerarquía de tipos de tipos de suministro de agua. Comienza por hacer que WaterSupply sea una clase open, por lo que puede ser una subclase.
  4. Agrega un parámetro booleano var, needsProcessing. Esto crea automáticamente una propiedad mutable, junto con un método get y un método set.
  5. Crea una subclase TapWater que extienda WaterSupply, y pasa true para needsProcessing, ya que el agua del grifo contiene aditivos que son malos para los peces.
  6. En TapWater, define una función llamada addChemicalCleaners() que establezca needsProcessing en false después de limpiar el agua. La propiedad needsProcessing se puede establecer desde TapWater, ya que es public de forma predeterminada y accesible para las subclases. Este es el código completo.
package generics

open class WaterSupply(var needsProcessing: Boolean)

class TapWater : WaterSupply(true) {
   fun addChemicalCleaners() {
       needsProcessing = false
   }
}
  1. Crea dos subclases más de WaterSupply, llamadas FishStoreWater y LakeWater. FishStoreWater no necesita procesarse, pero LakeWater debe filtrarse con el método filter(). Después del filtrado, no es necesario volver a procesarlo, por lo que en filter() debes configurar needsProcessing = false.
class FishStoreWater : WaterSupply(false)

class LakeWater : WaterSupply(true) {
   fun filter() {
       needsProcessing = false
   }
}

Si necesitas más información, consulta la lección anterior sobre herencia en Kotlin.

Paso 2: Crea una clase genérica

En este paso, modificarás la clase Aquarium para admitir diferentes tipos de suministros de agua.

  1. En Aquarium.kt, define una clase Aquarium, con <T> entre corchetes después del nombre de la clase.
  2. Agrega una propiedad inmutable waterSupply de tipo T a Aquarium.
class Aquarium<T>(val waterSupply: T)
  1. Escribe una función llamada genericsExample(). Esto no forma parte de una clase, por lo que puede ir al nivel superior del archivo, como la función main() o las definiciones de clase. En la función, crea un Aquarium y pásale una WaterSupply. Dado que el parámetro waterSupply es genérico, debes especificar el tipo entre corchetes angulares <>.
fun genericsExample() {
    val aquarium = Aquarium<TapWater>(TapWater())
}
  1. En genericsExample(), tu código puede acceder al waterSupply de tu acuario. Como es de tipo TapWater, puedes llamar a addChemicalCleaners() sin conversiones de tipo.
fun genericsExample() {
    val aquarium = Aquarium<TapWater>(TapWater())
    aquarium.waterSupply.addChemicalCleaners()
}
  1. Cuando creas el objeto Aquarium, puedes quitar los corchetes angulares y lo que se encuentra entre estos porque Kotlin tiene inferencia de tipo. Por lo tanto, no hay motivo para decir TapWater dos veces cuando creas la instancia. El argumento se puede deducir mediante el argumento de Aquarium; de todos modos, creará un Aquarium de tipo TapWater.
fun genericsExample() {
    val aquarium = Aquarium(TapWater())
    aquarium.waterSupply.addChemicalCleaners()
}
  1. Para ver qué está sucediendo, imprime needsProcessing antes y después de llamar a addChemicalCleaners(). A continuación, se muestra la función completada.
fun genericsExample() {
    val aquarium = Aquarium<TapWater>(TapWater())
    println("water needs processing: ${aquarium.waterSupply.needsProcessing}")
    aquarium.waterSupply.addChemicalCleaners()
    println("water needs processing: ${aquarium.waterSupply.needsProcessing}")
}
  1. Agrega una función main() para llamar a genericsExample() y, luego, ejecuta tu programa y observa el resultado.
fun main() {
    genericsExample()
}
⇒ water needs processing: true
water needs processing: false

Paso 3: Sé más específico

"Genérico" significa que puedes pasar casi todo lo que sucede, y a veces eso es un problema. En este paso, harás que la clase Aquarium sea más específica sobre lo que puedes poner en ella.

  1. En genericsExample(), crea un Aquarium y pasa una string para waterSupply y, luego, imprime la propiedad waterSupply del acuario.
fun genericsExample() {
    val aquarium2 = Aquarium("string")
    println(aquarium2.waterSupply)
}
  1. Ejecuta el programa para observar el resultado.
⇒ string

El resultado es la string que pasaste, ya que Aquarium no coloca ninguna limitación en T.Cualquier tipo, incluido String, se puede pasar.

  1. En genericsExample(), crea otro Aquarium y pasa null para waterSupply. Si waterSupply es nulo, imprime "waterSupply is null".
fun genericsExample() {
    val aquarium3 = Aquarium(null)
    if (aquarium3.waterSupply == null) {
        println("waterSupply is null")
    }
}
  1. Ejecuta el programa y observa el resultado.
⇒ waterSupply is null

¿Por qué puedes pasar null cuando creas un Aquarium? Esto es posible porque, de forma predeterminada, T representa el tipo anulable Any?, el tipo en la parte superior de la jerarquía de tipos. El siguiente es equivalente a lo que escribiste antes.

class Aquarium<T: Any?>(val waterSupply: T)
  1. Para no permitir el paso de null, quita el elemento ? después de Any y haz que T de tipo Any sea explícito.
class Aquarium<T: Any>(val waterSupply: T)

En este contexto, Any se denomina restricción genérica. Significa que se puede pasar cualquier tipo para T, siempre que no sea null.

  1. Lo que realmente quieres es asegurarte de que solo se pueda pasar un WaterSupply (o una de sus subclases) para T. Reemplaza Any por WaterSupply para definir una restricción genérica más específica.
class Aquarium<T: WaterSupply>(val waterSupply: T)

Paso 4: Agrega más verificaciones

En este paso, aprenderás sobre la función check() para asegurarte de que tu código se comporte como se espera. La función check() es una función de biblioteca estándar en Kotlin. Funciona como una aserción y arrojará una IllegalStateException si su argumento se evalúa como false.

  1. Agrega un método addWater() a la clase Aquarium a fin de agregar agua, con un check() que garantiza que no se necesite procesar el agua primero.
class Aquarium<T: WaterSupply>(val waterSupply: T) {
    fun addWater() {
        check(!waterSupply.needsProcessing) { "water supply needs processing first" }
        println("adding water from $waterSupply")
    }    
}

En este caso, si needsProcessing es verdadero, check() arrojará una excepción.

  1. En genericsExample(), agrega código para crear Aquarium con LakeWater y, luego, agrégale agua.
fun genericsExample() {
    val aquarium4 = Aquarium(LakeWater())
    aquarium4.addWater()
}
  1. Ejecuta el programa. Se mostrará una excepción, ya que primero debes filtrar el agua.
⇒ Exception in thread "main" java.lang.IllegalStateException: water supply needs processing first
        at Aquarium.generics.Aquarium.addWater(Aquarium.kt:21)
  1. Agrega una llamada para filtrar el agua antes de agregarla a Aquarium. Cuando ejecutas el programa, no se producen excepciones.
fun genericsExample() {
    val aquarium4 = Aquarium(LakeWater())
    aquarium4.waterSupply.filter()
    aquarium4.addWater()
}
⇒ adding water from generics.LakeWater@880ec60

Lo anterior cubre los conceptos básicos de los elementos genéricos. Las siguientes tareas cubren más, pero el concepto importante es cómo declarar y usar una clase genérica con una restricción genérica.

En esta tarea, aprenderá sobre los tipos de entrada y salida con genéricos. Un tipo in es un tipo que solo se puede pasar a una clase, no se muestra. Un tipo out es un tipo que solo se puede mostrar desde una clase.

Si observas la clase Aquarium, verás que el tipo genérico solo se muestra cuando se obtiene la propiedad waterSupply. No hay ningún método que tome un valor de tipo T como parámetro (excepto por definirlo en el constructor). Kotlin te permite definir tipos out para este caso con exactitud, y también puede inferir información adicional sobre los lugares en los que los tipos son seguros. De manera similar, puedes definir tipos in para tipos genéricos que solo se pasan a métodos, no se muestran. Esto permite que Kotlin realice verificaciones adicionales para comprobar la seguridad del código.

Los tipos in y out son directivas para el sistema de tipos de Kotlin. La explicación de todo el sistema de tipos está fuera del alcance de este bootcamp (está bastante relacionado). Sin embargo, el compilador marcará los tipos que no estén marcados correctamente in y out, por lo que debes conocerlos.

Paso 1: Define un tipo de salida

  1. En la clase Aquarium, cambia T: WaterSupply para que sea un tipo out.
class Aquarium<out T: WaterSupply>(val waterSupply: T) {
    ...
}
  1. En el mismo archivo, fuera de la clase, declara una función addItemTo() que espere una Aquarium de WaterSupply.
fun addItemTo(aquarium: Aquarium<WaterSupply>) = println("item added")
  1. Llama a addItemTo() desde genericsExample() y ejecuta tu programa.
fun genericsExample() {
    val aquarium = Aquarium(TapWater())
    addItemTo(aquarium)
}
⇒ item added

Kotlin puede asegurarse de que addItemTo() no haga nada de tipo no seguro con el WaterSupply genérico, ya que se declara como un tipo out.

  1. Si quitas la palabra clave out, el compilador mostrará un error cuando se llame a addItemTo(), ya que Kotlin no puede garantizar que no hagas nada inseguro con el tipo.

Paso 2: Define un tipo en

El tipo in es similar al tipo out, pero para los tipos genéricos que solo se pasan a funciones, no se muestran. Si intentas mostrar un tipo in, se mostrará un error de compilador. En este ejemplo, definirás un tipo in como parte de una interfaz.

  1. En Aquarium.kt, define una interfaz Cleaner que tome un elemento T genérico que esté restringido a WaterSupply. Como solo se usa como argumento de clean(), puedes convertirlo en un parámetro in.
interface Cleaner<in T: WaterSupply> {
    fun clean(waterSupply: T)
}
  1. Si deseas usar la interfaz Cleaner, agrega productos químicos para crear una clase TapWaterCleaner que implemente Cleaner a fin de limpiar TapWater.
class TapWaterCleaner : Cleaner<TapWater> {
    override fun clean(waterSupply: TapWater) =   waterSupply.addChemicalCleaners()
}
  1. En la clase Aquarium, actualiza addWater() para tomar un Cleaner de tipo T y limpia el agua antes de agregarlo.
class Aquarium<out T: WaterSupply>(val waterSupply: T) {
    fun addWater(cleaner: Cleaner<T>) {
        if (waterSupply.needsProcessing) {
            cleaner.clean(waterSupply)
        }
        println("water added")
    }
}
  1. Actualiza el código de ejemplo de genericsExample() para crear un TapWaterCleaner, un Aquarium con TapWater y, luego, agrega agua con el limpiador. Usará la herramienta de limpieza según sea necesario.
fun genericsExample() {
    val cleaner = TapWaterCleaner()
    val aquarium = Aquarium(TapWater())
    aquarium.addWater(cleaner)
}

Kotlin usará la información de tipo in y out para asegurarse de que tu código use los genéricos de forma segura. Out y in son fáciles de recordar: los tipos out se pueden pasar hacia afuera como valores de retorno, los tipos in se pueden pasar hacia adentro como argumentos.

Si deseas profundizar en el tipo de problemas que tienen los tipos y cómo se resuelven los tipos, la documentación los trata en profundidad.

En esta tarea, aprenderá sobre las funciones genéricas y cuándo usarlas. Por lo general, una función genérica es recomendable cuando la función toma un argumento de una clase que tiene un tipo genérico.

Paso 1: Crea una función genérica

  1. En generics/Aquarium.kt, crea una función isWaterClean() que tome un elemento Aquarium. Debes especificar el tipo genérico del parámetro; una opción es usar WaterSupply.
fun isWaterClean(aquarium: Aquarium<WaterSupply>) {
   println("aquarium water is clean: ${aquarium.waterSupply.needsProcessing}")
}

Sin embargo, esto significa que Aquarium debe tener un parámetro de tipo out para que se lo llame. A veces, out o in son demasiado restrictivos porque debes usar un tipo tanto para la entrada como para la salida. Puedes quitar el requisito out si haces que la función sea genérica.

  1. Para que la función sea genérica, coloca corchetes angulares después de la palabra clave fun con un tipo genérico T y cualquier restricción, en este caso, WaterSupply. Cambia Aquarium para que esté restringido por T en lugar de por WaterSupply.
fun <T: WaterSupply> isWaterClean(aquarium: Aquarium<T>) {
   println("aquarium water is clean: ${!aquarium.waterSupply.needsProcessing}")
}

T es un parámetro de tipo para isWaterClean() que se usa a fin de especificar el tipo genérico del acuario. Este patrón es muy común, y te recomendamos tomarte un momento para solucionarlo.

  1. Llama a la función isWaterClean() especificando el tipo entre corchetes angulares justo después del nombre de la función y antes de los paréntesis.
fun genericsExample() {
    val aquarium = Aquarium(TapWater())
    isWaterClean<TapWater>(aquarium)
}
  1. Debido a la inferencia de tipo del argumento aquarium, no es necesario, por lo que debes quitarlo. Ejecuta el programa y observa el resultado.
fun genericsExample() {
    val aquarium = Aquarium(TapWater())
    isWaterClean(aquarium)
}
⇒ aquarium water is clean: false

Paso 2: Crea un método genérico con un tipo reificado

También puedes usar funciones genéricas para los métodos, incluso en clases que tengan su propio tipo genérico. En este paso, agregarás un método genérico a Aquarium, que verifica si tiene un tipo de WaterSupply.

  1. En la clase Aquarium, declara un método que hasWaterSupplyOfType() tome un parámetro genérico R (T ya está usado) restringido a WaterSupply y muestra true si waterSupply es del tipo R. Esto es como la función que declaraste antes, pero dentro de la clase Aquarium.
fun <R: WaterSupply> hasWaterSupplyOfType() = waterSupply is R
  1. Observa que el R final está subrayado en rojo. Coloca el cursor sobre él para ver cuál es el error.
  2. Para realizar una verificación de is, debes indicarle a Kotlin que el tipo es reificado o real, y que se puede usar en la función. Para ello, escribe inline delante de la palabra clave fun y reified delante del tipo genérico R.
inline fun <reified R: WaterSupply> hasWaterSupplyOfType() = waterSupply is R

Una vez que se especifica un tipo, se puede usar como un tipo normal, ya que se trata de un tipo real después del intercalado. Esto significa que puedes realizar verificaciones de is con el tipo.

Si no usas reified aquí, el tipo no será lo suficientemente real como para que Kotlin permita las comprobaciones de is. Eso se debe a que los tipos no reificados solo están disponibles durante el tiempo de compilación y tu programa no puede usarlos. Esto se analiza con más detalle en la siguiente sección.

  1. Pasa TapWater como el tipo. Al igual que con las funciones genéricas, puedes llamar a métodos genéricos usando corchetes angulares con el tipo que aparece después del nombre de la función. Ejecuta el programa y observa el resultado.
fun genericsExample() {
    val aquarium = Aquarium(TapWater())
    println(aquarium.hasWaterSupplyOfType<TapWater>())   // true
}
⇒ true

Paso 3: Crea funciones de extensión

También puedes usar tipos reificados para funciones normales y de extensión.

  1. Fuera de la clase Aquarium, define una función de extensión en WaterSupply llamada isOfType() que verifique si el WaterSupply pasado es de un tipo específico, por ejemplo, TapWater.
inline fun <reified T: WaterSupply> WaterSupply.isOfType() = this is T
  1. Llama a la función de extensión como si fuera un método.
fun genericsExample() {
    val aquarium = Aquarium(TapWater())
    println(aquarium.waterSupply.isOfType<TapWater>())  
}
⇒ true

Con estas funciones de extensión, no importa qué tipo de Aquarium sea (Aquarium, TowerTank o alguna otra subclase), siempre que sea un Aquarium. La sintaxis de star-projection es una forma conveniente de especificar una variedad de coincidencias. Y, cuando uses una proyección en estrella, Kotlin se asegurará de no hacer nada inseguro.

  1. Para usar una proyección en estrella, coloca <*> después de Aquarium. Mueve hasWaterSupplyOfType() para que sea una función de extensión, porque realmente no forma parte de la API principal de Aquarium.
inline fun <reified R: WaterSupply> Aquarium<*>.hasWaterSupplyOfType() = waterSupply is R
  1. Cambia la llamada a hasWaterSupplyOfType() y ejecuta tu programa.
fun genericsExample() {
    val aquarium = Aquarium(TapWater())
    println(aquarium.hasWaterSupplyOfType<TapWater>())
}
⇒ true

En el ejemplo anterior, tuviste que marcar el tipo genérico como reified y hacer la función inline, porque Kotlin necesita conocerlos en el tiempo de ejecución, no solo el tiempo de compilación.

Kotlin solo usa todos los tipos genéricos en el tiempo de compilación. De esta manera, el compilador se asegurará de que lo hagas de forma segura. Durante el tiempo de ejecución, se borran todos los tipos genéricos. Por lo tanto, el mensaje de error anterior sobre verificar un tipo borrado

Resulta que el compilador puede crear código correcto sin conservar los tipos genéricos hasta el tiempo de ejecución. Sin embargo, a veces realizas acciones, como is, para comprobar tipos genéricos que el compilador no admite. Por eso Kotlin agregó tipos reificados o reales.

Puedes leer más sobre los tipos reificados y el borrado de tipos en la documentación de Kotlin.

Esta lección se enfocó en elementos genéricos, que son importantes para que el código sea más flexible y fácil de reutilizar.

  • Crea clases genéricas para que el código sea más flexible.
  • Agregue restricciones genéricas para limitar los tipos que se usan con elementos genéricos.
  • Usa los tipos in y out con genéricos para proporcionar una mejor verificación de tipos a fin de restringir los tipos que se pasan a las clases o se devuelven de las clases.
  • Crea funciones y métodos genéricos para trabajar con tipos genéricos. Por ejemplo:
    fun <T: WaterSupply> isWaterClean(aquarium: Aquarium<T>) { ... }
  • Usa funciones de extensión genéricas para agregar una funcionalidad no principal a una clase.
  • Los tipos reificados a veces son necesarios debido al borrado de tipos. Los tipos reificados, a diferencia de los genéricos, persisten en el entorno de ejecución.
  • Usa la función check() para verificar que tu código se ejecute como se espera. Por ejemplo:
    check(!waterSupply.needsProcessing) { "water supply needs processing first" }

Documentación de Kotlin

Si necesitas más información sobre algún tema de este curso o si no puedes avanzar, https://kotlinlang.org es tu mejor punto de partida.

Instructivos de Kotlin

El sitio web https://try.kotlinlang.org incluye instructivos enriquecidos llamados Kotlin Koans, un intérprete basado en la Web, y un conjunto completo de documentación de referencia con ejemplos.

Curso de Udacity

Para ver el curso de Udacity sobre este tema, consulta el Capacitación de Kotlin para programadores.

IntelliJ IDEA

Puedes encontrar la documentación de IntelliJ IDEA en el sitio web de JetBrains.

En esta sección, se enumeran las posibles tareas para los alumnos que trabajan con este codelab como parte de un curso que dicta un instructor. Depende del instructor hacer lo siguiente:

  • Si es necesario, asigna la tarea.
  • Informa a los alumnos cómo enviar los deberes.
  • Califica las tareas.

Los instructores pueden usar estas sugerencias lo poco o lo que quieran, y deben asignar cualquier otra tarea que consideren apropiada.

Si estás trabajando en este codelab por tu cuenta, usa estas tareas para poner a prueba tus conocimientos.

Responde estas preguntas

Pregunta 1

¿Cuál de las siguientes opciones es una convención para asignar un nombre genérico?

<Gen>

<Generic>

<T>

<X>

Pregunta 2

Una restricción sobre los tipos permitidos para un tipo genérico se denomina:

▢ una restricción genérica

▢ una restricción genérica

▢ desambiguación

▢ un límite de tipos genérico

Pregunta 3

Unificado significa lo siguiente:

▢ Se calculó el impacto real de ejecución de un objeto.

▢ Se estableció un índice de entrada restringido en la clase.

▢ El parámetro de tipo genérico se convirtió en un tipo real.

▢ Se activó un indicador de error remoto.

Continúa con la siguiente lección: 6. Manipulación funcional

Para obtener una descripción general del curso, incluidos vínculos a otros codelabs, consulta "Capacitación de Kotlin para programadores: Bienvenido al curso."