Capacitación de Kotlin para programadores 5.2: Elementos genéricos

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

Introducción

En este codelab, se te presentarán las clases, las funciones y los métodos genéricos, y cómo funcionan en Kotlin.

En lugar de crear una sola app de ejemplo, las lecciones de este curso están diseñadas para ampliar tus conocimientos, pero son semiindependientes entre sí para que puedas revisar las secciones con las que ya estás familiarizado. Para unirlos, muchos de los ejemplos usan un tema de acuario. Si quieres ver la historia completa del acuario, consulta el curso de Udacity Kotlin Bootcamp for Programmers.

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 ejecutar un programa

Qué aprenderás

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

Actividades

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

Introducción a los genéricos

Kotlin, como 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, mucho más flexible.

Imagina que implementas una clase MyList que contiene una lista de elementos. Sin los genéricos, deberías implementar una nueva versión de MyList para cada tipo: una para Double, una para String y una para Fish. Con los genéricos, puedes hacer que la lista sea genérica, de modo que pueda contener cualquier tipo de objeto. Es como convertir el tipo en un comodín que se ajustará a muchos tipos.

Para definir un tipo genérico, coloca T entre corchetes angulares <T> después del nombre de la clase. (Podrías 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 devolución para get() es T, y el parámetro para addItem() es del 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 siguiente. Ya vimos la creación de subclases en un codelab anterior, pero aquí hay un breve repaso.

  1. Para que el ejemplo no se vea desordenado, crea un paquete nuevo en src y llámalo generics.
  2. En el paquete generics, crea un archivo Aquarium.kt nuevo. Esto te permite redefinir elementos con los mismos nombres sin conflictos, por lo que el resto del código de este codelab se incluye en este archivo.
  3. Crea una jerarquía de tipos de suministro de agua. Comienza por convertir WaterSupply en una clase open para que pueda ser una subclase.
  4. Agrega un parámetro booleano var, needsProcessing. Esto crea automáticamente una propiedad mutable, junto con un getter y un setter.
  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 completado.
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 procesamiento, pero LakeWater se debe filtrar con el método filter(). Después de filtrar, no es necesario volver a procesar, por lo que, en filter(), establece needsProcessing = false.
class FishStoreWater : WaterSupply(false)

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

Si necesitas información adicional, revisa la lección anterior sobre la herencia en Kotlin.

Paso 2: Crea una clase genérica

En este paso, modificarás la clase Aquarium para admitir diferentes tipos de suministro 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 en el nivel superior del archivo, como la función main() o las definiciones de clase. En la función, crea un Aquarium y pásale un 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 del acuario. Como es de tipo TapWater, puedes llamar a addChemicalCleaners() sin ninguna conversión de tipos.
fun genericsExample() {
    val aquarium = Aquarium<TapWater>(TapWater())
    aquarium.waterSupply.addChemicalCleaners()
}
  1. Cuando crees el objeto Aquarium, puedes quitar los corchetes angulares y lo que se encuentra entre ellos, ya que Kotlin tiene inferencia de tipos. Por lo tanto, no hay motivo para decir TapWater dos veces cuando creas la instancia. El tipo se puede inferir a partir del argumento de Aquarium; de todos modos, se creará un Aquarium de tipo TapWater.
fun genericsExample() {
    val aquarium = Aquarium(TapWater())
    aquarium.waterSupply.addChemicalCleaners()
}
  1. Para ver qué sucede, 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(), luego ejecuta el programa y observa el resultado.
fun main() {
    genericsExample()
}
⇒ water needs processing: true
water needs processing: false

Paso 3: Hazlo más específico

Genérico significa que puedes pasar casi cualquier cosa, 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 incluir en ella.

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

El resultado es la cadena que pasaste, ya que Aquarium no impone ninguna limitación en T.. Se puede pasar cualquier tipo, incluido String.

  1. En genericsExample(), crea otro Aquarium y pasa null para el 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 Any? anulable, el tipo en la parte superior de la jerarquía de tipos. Lo siguiente equivale a lo que escribiste antes.

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

En este contexto, Any se denomina restricción genérica. Esto significa que se puede pasar cualquier tipo para T, siempre y cuando 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. Actúa como una aserción y arrojará un IllegalStateException si su argumento se evalúa como false.

  1. Agrega un método addWater() a la clase Aquarium para agregar agua, con un check() que garantice que no necesites 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() mostrará una excepción.

  1. En genericsExample(), agrega código para crear un Aquarium con LakeWater y, luego, agrega agua.
fun genericsExample() {
    val aquarium4 = Aquarium(LakeWater())
    aquarium4.addWater()
}
  1. Ejecuta el programa y obtendrás una excepción, ya que el agua debe filtrarse primero.
⇒ 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. Ahora, cuando ejecutes el programa, no se arrojará ninguna excepción.
fun genericsExample() {
    val aquarium4 = Aquarium(LakeWater())
    aquarium4.waterSupply.filter()
    aquarium4.addWater()
}
⇒ adding water from generics.LakeWater@880ec60

Lo anterior abarca los conceptos básicos de los genéricos. Las siguientes tareas abarcan 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ás sobre los tipos de entrada y salida con elementos genéricos. Un tipo in es un tipo que solo se puede pasar a una clase, no se puede devolver. Un tipo out es un tipo que solo se puede devolver desde una clase.

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

Los tipos in y out son directivas para el sistema de tipos de Kotlin. Explicar todo el sistema de tipos está fuera del alcance de este bootcamp (es bastante complejo). Sin embargo, el compilador marcará los tipos que no estén marcados como in y out de forma adecuada, por lo que debes conocerlos.

Paso 1: Define un tipo de salida

  1. En la clase Aquarium, cambia T: WaterSupply para que sea de 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 espera un 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 garantizar que addItemTo() no hará nada que no sea seguro para el tipo 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 llames a addItemTo(), ya que Kotlin no puede garantizar que no estés haciendo nada inseguro con el tipo.

Paso 2: Define un tipo in

El tipo in es similar al tipo out, pero para los tipos genéricos que solo se pasan a las funciones, no se devuelven. Si intentas devolver un tipo in, obtendrás un error del 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 T genérico que esté restringido a WaterSupply. Como solo se usa como argumento para clean(), puedes convertirlo en un parámetro in.
interface Cleaner<in T: WaterSupply> {
    fun clean(waterSupply: T)
}
  1. Para usar la interfaz Cleaner, crea una clase TapWaterCleaner que implemente Cleaner para limpiar TapWater agregando productos químicos.
class TapWaterCleaner : Cleaner<TapWater> {
    override fun clean(waterSupply: TapWater) =   waterSupply.addChemicalCleaners()
}
  1. En la clase Aquarium, actualiza addWater() para que tome un Cleaner de tipo T y limpia el agua antes de agregarla.
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 un poco de agua con el limpiador. Usará el limpiador 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 devolución, y los tipos in se pueden pasar hacia adentro como argumentos.

Si deseas profundizar en los tipos de problemas que resuelven los tipos de entrada y salida, la documentación los abarca en detalle.

En esta tarea, aprenderás sobre las funciones genéricas y cuándo usarlas. Por lo general, es una buena idea crear una función genérica siempre que la función tome un argumento de una clase que tenga 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 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 pueda llamar. A veces, out o in son demasiado restrictivos porque necesitas usar un tipo para la entrada y la salida. Puedes quitar el requisito de 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 para especificar el tipo genérico del acuario. Este patrón es muy común, y es una buena idea dedicar un momento a analizarlo.

  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 se necesita el tipo, por lo que se puede quitar. Ejecuta tu 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 materializado

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

  1. En la clase Aquarium, declara un método, hasWaterSupplyOfType(), que tome un parámetro genérico R (T ya se usa) restringido a WaterSupply y que muestre true si waterSupply es de tipo R. Es similar a la función que declaraste antes, pero dentro de la clase Aquarium.
fun <R: WaterSupply> hasWaterSupplyOfType() = waterSupply is R
  1. Observa que el elemento R final está subrayado en rojo. Mantén el puntero sobre él para ver cuál es el error.
  2. Para hacer 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, coloca 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 reifica un tipo, puedes usarlo como un tipo normal, ya que es un tipo real después de la inserción. Esto significa que puedes realizar verificaciones de is con el tipo.

Si no usas reified aquí, el tipo no será lo suficientemente "real" para que Kotlin permita las verificaciones de is. Esto se debe a que los tipos no materializados solo están disponibles en el tiempo de compilación y tu programa no los puede usar en el tiempo de ejecución. 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, llama a los métodos genéricos usando corchetes angulares con el tipo 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 materializados para funciones regulares 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 o TowerTank o alguna otra subclase), siempre y cuando sea un Aquarium. Usar la sintaxis de proyección de asterisco es una forma conveniente de especificar una variedad de coincidencias. Además, cuando usas una proyección de asterisco, Kotlin se asegura de que no hagas nada inseguro.

  1. Para usar una proyección de asterisco, coloca <*> después de Aquarium. Mueve hasWaterSupplyOfType() para que sea una función de extensión, ya que 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, debías marcar el tipo genérico como reified y hacer que la función fuera inline, ya que Kotlin necesita conocerlos en el tiempo de ejecución, no solo en el tiempo de compilación.

Kotlin solo usa todos los tipos genéricos en el tiempo de compilación. Esto permite que el compilador se asegure de que estás haciendo todo de forma segura. En el tiempo de ejecución, se borran todos los tipos genéricos, por lo que se muestra el mensaje de error anterior sobre la verificación de 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, esto significa que, a veces, haces algo, como is verificaciones en tipos genéricos, que el compilador no puede admitir. Por eso, Kotlin agregó tipos reificados o reales.

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

En esta lección, nos enfocamos en los 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.
  • Agrega restricciones genéricas para limitar los tipos que se usan con los genéricos.
  • Usa los tipos in y out con elementos genéricos para proporcionar una mejor verificación de tipos y restringir los tipos que se pasan a las clases o se devuelven de ellas.
  • 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 funcionalidad no principal a una clase.
  • A veces, los tipos materializados son necesarios debido al borrado de tipos. Los tipos materializados, a diferencia de los tipos genéricos, persisten hasta el tiempo de ejecución.
  • Usa la función check() para verificar que tu código se ejecute según lo previsto. Por ejemplo:
    check(!waterSupply.needsProcessing) { "water supply needs processing first" }

Documentación de Kotlin

Si deseas obtener más información sobre algún tema de este curso o si te quedas atascado, https://kotlinlang.org es el 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 Capacitación de Kotlin para programadores.

IntelliJ IDEA

Encontrarás la documentación de IntelliJ IDEA en el sitio web de JetBrains.

En esta sección, se enumeran las posibles actividades para el hogar para los alumnos que trabajan en este codelab como parte de un curso dirigido por un instructor. Depende del instructor hacer lo siguiente:

  • Si es necesario, asigna una tarea.
  • Comunicarles a los alumnos cómo enviar las actividades para el hogar.
  • Califica las actividades para el hogar.

Los instructores pueden usar estas sugerencias en la medida que quieran y deben asignar cualquier otra actividad para el hogar que consideren apropiada.

Si estás trabajando en este codelab por tu cuenta, usa estas actividades para el hogar para probar tus conocimientos.

Responde estas preguntas

Pregunta 1

¿Cuál de las siguientes opciones es la convención para nombrar un tipo 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 tipo genérico

Pregunta 3

Reificado significa lo siguiente:

▢ Se calculó el impacto real de la 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 los vínculos a otros codelabs, consulta "Capacitación de Kotlin para programadores: Bienvenido al curso".