Genéricos en Java

Avanzado

Desde su versión original 1.0, muchas nuevas características han sido añadidas a Java. Todos han mejorado y ampliado el alcance del lenguaje, pero uno que ha tenido un impacto especialmente profundo y de gran alcance es generics o genéricos porque sus efectos se sintieron en todo el lenguaje Java. Por ejemplo, los genéricos añadieron un elemento de sintaxis completamente nuevo y causaron cambios en muchas de las clases y métodos de la API principal.

El tema de los genéricos es bastante amplio, y algunos de ellos están lo suficientemente avanzados y lo veremos de a pocos. Sin embargo, un conocimiento básico de los genéricos es necesario para todos los programadores de Java. A primera vista, la sintaxis de los genéricos puede parecer un poco intimidante, pero no te preocupes. Los genéricos son sorprendentemente fáciles de usar. Para cuando termine este artículo, tendrá una comprensión de los conceptos clave que subyacen a los genéricos y suficiente conocimiento para usarlos efectivamente en tus propios programas.

1. Qué son Genéricos en Java

En su esencia, el término genéricos significa tipos parametrizados. Los tipos parametrizados son importantes porque le permiten crear clases, interfaces y métodos en los que el tipo de datos sobre los que operan se especifica como parámetro. Una clase, interfaz o método que funciona con un tipo de parámetro se denomina genérico, como una clase genérica o método genérico.

Una ventaja principal del código genérico es que trabajará automáticamente con el tipo de datos pasados a su parámetro de tipo. Muchos algoritmos son lógicamente los mismos, independientemente del tipo de datos a los que se apliquen. Por ejemplo, un Quicksort (algoritmo de ordenación) es el mismo si está ordenando elementos de tipo Integer, String, Object, o Hilo. Con los genéricos, puede definir un algoritmo una vez, independientemente de cualquier tipo específico de datos, y luego aplicar ese algoritmo a una amplia variedad de tipos de datos sin ningún esfuerzo adicional.

Es importante entender que Java siempre le ha dado la habilidad de crear clases, interfaces y métodos generalizados operando a través de referencias del tipo Object. Debido a que Object es la superclase de todas las demás clases, una referencia de Object puede referirse a cualquier tipo de objeto. Así, en el código pre-genérico, las clases, interfaces y métodos generalizados utilizaban referencias a objetos para operar en varios tipos de datos.

El problema era que no podían hacerlo con la seguridad del tipo porque se necesitaban conversiones para convertir explícitamente de Object al tipo real de datos sobre los que se operaba. Por lo tanto, fue posible crear accidentalmente desajustes de tipo. Los genéricos agregan el tipo de seguridad que faltaba porque hacen que estas conversiones sean automáticas e implícitas. En resumen, los genéricos amplían su capacidad de reutilizar el código y le permiten hacerlo de forma segura y confiable.

2. Un ejemplo simple de genéricos

Antes de discutir cualquier otra teoría, es mejor mirar un simple ejemplo de genéricos. El siguiente programa define dos clases. El primero es la clase genérica Gen, y el segundo es Genericos, que usa Gen.

//Una simple clase genérica.
//Aquí, T es un parámetro de tipo que será reemplazado por un tipo real
//cuando se crea un objeto de tipo Gen.

class Gen<T>{
    //T es el parámetro de tipo genérico.
    T ob; //Declara un objeto de tipo T

    //Pase al constructor una referencia a un objeto de tipo T.
    Gen(T o){
        ob=o;
    }

    T getOb(){
        return ob;
    }

    //Muestra el tipo de T
    void mostrarTipo(){
        System.out.println("El tipo de T es: "+ob.getClass().getName());
    }
}

//Demostración de clase genérica
class Genericos {
    public static void main(String[] args) {

        //Crea una referencia Gen para Integers.
        Gen<Integer> iOb;

        //Cree un objeto Gen<Integer> y asigne su referencia a iOb.
        //Observe el uso de autoboxing para encapsular el valor 28 dentro de un objeto Integer.
        iOb=new Gen<Integer>(28);

        //Muestra el tipo de dato utilizado por iOb
        iOb.mostrarTipo();

        //Obtenga el valor en iOb.
        //Fíjese que no se necesita una conversión
        int v=iOb.getOb();
        System.out.println("Valor: "+v);
        System.out.println();

        //Cree un objeto Gen para Strings.
        Gen<String> strOb=new Gen<String>("Prueba de genéricos");

        //Muestra el tipo de dato utilizado por strOb
        strOb.mostrarTipo();

        //Obtenga el valor de strOb.
        // De nuevo, note que no se necesita de conversión
        String str=strOb.getOb();
        System.out.println("Valor: "+str);
    }
}

Salida:

El tipo de T es: java.lang.Integer
Valor: 28

El tipo de T es: java.lang.String
Valor: Prueba de genéricos

2.1. Explicación del código (I)

Examinemos este programa cuidadosamente. Primero, observe cómo Gen se declara por la siguiente línea:

class Gen<T> {

Aquí, T es el nombre de un parámetro de tipo. Este nombre se usa como un marcador de posición para el tipo real que será pasado a Gen cuando se crea un objeto. Así, T se utiliza dentro de Gen siempre que se necesite el parámetro tipo. Note que T está contenido dentro de < >. Esta sintaxis puede generalizarse. Cada vez que se declara un parámetro de tipo, se especifica entre corchetes angulares (<>) . Debido a que Gen utiliza un parámetro tipo, Gen es una clase genérica.

En la declaración de Gen, no hay ningún significado especial para el nombre T. Cualquier identificador válido podría haber sido utilizado, pero T es tradicional. Además, se recomienda que los nombres de los parámetros de tipo sean de un solo carácter y mayúsculas. Otros nombres de parámetros de uso común son V y E.

Luego, T se usa para declarar un objeto llamado ob, como se muestra aquí:

T ob; // declara un objeto de tipo T

Como se explicó, T es un marcador de posición para el tipo real que se especificará cuando se cree un objeto Gen. Así, ob será un objeto del tipo pasado a T. Por ejemplo, si el tipo String es pasado a T, entonces en ese caso, ob será del tipo String.

Ahora considere el constructor de Gen:

Gen(T o){
ob=o;
}

Observe que su parámetro, o, es de tipo T. Esto significa que el tipo real de o está determinado por el tipo pasado a T cuando se crea un objeto Gen. Además, dado que tanto el parámetro o como la variable miembro ob son de tipo T, ambos serán del mismo tipo cuando se cree un objeto Gen.

El parámetro de tipo T también se puede usar para especificar el tipo de retorno de un método, como es el caso con el método getOb(), que se muestra aquí:

T getOb(){
return ob;
}

Como ob también es de tipo T, su tipo es compatible con el tipo de retorno especificado por getOb().

2.2. Explicación del código (II)

El método mostrarTipo() muestra el tipo de T. Esto lo hace llamando a getName() en el objeto Class devuelto por la llamada a getClass() en ob. No hemos usado esta característica antes, así que vamos a examinarla de cerca. La clase Object define el método getClass(). Por lo tanto, getClass() es un miembro de todos los tipos de clase. Devuelve un objeto Class que corresponde al tipo de clase del objeto al que se llama.

Class es una clase definida dentro de java.lang que encapsula información sobre una clase. La clase define varios métodos que se pueden usar para obtener información sobre una clase en tiempo de ejecución. Entre estos se encuentra el método getName(), que devuelve una representación de cadena del nombre de clase.

La clase Genericos muestra la clase Gen genérica. Primero crea una versión de Gen para enteros, como se muestra aquí:

Gen<Integer> iOb;

Mire cuidadosamente esta declaración. Primero, observe que el tipo Integer se especifica dentro de los corchetes angulares después de Gen. En este caso, Integer es un argumento de tipo que se pasa al parámetro de tipo Gen, T. Esto crea efectivamente una versión de Gen en la que se traducen todas las referencias a T en referencias a Integer. Por lo tanto, para esta declaración, ob es de tipo Integer, y el tipo de retorno de getOb() es de tipo Integer.

Antes de continuar, es necesario indicar que el compilador de Java no crea realmente versiones diferentes de Gen, o de cualquier otra clase genérica. Aunque es útil pensar en estos términos, no es lo que realmente sucede. En cambio, el compilador elimina toda la información de tipo genérico, sustituyendo las conversiones necesarias, para que el código se comporte como si se hubiera creado una versión específica de Gen. Por lo tanto, en realidad solo existe una versión de Gen que realmente existe en tu programa. El proceso de eliminación de información de tipo genérico se denomina borrado (erasure), que se explica más adelante.

2.3. Explicación del código (III)

La siguiente línea asigna a iOb una referencia a una instancia de una versión Integer de la clase Gen.

iOb = new Gen<Integer>(28);

Observe que cuando se llama al constructor Gen, también se especifica el argumento de tipo Integer. Esto se debe a que el tipo de objeto (en este caso iOb) al que se le asigna la referencia es de tipo Gen <Entero>. Por lo tanto, la referencia devuelta por new también debe ser de tipo Gen<Integer>. Si no es así, se producirá un error de tiempo de compilación. Por ejemplo, la siguiente asignación provocará un error en tiempo de compilación:

iOb = new Gen<Double>(28.0); // Error!

Como iOb es del tipo Gen<Integer>, no se puede usar para referirse a un objeto de Gen<Double>. Este tipo de control es uno de los principales beneficios de los genéricos porque asegura la seguridad del tipo.

Como dicen los comentarios en el programa, la declaración

iOb = new Gen<Integer>(28);

hace uso de autoboxing para encapsular el valor 28, que es un int, en un Integer. Esto funciona porque Gen<Integer> crea un constructor que toma un argumento Integer. Como se espera un Integer, Java colocará automáticamente 28 dentro de uno. Por supuesto, la tarea también podría haber sido escrita explícitamente, así:

iOb = new Gen<Integer>(Integer.valueOf(28));

Sin embargo, no habría ningún beneficio al usar esta versión.

El programa muestra el tipo de ob dentro de iOb, que es Integer. A continuación, el programa obtiene el valor de ob mediante el uso de la siguiente línea:

int v=iOb.getOb();

Debido a que el tipo de retorno de getOb() es T, que fue reemplazado por Integer cuando se declaró iOb, el tipo de retorno de getOb() también es Integer, que se auto-desencapsula en int cuando se asigna a v (que es un int). Por lo tanto, no es necesario convertir el tipo de retorno de getOb() en Integer.

A continuación, Genericos declara un objeto de tipo Gen<String>:

Gen<String> strOb=new Gen<String>("Prueba de genéricos");

Como el argumento tipo es String, String se sustituye por T dentro de Gen. Esto crea (conceptualmente) una versión String de Gen, como lo demuestran las líneas restantes en el programa.

3. Puntos importantes sobre Genéricos

3.1. Los genéricos funcionan solo con tipos de referencia

Al declarar una instancia de un tipo genérico, el argumento de tipo pasado al parámetro de tipo debe ser un tipo de referencia. No se puede utilizar un tipo primitivo, como int o char. Por ejemplo, con Gen, es posible pasar cualquier tipo de clase a T, pero no se puede pasar un tipo primitivo a T. Por lo tanto, la siguiente declaración es ilegal:

Gen<int> intOb = new Gen<int>(28); // Error, no puede usar el tipo primitivo

Por supuesto, no poder especificar un tipo primitivo no es una restricción seria porque puede usar los envoltorios de tipo (leer sobre wrappers) para encapsular un tipo primitivo. Además, el mecanismo de autoboxing y auto-unboxing de Java hace que el uso del wrapper de tipo sea transparente.

3.2. Los tipos genéricos difieren según sus argumentos de tipo

Un punto clave a entender sobre los tipos genéricos es que una referencia de una versión específica de un tipo genérico no es compatible con otra versión del mismo tipo genérico. Por ejemplo, asumiendo que el programa acaba de mostrarse, la siguiente línea de código está en error y no se compilará:

iOb = strOb; // Error!

Aunque tanto iOb como strOb son de tipo Gen <T>, son referencias a diferentes tipos porque sus argumentos de tipo son diferentes. Esto es parte de la forma en que los genéricos agregan seguridad de tipo y previenen errores.

4. Una clase genérica con dos parámetros de tipo

Puede declarar más de un parámetro de tipo en un tipo genérico. Para especificar dos o más parámetros de tipo, simplemente use una lista separada por comas. Por ejemplo, la siguiente clase DosGen es una variación de la clase Gen que tiene dos parámetros de tipo:

// Una clase DosGen simple con dos parámetros de tipo:
// T y V.

class DosGen<T, V>{
    // Usa dos parámetros de tipo
    T ob1; //Declara un objeto de tipo T
    V ob2; //Declara un objeto de tipo V

    //Pase al constructor una referencia a un objeto de tipo T y V.
    DosGen(T o1, V o2){
        ob1=o1;
        ob2=o2;
    }

    T getOb1(){
        return ob1;
    }

    V getOb2(){
        return ob2;
    }

    //Muestra el tipo de T y V
    void mostrarTipo(){
        System.out.println("El tipo de T es: "+ob1.getClass().getName());
        System.out.println("El tipo de V es: "+ob2.getClass().getName());
    }
}

//Demostración de clase DosGen
class Genericos {
    public static void main(String[] args) {

       DosGen<Integer,String> dosGen= new DosGen<Integer, String>(28,"Genericos");

       //Mostrar los tipos
        dosGen.mostrarTipo();

        //Obtener y mostrar los valores
        int v=dosGen.getOb1();
        System.out.println("Valor: "+v);

        String str=dosGen.getOb2();
        System.out.println("Valor: "+str);
    }
}

Salida:

El tipo de T es: java.lang.Integer
El tipo de V es: java.lang.String
Valor: 28
Valor: Genericos

Fíjese como se declara DosGen:

class DosGen<T, V> {

Especifica dos parámetros de tipo, T y V, separados por una coma. Como tiene dos parámetros de tipo, se deben pasar dos argumentos de tipo a DosGen cuando se crea un objeto, como se muestra a continuación:

DosGen<Integer,String> dosGen = 
       new DosGen<Integer, String>(28,"Genericos");

En este caso, Integer se sustituye por T, y String se sustituye por V. Aunque los dos argumentos de tipo son diferentes en este ejemplo, es posible que ambos tipos sean los mismos. Por ejemplo, la siguiente línea de código es válida:

DosGen<String, String> x = new DosGen<String, String>("A", "B");

En este caso, tanto T como V serían del tipo String. Por supuesto, si los argumentos de tipo fueran siempre los mismos, entonces dos parámetros de tipo serían innecesarios.

5. La forma general de una clase genérica

La sintaxis genérica que se muestra en los ejemplos anteriores puede generalizarse. Aquí está la sintaxis para declarar una clase genérica:

class nombre-clase<lista-parametros-tipo> { // ...

Aquí está la sintaxis completa para declarar una referencia a una clase genérica y crear una instancia genérica:

nombre-clase<lista-argumentos-tipo> nombre-var =
new nombre-clase<lista-argumentos-tipo>(lista-arg-cons);

6. Tipos Limitados

En los ejemplos anteriores, los parámetros de tipo podrían reemplazarse por cualquier tipo de clase. Esto está bien para muchos propósitos, pero a veces es útil limitar los tipos que se pueden pasar a un parámetro de tipo.

Por ejemplo, suponga que desea crear una clase genérica que almacene un valor numérico y que sea capaz de realizar diversas funciones matemáticas, como calcular el recíproco u obtener la parte fraccionaria. Además, desea utilizar la clase para calcular estas cantidades para cualquier tipo de número, incluidos integers, floats, y doubles. Por lo tanto, desea especificar el tipo de los números genéricamente, utilizando un parámetro de tipo. Para crear una clase así, puedes intentar algo como esto:

// OperaMat intenta (sin éxito) crear una clase genérica
// que puede calcular varias funciones numéricas,
// como el recíproco o parte fraccionaria, dado cualquier tipo de número.
class OperaMate <T>{
    T num;

    // Pase al constructor una referencia a un objeto numérico.
    OperaMate( T n){
        num=n;
    }

    //Devuelve el recíproco
    double reciproco(){
        return 1/num.doubleValue(); //Error!
    }

    //Devuelve parte fraccionaria
    double fraccion(){
        return num.doubleValue()-num.intValue(); //Error!
    }

   //...
}

Desafortunadamente, OperaMate no se compilará como está escrito porque ambos métodos generarán errores en tiempo de compilación.

  • Primero, examine el método reciproco(), que intenta devolver el recíproco de num. Para hacer esto, debe dividir 1 por el valor de num. El valor de num se obtiene llamando a doubleValue(), que obtiene la versión double del objeto numérico almacenado en num. Debido a que todas las clases numéricas, como Integer y Double, son subclases de Number, y Number define el método doubleValue(), este método está disponible para todas las clases wrapper numéricas.
  • El problema es que el compilador no tiene manera de saber que tiene la intención de crear objetos OperaMate utilizando únicamente tipos numéricos. Por lo tanto, cuando intenta compilar OperaMate, se informa un error que indica que el método doubleValue() es desconocido. El mismo tipo de error ocurre dos veces en fraccion(), que necesita llamar tanto a doubleValue() como a intValue(). Ambas llamadas dan como resultado mensajes de error que indican que estos métodos son desconocidos.
  • Para resolver este problema, necesita alguna manera de decirle al compilador que tiene la intención de pasar solo los tipos numéricos a T. Además, necesita alguna forma de asegurarse de que solo se pasen los tipos numéricos.

6.1. Ejemplo de Tipos Limitados (bounded types)

Para manejar tales situaciones, Java proporciona tipos limitados (bounded types). Al especificar un parámetro de tipo, puede crear un límite superior que declara la superclase de la cual se derivan todos los argumentos de tipo. Esto se logra mediante el uso de una cláusula extends al especificar el parámetro de tipo, como se muestra aquí:

<T extends superclass>

Esto especifica que T puede reemplazarse solo por superclase o subclases de superclase. Por lo tanto, la superclase define un límite superior inclusivo.

Puede usar un límite superior para corregir la clase OperaMate mostrada anteriormente al especificar Number como un límite superior, como se muestra aquí:

// OperaMat intenta (sin éxito) crear una clase genérica
// que puede calcular varias funciones numéricas,
// como el recíproco o parte fraccionaria, dado cualquier tipo de número.
class OperaMate <T extends Number>{
    // En este caso, el argumento de tipo
    // debe ser Number o una subclase en Number.
    T num;

    // Pase al constructor una referencia a un objeto numérico.
    OperaMate( T n){
        num=n;
    }

    //Devuelve el recíproco
    double reciproco(){
        return 1/num.doubleValue();
    }

    //Devuelve parte fraccionaria
    double fraccion(){
        return num.doubleValue()-num.intValue();
    }
}

//Demostrar OperaMate
class DemoTipoLimite{
    public static void main(String[] args) {
        OperaMate<Integer> iOb=
                new OperaMate<Integer>(5);

        System.out.println("El recíproco de 5 es: "+iOb.reciproco());
        System.out.println("La parte fraccionaria de 5 es: "+iOb.fraccion());
        System.out.println();

        OperaMate<Double> dOb=
                new OperaMate<Double>(15.25);

        System.out.println("El recíproco de 15.25 es: "+dOb.reciproco());
        System.out.println("La parte fraccionaria de 15.25 es: "+dOb.fraccion());

        // Esto no se compilará
        // porque String no es una subclase de Number.
        // OperaMate<String> strOb = new OperaMate<String>('Error");
    }
}

Salida:

El recíproco de 5 es: 0.2
La parte fraccionaria de 5 es: 0.0

El recíproco de 15.25 es: 0.06557377049180328
La parte fraccionaria de 15.25 es: 0.25

Observe cómo OperaMate ahora está declarado por esta línea:

class OperaMate <T extends Number>{

Debido a que el tipo T ahora está limitado por Number, el compilador de Java sabe que todos los objetos de tipo T pueden llamar a doubleValue() porque es un método declarado por Number. Esto es, en sí mismo, una gran ventaja. Sin embargo, como una ventaja adicional, el límite de T también evita que se creen objetos OperaMate no numéricos. Por ejemplo, si elimina los comentarios de la línea al final del programa y luego intenta volver a compilar, recibirá errores en tiempo de compilación porque String no es una subclase de Number.

6.2. Compatibilidad de tipo

Los tipos delimitados son especialmente útiles cuando necesita asegurarse de que un parámetro de tipo sea compatible con otro. Por ejemplo, considere la siguiente clase llamada Pareja, que almacena dos objetos que deben ser compatibles entre sí:

class Pareja<T,V extends T>{
    //Aquí, V debe ser algún tipo como T o una subclase de T
    T primero;
    V segundo;

    Pareja(T a, V b){
        primero=a;
        segundo=b;
    }
}

Observe que Pareja usa dos parámetros de tipo, T y V, y que V extiende de T. Esto significa que V será igual a T o una subclase de T. Esto asegura que los dos argumentos para el constructor de Pareja serán objetos del mismo tipo o de tipos relacionados. Por ejemplo, las siguientes construcciones son válidas:

// Esto está bien porque tanto T como V son Integer.
Pareja<Integer, Integer> x = new Pareja<Integer, Integer>(1, 2);
 // Esto está bien porque Integer es una subclase de Number.
Pareja<Number, Integer> y = new Pareja<Number, Integer>(10.4, 12);

Sin embargo, lo siguiente es inválido:

// Esto causa un error porque String no es una subclase de Number
Pareja<Number, String> z = new Pareja<Number, String>(10.4, "12");

En este caso, String no es una subclase de Number, que viola el límite especificado por Pareja.

Genéricos en Java
  • Fundamentos de Genéricos en Java

Sobre el Autor:

Hey hola! Yo soy Alex Walton y tengo el placer de compartir conocimientos hacía ti sobre el tema de Programación en Java, desde cero, Online y Gratis.

Deja una Respuesta

*

Este sitio usa Akismet para reducir el spam. Aprende cómo se procesan los datos de tus comentarios.