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
niout)
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 Float
s. Necesitas limitar tu código de esa forma.
Tiene sentido que puedas hacerlo, ya que si el Comparable
permite comparar Number
s, comparar con Float
s 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.
0 comentarios