Cuando trabajamos con clases en Java, a menudo nos encontramos con la necesidad de que una clase pueda manejar distintos tipos de datos sin perder la seguridad de tipos ni la flexibilidad. Para ilustrar esto, podemos imaginar que queremos crear una clase llamada Envoltorio que sirva para envolver una instancia de otra clase, como por ejemplo un Employee.
Si definimos Envoltorio de forma tradicional, con un atributo privado de tipo Employee, el código sería algo así:
public class Envoltorio {
private Employee envuelto;
public Envoltorio(Employee envuelto) {
this.envuelto = envuelto;
}
public Employee get() {
return envuelto;
}
@Override
public String toString() {
return "Envoltorio{" + envuelto.toString() + "}";
}
}
Con esta implementación, podemos crear un Envoltorio que contenga un Employee y acceder a sus métodos sin problemas. Sin embargo, esta solución es limitada porque solo funciona para Employee. Si intentamos envolver otro tipo, como un Department, el compilador nos impedirá hacerlo, ya que el tipo esperado es exclusivamente Employee.
Una forma de intentar solucionar esto es hacer que el atributo sea de tipo Object, que es la clase base de todas las clases en Java. Así, podríamos envolver cualquier objeto:
public class Envoltorio {
private Object envuelto;
public Envoltorio(Object envuelto) {
this.envuelto = envuelto;
}
public Object get() {
return envuelto;
}
}
Esto compila y funciona para cualquier tipo, pero tiene un inconveniente importante: al obtener el objeto con get(), solo sabemos que es un Object, por lo que para acceder a métodos específicos del tipo original debemos hacer un casteo explícito. Por ejemplo:
Envoltorio envoltorio = new Envoltorio(new Department("Europa"));
Department dept = (Department) envoltorio.get();
System.out.println(dept.getLocation());
Este casteo puede provocar errores en tiempo de ejecución si nos equivocamos y tratamos de castear a un tipo incorrecto. Además, el compilador no puede ayudarnos a detectar estos errores porque el casteo desactiva la comprobación estricta de tipos.
Para resolver estos problemas, Java nos ofrece los genéricos, que nos permiten definir clases que funcionan con cualquier tipo, pero manteniendo la seguridad de tipos en tiempo de compilación. Para crear una clase genérica, simplemente añadimos un parámetro de tipo entre los símbolos < y > en la declaración de la clase. Por convención, se suele usar una letra mayúscula para nombrar el genérico, como E para envuelto:
public class Envoltorio<E> {
private E envuelto;
public Envoltorio(E envuelto) {
this.envuelto = envuelto;
}
public E get() {
return envuelto;
}
@Override
public String toString() {
return "Envoltorio{" + envuelto.toString() + "}";
}
}
Con esta definición, podemos crear un Envoltorio para cualquier tipo, y el compilador sabrá exactamente qué tipo estamos usando en cada caso. Por ejemplo:
Envoltorio<Employee> envoltorioEmpleado = new Envoltorio<>(new Employee("Foo"));
System.out.println(envoltorioEmpleado.get().getName());
Envoltorio<Department> envoltorioDepartamento = new Envoltorio<>(new Department("Europa"));
System.out.println(envoltorioDepartamento.get().getLocation());
Aquí no necesitamos hacer casteos, y el compilador nos asegura que estamos usando los tipos correctamente. Esto hace que nuestro código sea más flexible y seguro.
En definitiva, los genéricos nos permiten crear clases polimórficas que pueden trabajar con distintos tipos sin perder la comprobación de tipos en tiempo de compilación, evitando así errores comunes y mejorando la calidad del código. Solo basta con añadir el parámetro de tipo en la declaración de la clase y usarlo en los atributos y métodos donde corresponda. Así, nuestras clases serán mucho más versátiles y robustas.