En la programación orientada a objetos vimos que uno de los conceptos clave es la reutilización de código. Los objetos permiten encapsular datos y comportamiento en una unidad coherente, lo que facilita la creación de aplicaciones más mantenibles y escalables. Sin embargo, cuando trabajamos con objetos en Java, a veces enfrentamos el desafío de manejar diferentes tipos de datos de manera segura y eficiente. Aquí es donde entran en juego los tipos paramétricos o Generics en Java.
Es una característica del lenguaje Java que nos permite crear clases, interfaces y métodos que funcionan con tipos de datos específicos sin comprometer la seguridad de tipos. Podremos así utilizar en nuestro código variables de tipo en lugar de tipos de datos concretos. Nos permiten escribir código que puede ser reutilizado con diferentes tipos de datos de manera segura.
Imaginemos que estamos escribiendo una clase para almacenar una lista de cualquier tipo de elemento. Sin Generics, podríamos hacer algo así:
public class ListaNoGenerica {
private Object[] elementos = new Object[10];
private int size = 0;
public void agregar(Object elemento) {
// TODO: Resize de arreglo elementos
elementos[size++] = elemento;
}
public Object obtener(int indice) {
// TODO: Validación de índice fuera de rango
return elementos[indice];
}
}
En esta implementación estamos usando un arreglo de tipo Object para almacenar elementos en nuestra lista. Esto significa que podemos almacenar cualquier tipo de objeto en la lista. Sin embargo, también significa que cuando obtengamos un elemento de la lista, necesitaremos realizar una conversión explícita de tipo (downcasting), lo cual puede ser propenso a errores en tiempo de ejecución.
ListaNoGenerica lista = new ListaNoGenerica();
lista.agregar(11); // Autoboxing a Integer y Upcasting a Object
listaEnteros.agregar("hola"); // Upcasting de String a Object
listaEnteros.obtener(0); // Devuelve Object con referencia a Integer (11)
(Integer) listaEnteros.obtener(0); // Devuelve 11 (Integer), pero es inseguro.
(Integer) listaEnteros.obtener(1); // ClassCastException
Con Generics podemos hacer esto de manera más segura y sin necesidad de conversiones de tipo:
public class ListaGenerica<T> {
private T[] elementos;
private int size = 0;
public ListaGenerica(int capacidad) {
elementos = (T[]) new Object[capacidad];
}
public void agregar(T elemento) {
// TODO: Resize de arreglo elementos
elementos[size++] = elemento;
}
public T obtener(int indice) {
// TODO: Validación de índice fuera de rango
return elementos[indice];
}
}
Con esta implementación genérica, podemos crear una lista que almacena elementos de cualquier tipo sin necesidad de conversiones de tipo. Notemos que ahora utilizamos una variable de tipo T en lugar de Object. Cuando declaramos una variable del tipo ListaGenerica debemos hacerlo con un tipo de dato (clase o interfaz) específico en su parámetro que sustituirá a T, de forma que en tiempo de compilación se pueda hacer el type checking necesario.
ListaGenerica<Integer> listaEnteros = new ListaGenerica<>(10);
listaEnteros.agregar(11);
listaEnteros.agregar(21);
listaEnteros.obtener(1); // Devuelve 21 (Integer)
public class MiClaseGenerica<T> {...}
Para declarar una clase o interfaz genérica, definimos los parámetros de tipo separados por coma y encerrados entre los símbolos < y > después del nombre de la clase. También podemos usar otros nombres para las variables de tipo. Según documentación oficial:
- E - Element (used extensively by the Java Collections Framework)
- K - Key
- N - Number
- T - Type
- V - Value
- S,U,V etc. - 2nd, 3rd, 4th types
También podemos declarar métodos genéricos dentro de clases no genéricas. Para hacerlo usamos la misma sintaxis <T> antes del tipo de retorno del método. En este caso, el parámetro T sólo están en el alcance (scope) del método. Podemos usarlo para métodos estáticos, de instancia o constructores.
public class MiClaseNoGenerica {
public <K, V> void miMetodoGenerico(K clave, V valor) {
// ...
}
}
Recordemos que podemos definir más de un parámetro de tipo cuando usamos Generics.
Cuando creamos objetos de una clase genérica especificamos el tipo de datos que queremos utilizar entre los signos de mayor y menor < >. Por ejemplo:
MiClaseGenerica<Integer> objetoEntero = new MiClaseGenerica<Integer>();
MiClaseGenerica<String> objetoString = new MiClaseGenerica<>();
En este ejemplo creamos dos objetos de MiClaseGenerica, uno que almacena enteros y otro que almacena cadenas. Notemos que en la primera inicialización agregamos Integer como parámetro en el constructor, pero esto no es necesario porque Java puede inferirlo a partir de la declaración de la variable (MiClaseGenerica<Integer>). Por eso al inicializar objetoString podemos usar el operador diamante <> en la inicialización.
- Implementar una clase genérica Contenedor<T> que permita almacenar un elemento de cualquier tipo de dato.
- Agregar el siguiente comportamiento a la clase:
- agregar elemento al contenedor
- quitar el elemento del contenedor
- verificar si el contenedor está vacío
- visualizar el elemento dentro del contenedor
- comparar si el elemento dentro del contenedor es igual a otro elemento
- Generar una instancia de Contenedor y probar su funcionamiento insertando diferentes objetos, visualizando el contenido y comparando si es igual a cierto otro objeto.
A veces es necesario limitar los tipos que se pueden utilizar en una clase genérica. Esto puede hacer mediante parámetros de tipos acotados o bounded type parameters. Por ejemplo, si quisiéramos que una clase genérica sólo pueda aceptar un tipo numérico como parámetro lo haríamos así:
public class ClaseGenericaNumerica<T extends Number> {...}
De esta forma estamos acotando al tipo aceptado como parámetro a alguno que sea Number o cualquiera de sus clases derivadas. Esto aplica también para interfaces, es decir, que el extends puede funcionar para definir que sea de cierta interfaz o cualquiera de las clases que la implementa. Esto último podemos verlo con el ejemplo de aceptar cualquier tipo Comparable, aquellos que implementan esa interfaz.
public class ClaseLimitada<T extends Comparable<T>> {...}
Para el caso de métodos genéricos también es posible acotar los tipos aceptados. Se define de la misma forma en la declaración del parámetro. Por ejemplo, si deseamos implementar una operación que cuente la cantidad de elementos menores a cierto elemento de un arreglo de cualquier tipo, podríamos hacer algo así:
public static <T extends Comparable<T>> int contarMenores(T[] arreglo, T elemento) {
int total = 0;
for (T e : arreglo) {
if (e.compareTo(elemento) < 0) {
total++;
}
}
return total;
}
Lo iteresante de esto es que este método funciona para arreglos de cualquier tipo de dato, siempre y cuando implementen la interfaz Comparable y, por lo tanto, tengan definido el método compareTo que exige esa intefaz.
Es posible definir más de una cota superior al tipo de dato. Por ejemplo, si quisiéramos definir una clase genérica que acepte tipos numéricos, comparables y clonables.
public class ClaseGenericaMasLimitada<T extends Number & Comparable<T> & Clonable> {...}
Entonces T debería ser de tipo Number, Comparable<Number> y Clonable para poder ser utilizado en esta nueva clase genérica. Notemos que si alguna de las restricciones es una clase (Number en este caso), se debe colocar antes que las interfaces en la definición.
Contemplando el ejercicio previo Caja Contenedora, implementar una nueva clase ContenedorNumerico donde el parámetro de tipo debe ser Number o algún subtipo.
Es común confundirnos con la jerarquía de herencia de los tipos que pasamos como argumentos a tipos genéricos, porque podríamos pensar que, si tenemos dos objetos de un tipo genérico donde uno acepta un parámetro Number y otro acepta un Integer, entonces el segundo objeto sería subtipo del primero por la relación entre los parámetros. Esto no es correcto, ya que los tipos genéricos son invariantes.
Si desean profundizar los conceptos de Covarianza, Contravarianza e Invarianza en tipos de datos:
Number numero;
Integer entero = Integer.valueOf(3);
numero = entero; // Upcasting correcto
List<Number> numeros;
List<Integer> enteros = new ArrayList<>();
numeros = enteros; // Error de compilación: Type mismatch: cannot convert from List<Integer> to List<Number>
Si bien podemos asignar una referencia de Integer a una variable de tipo Number porque la primera es también de tipo Number, no debemos confundir que esa herencia sea trasladada al tipo paramétrico. List<Number> no tiene ninguna relación con List<Integer>.
Por otra parte, sí podemos establecer jerarquías de herencia a través de extender de otras clases genéricas o implementar interfaces genéricas. Esta relación aplica sobre los tipos paramétricos y no sobre los argumentos de tipo. Por ejemplo, en las colecciones de Java se define esa relación de herencia porque ArrayList<E> implementa List<E> y esta última extiende a Collection<E>. Entonces podemos afirmar que ArrayList<Integer> es subtipo de List<Integer>, la cual es subtipo de Collection<Integer>.
Una forma de lograr que un tipo paramétrico no sea invariante respecto a su argumento es a través del uso de comodines o wildcards (?), lo cual nos provee mayor flexibilidad pero con cierto recaudo en las operaciones que podemos hacer sobre ellos. Estos comodines se pueden utilizar en el momento de declarar un tipo paramétrico, no al momento definirlo. Entonces, en lugar de utilizar un tipo específico como argumento, podemos utilizar un comodín que básicamente significa tipo desconocido. En general, se utilizan acotados (Bounded) para describir que puede usarse cualquier tipo de dato con cierta restricción.
Un comodín que actúa como cota superrior nos permite indicar que es un tipo desconocido que puede ser de cierto tipo o cualquiera de sus subtipos, a través del cual se logra que sea covariante.
List<? extends Number> numeros = new ArrayList<Integer>();
La asignación es válida porque ahora numeros es de tipo List<? extends Number>, mientras que ArrayList<Integer> es subtipo de List<Integer> (por definición de las colecciones) y esta última es subtipo de la primera. También podríamos afirmar que List<? extends Number> es subtipo de List<?>, la cual sería una interfaz de lista que puede tener cualquier tipo de dato como elemento (Unbounded).
Esta capacidad de utilizar comodines como argumentos de tipo es poderosa para lograr esa relación de herencia en tipos paramétricos y así aprovechar el polimorfismo, pero conlleva también un riesgo. Por ejemplo, no podríamos nunca insertar elementos a la variable numeros porque no es posible saber con certeza qué objeto tiene asociado (en particular, con qué argumento de tipo se ha instanciado ArrayList).
List<? extends Number> numeros = new ArrayList<Integer>();
numeros.add(10); // Error en tiempo de compilación
El compilador nos detiene porque no tiene forma de saber de qué subtipo de Number es la lista asociada a numeros. Entonces, ¿para qué nos serviría usar wildcards acotadas superiormente? En este caso, donde estamos restringiendo con una cota superior de clase Number, es útil únicamente para leer los elementos de arreglo de forma segura (tratarlo como productor o producer).
ArrayList<? extends Number> numeros;
ArrayList<Integer> enteros = new ArrayList<Integer>();
numeros = enteros;
enteros.add(10);
// ...
for (Number numero : numeros) {
// Hacer algo con numero (es un objeto de Integer)
}
Similar al caso previo, también podemos pasar como argumento a un tipo genérico un lower bounded wildcard que indica un tipo desconocido que puede ser de un cierto tipo o cualquiera de sus supertipos, a través del cual logramos que sea contravariante.
public static void insertarNumeros(List<? super Integer> numeros, int[] otros) {
for(int n : otros) {
numeros.add(n);
}
}
Esta cota inferior (Integer) para el comodin del tipo de la lista permite entonces que el método insertarNumero sea invocado con un argumento que puede ser: List<Integer>, List<Number> o List<Object>, ya que existe una relación de herencia entre ellos gracias al comodín.
A diferencia del caso previo, en el parámetro numeros podemos únicamente escribir elementos de forma segura (tratarlo como consumidor o consumer), pero no podemos leerlos porque no sabríamos qué instancia de lista tenemos realmente. No sabríamos qué tipo de dato tiene un elemento obtenido (puede ser Integer, Number u Object). La única excepción de lectura segura sería si tratamos sus elementos siempre como Object.
Finalmente, ¿qué podríamos hacer si necesitamos tratar a un tipo genérico tanto como consumer y producer a la vez? En ese caso podríamos usar directamente el comodín sin restricción: unbounded wildcard (?). El problema de usar esta opción es que deberíamos tratar el parámetro del tipo siempre como Object.
public static void mostrarElementos(List<?> elementos) {
for (Object elemento: elementos) {
System.out.print(elemento + ", ");
}
}
El método del ejemplo funciona bien porque sirve para listas de cualquier tipo de elemento, porque la operación que se aplica sobre cada uno de ellos es Object.toString() cuando se lo muestra. Al no necesitar realizar operación especial según el tipo de dato real de cada instancia, es una muy buena opción para definir métodos con mayor flexibilidad.
Siempre dependerá de la situación o diseño que estemos construyendo, pero suele ser recomendable el uso de wildcards porque siempre tendremos mayor flexibilidad en nuestra solución. A continuación describiremos algunos consejos para el uso de correcto de comodines:
- Cuando necesitamos que una variable genérica produzca información (variable de entrada), podemos definirla con upper bounded wildcard.
- Cuando necesitamos que una variable genérica consuma información (variable de salida), podemos definirla con lower bounded wildcard.
- Cuando podemos acceder a una variable de entrada únicamente con métodos de Object, podemos definirla con unbounded wildcard.
- Si tenemos la necesidad de usar una variable de entrada/salida, entonces no utilicemos wildcard.
Recordemos que los parámetros de operaciones pueden clasificarse como:
- variable de entrada (in): provee información necesaria para la operación. Por ejemplo, la variable arreglo en contarDuplicados(List<T> arreglo).
- variable de salida (out): sirve para almacenar información durante la ejecución de la operación para luego ser utilizada en otro lugar. Por ejemplo, la variable arreglo en insertar(T elemento, List<T> arreglo).
- variable de entrada/salida: cumple ambos roles a la vez. Por ejemplo, la variable arreglo en ordenar(List<T> arreglo).
-
Generar una versión propia llamada ListaGenérica que extienda a la clase AbstractList<E>. Debe utilizarse como estructura un arreglo nativo de Java para almacenar los elementos, el cual debe crecer y reducirse a medida que se agregan o eliminan los elementos. No importa el criterio utilizado, puede copiarse ese comportamiento de la Lista No Genérica, no se busca eficiencia.
-
Completar la funcionalidad sobreescribiendo los métodos:
- public E set(int index, E element)
- public void add(int index, E element)
- public E remove(int index)
Los tipos genéricos o paramétricos ofrecen una herramienta muy útil al momento de diseñar soluciones ya que nos proveen:
- Seguridad de tipos: Nos ayudan a detectar errores de tipo en tiempo de compilación en lugar de en tiempo de ejecución.
- Reutilización de código: Podemos escribir componentes genéricos que funcionen con una amplia variedad de tipos de datos.
- Legibilidad del código: Hacen que el código sea más claro y autodocumentado al indicar explícitamente qué tipo de datos se espera.
Lectura de interés:
- The Java Tutorials. Lesson: Generics
- Effective Java 3rd, de Joshua Bloch
- Item 26: Don’t use raw types
- Item 27: Eliminate unchecked warnings
- Item 28: Prefer lists to arrays
- Item 29: Favor generic types
- Item 30: Favor generic methods
- Item 31: Use bounded wildcards to increase API flexibility