Varianza en Kotlin – Gana la batalla a los genéricos
Antonio Leiva

Cuando hablamos de herencia, normalmente tenemos claro que cuando una clase hereda de otra, podemos usar la clase más genérica para dar soluciones más flexibles.

Por tanto, si tengo los tipos Int y Long, puedo crearme una función que reciba Number y dar una solución para ambos.

fun printNumber(number: Number){ ... }

Pero muchas veces confundimos los términos “Clase” y “Tipo” y esto nos lleva a situaciones en las que, aunque parece que un código debería funcionar, realmente no compila.

Clase vs Tipo

Todas las clases tienen (casi siempre) un tipo de datos asociados, y por eso muchas veces usamos los conceptos de forma indistinta. Pero la realidad no es esta.

Por ejemplo, mientras que Int es una clase, cuando creamos un objeto de Int, la variable que lo contiene es de tipo Int

La clase es lo que implementamos, mientras que el tipo es lo que podemos hacer con ese objeto.

Normalmente cada clase representa un tipo, pero cuando entramos en el mundo de los genéricos, una clase puede representar múltiples tipos (la mayoría de las veces, infinitos).

Si volvemos al ejemplo que hicimos sobre Encrypter:

class Encrypter<T>(val item: T)

Encrypter es la clase, mientras que Encrypter<Note> o Encrypter<List<String>> es un tipo.

¿Y cuándo un tipo no está asociado a una clase? En Kotlin no pasa porque todos los tipos son clases, pero en Java por ejemplo tenemos los tipos primitivos, que no tienen una clase asociada.

¿Y qué pasa con esto?

Básicamente que aunque Int sea una subclase de Number, Encrypter<Int> no es un subtipo de Encrypter<Number>. Si intentas hacer esto:

val e: Encrypter<Int> = Encrypter(10)
val e2: Encrypter<Number> = e

Verás que no compila.

Vamos a verlo en un ejemplo con el que es más fácil razonar. Imagina que tienes un código como este:

val list: MutableList<Int> = mutableListOf(1, 2, 3)
val list2: MutableList<Any> = list
list2.add("Hello")
val i = list[3]

Si yo considero que MutableList<Int> es un subtipo de MutableList<Any>, entonces podría guardarlo en una variable con un tipo más genérico, ¿no?

Si el lenguaje me permitiera esto, tendría que en una lista de enteros, puedo guardar un String 😱, ¡y para nada queremos permitir esto!

Ya que al recuperarlo en la última línea, tendríamos una excepción ya que los tipos no se corresponden.

Pero si esto no se permitiera nunca, trabajar con tipos genéricos se volvería inmanejable

Hay veces que tiene sentido que se nos permitan ciertas operaciones. Por ejemplo, al igual que añadir un elemento no debe permitirse, si pudiéramos estar seguros de que solo vamos a recuperar elementos, aquí no habría problema, ¿verdad?

Una lista de Int a todos los efectos se comportaría como una lista de Number, ya que las operaciones de lectura permiten que trabajemos con los objetos a un nivel más genérico.

Si simplemente vamos a consumir datos (somos consumidores, o consumers), entonces podemos relajar esa condición:

val list: List<Int> = listOf(1, 2, 3)
val list2: List<Number> = list
val obj: Number = list2[0]

En Kotlin, si en vez de una MutableList usamos una List (que solo permite leer datos, pero no consumirlos), nos encontramos con que un código similar al anterior sí que compila.

¿Esto cómo se define a nivel formal?

  • MutableList es invariante con respecto a su tipo genérico (no podemos ni consumir ni producir/añadir valores con un tipo más genérico)
  • List es covariante con respecto a su tipo genérico (podemos consumir sus valores con un tipo más genérico)

Obviamente esto no ocurre así, sin más. Cuando definimos un tipo genérico tenemos que indicarle si puede ser o no covariante.

Para ello, es tan sencillo como añadirle la palabra out al tipo:

public interface List<out E>

Esto luego va a suponer ciertas restricciones a la hora de escribir funciones asociadas a esta clase.

No vamos a poder escribir funciones que reciban elementos de ese tipo E por argumento. Esto dará error:

interface List<out E>{
    fun add(e: E)
}

¿Qué significa consumir (covarianza) y qué producir (contravarianza)?

  • Consumir implica tener funciones que devuelven un valor del tipo genérico
  • Producir implica tener funciones que reciben por argumento un objeto del tipo genérico

Por tanto:

  • Si queremos devolver valores de un tipo más genérico que el tipo original, necesitamos usar covarianza, y por tanto usar out en el tipo.
  • Si queremos pasar valores de un tipo más genérico que el original, necesitamos usar contravarianza, y por tanto usar in en el tipo.
  • Si solo queremos trabajar con el tipo original, entonces el tipo será invariante (no le ponemos al tipo in ni out)

Un ejemplo de covarianza

Aunque ya lo hemos visto anteriormente con la lista, vamos a volver al ejemplo del Encrypter:

val e: Encrypter<Int> = Encrypter(10)
val e2: Encrypter<Number> = e

Si quisierámos hacer esto, necesitamos asegurarnos de que no vamos a producir tipos Number en el Encrypter, ya que podríamos añadir objetos de un tipo distinto al original.

Para ello, es tan sencillo como añadir out al tipo:

class Encrypter<out T>(val item: T)

Ahora el código anterior ya funcionará

Un ejemplo de contravarianza

Esta idea es menos intuitiva, pero vamos a intentar seguirla con un ejemplo clásico. Imagina que tienes una interfaz que compara 2 objetos:

interface Comparable<T>{
    fun compare(other: T): Int
}

Y que en una parte de tu código te llega un Comparable<Number>:

fun test(comparable: Comparable<Number>) {
    ...
}

Pero por lo que sea, en esta función quieres que solo se puedan comparar con Floats. Necesitas limitar tu código de esa forma.

Tiene sentido que puedas hacerlo, ya que si el Comparable permite comparar Numbers, comparar con Floats es un subconjunto de lo que este tipo te deja hacer.

Así que decides hacer el casting para forzar a usarlo así a partir de este punto:

fun test(comparable: Comparable<Number>) {
    val comp: Comparable<Float> = comparable
}

Esto va a dar error, ya que no hemos definido el tipo de varianza, y por tanto el tipo aquí es invariante: si no indicamos la varianza, no podemos convertir un Comparable<Number en un Comparable<Float>.

Pero ahora tienes claro que esto lo podemos hacer, y que además como la función recibe un argumento de tipo T, nos encontramos ante un producer, y por tanto la palabra a usar en el tipo es in:

interface Comparable<in T>{
    fun compare(other: T): Int
}

Y ahora el ejemplo anterior funcionará

Conclusión

Los genéricos y la varianza son un tema confuso, que cuesta llegar a comprender y al que tendrás que volver de vez en cuando para refrescar conceptos.

No te preocupes, porque ahora tienes este artículo para volver a ello cuando lo necesites.

Recuerda siempre el concepto: cuando trabajes con genéricos, los tipos no mantienen la herencia de sus clases, y en función de si esas clases actúan como consumidores o productores, tendremos que usar covarianza o contravarianza.

Quizá también te interese…

Contracts en Kotlin: Haz más listo al compilador

Contracts en Kotlin: Haz más listo al compilador

El compilador de Kotlin es muy potente, y nos puede ayudar en muchos aspectos en los que otros compiladores como Java pasan de largo. Temas como los nulos, inferencia de tipos, genéricos, smart casting, y un largo etcétera, hacen del compilador de Kotlin una...

Kotlin 1.5.0 : Las 5 novedades que puedes empezar a usar hoy

Kotlin 1.5.0 : Las 5 novedades que puedes empezar a usar hoy

Kotlin 1.5.0 ya está aquí, y como siempre trae una serie de novedades que te van a interesar muchísimo. Cabe destacar que a partir de ahora, de acuerdo las nuevas versiones de Kotlin se lanzarán cada 6 meses, independientemente de las nuevas funcionalidades que...

¿Qué es Kotlin y para qué sirve?

¿Qué es Kotlin y para qué sirve?

Kotlin es un lenguaje de programación de código abierto creado por JetBrains que se ha popularizado gracias a que se puede utilizar para programar aplicaciones Android. Pero si has llegado hasta aquí pensando que Kotlin solo se puede usar en Android, lo que te voy a...

0 comentarios

Enviar un comentario

Los datos personales que proporciones a través de este formulario quedarán registrados en un fichero de Antonio Leiva Gordillo, con el fin de gestionar los comentarios que realizas en este blog. La legitimación se realiza a través del consentimiento de la parte interesada. Si no se acepta, no podrás comentar en este blog. Los datos que proporciona solo se utilizan para evitar el correo no deseado y no se usarán para nada más. Puede ejercer los derechos de acceso, rectificación, cancelación y oposición en contacto@devexperto.com.

Tu dirección de correo electrónico no será publicada. Los campos obligatorios están marcados con *

Acepto la política de privacidad *