Polimorfismo en Java

Polimorfismo en Java

1. Introducción

Se requiere que todos los lenguajes de programación orientados a objetos (OOP) exhiban cuatro características básicas: abstracción, encapsulación, herencia y polimorfismo.

En este artículo, cubrimos dos tipos fundamentales de polimorfismo: el polimorfismo estático o de tiempo de compilación y el polimorfismo dinámico o de tiempo de ejecución. El polimorfismo estático se aplica durante la compilación, mientras que el polimorfismo dinámico se realiza en tiempo de ejecución.

2. Polimorfismo Estático

Según Wikipedia, el polimorfismo estático es una imitación del polimorfismo que se resuelve en tiempo de compilación y, por lo tanto, elimina la necesidad de búsquedas en tablas virtuales en tiempo de ejecución.

Por ejemplo, nuestra clase FicheroTexto en una aplicación de gestión de archivos puede tener tres métodos con la misma firma que el método leer():

public class FicheroTexto extends FicheroGenerico {
    //...

    public String leer() {
        return this.getContent()
          .toString();
    }

    public String leer(int limite) {
        return this.getContent()
          .toString()
          .substring(0, limite);
    }

    public String leer(int inicio, int fin) {
        return this.getContent()
          .toString()
          .substring(inicio, fin);
    }
}

Durante la compilación del código, el compilador verifica que todas las invocaciones del método leer correspondan al menos a uno de los tres métodos definidos anteriormente.

3. Polimorfismo Dinámico

Con el polimorfismo dinámico, la Java Virtual Machine (JVM) maneja la detección del método apropiado para ejecutar cuando una subclase se asigna a su forma principal. Esto es necesario porque la subclase puede anular algunos o todos los métodos definidos en la clase principal.

En una aplicación hipotética de gestión de archivos, definamos la clase principal para todos los archivos llamada FicheroGenerico:

public class FicheroGenerico {
    private String nombre;

    //...

    public String obtenerInformacion() {
        return "Implementación de Fichero Genérico";
    }
}

También podemos implementar una clase FicheroImagen que extiende FicheroGenerico pero anula el método obtenerInformacion() y agrega más información:

public class FicheroImagen extends FicheroGenerico {
    private int altura;
    private int ancho;

    //... métodos getter y setter
    
    public String obtenerInformacion() {
        return "Implementación de Fichero de Imagen";
    }
}

Cuando creamos una instancia de FicheroImagen y la asignamos a una clase FicheroGenerico, se realiza una conversión implícita. Sin embargo, la JVM mantiene una referencia a la forma real de FicheroImagen.

La construcción anterior es análoga a la anulación de métodos. Podemos confirmarlo invocando el método obtenerInformacion() de la siguiente manera:

public static void main(String[] args) {
    FicheroGenerico ficheroGenerico = new FicheroImagen("EjemploFicheroImagen", 200, 100, 
      new BufferedImage(100, 200, BufferedImage.TYPE_INT_RGB)
      .toString()
      .getBytes(), "v1.0.0");
    logger.info("Información del Fichero: \n" + ficheroGenerico.obtenerInformacion());
}

Como era de esperar, ficheroGenerico.obtenerInformacion() activa el método obtenerInformacion() de la clase FicheroImagen, como se ve en la salida a continuación:

Información del Fichero: 
Implementación de Fichero de Imagen

4. Otras Características Polimórficas en Java

Además de estos dos tipos principales de polimorfismo en Java, existen otras características en el lenguaje de programación Java que exhiben polimorfismo. Discutamos algunas de estas características.

4.1. Coerción

La coerción polimórfica trata sobre la conversión implícita de tipo realizada por el compilador para evitar errores de tipo. Un ejemplo típico se ve en la concatenación de un entero y una cadena:

String cadena = "cadena" + 2;

4.2. Sobrecarga de Operadores

La sobrecarga de operadores o métodos se refiere a una característica polimórfica en la que un mismo símbolo u operador tiene diferentes significados (formas) según el contexto.

Por ejemplo, el símbolo de suma (+) se puede usar tanto para la adición matemática como para la concatenación de Strings. En ambos casos, solo el contexto (es decir, los tipos de argumentos) determina la interpretación del símbolo:

String cadena = "2" + 2;
int suma = 2 + 2;
System.out.printf(" cadena = %s\n suma = %d\n", cadena, suma);

Salida:

cadena = 22
suma = 4

4.3. Parámetros Polimórficos

El polimorfismo paramétrico permite que el nombre de un parámetro o método en una clase se asocie con diferentes tipos. Tenemos un ejemplo típico a continuación, donde definimos contenido como una String y luego como un Integer:

public class FicheroTexto extends FicheroGenerico {
    private String contenido;
    
    public String establecerDelimitadorDeContenido() {
        int contenido = 100;
        this.contenido = this.contenido + contenido;
    }
}

También es importante tener en cuenta que la declaración de parámetros polimórficos puede llevar a un problema conocido como "variable hiding" (ocultación de variables), donde la declaración local de un parámetro siempre anula la declaración global de otro parámetro con el mismo nombre.

Para resolver este problema, a menudo es aconsejable utilizar referencias globales, como la palabra clave this, para señalar variables globales dentro de un contexto local.

4.4. Subtipos Polimórficos

El subtipo polimórfico permite asignar varios subtipos a un tipo y esperar que todas las invocaciones en el tipo activen las definiciones disponibles en el subtipo.

Por ejemplo, si tenemos una colección de FicheroGenerico y llamamos al método obtenerInformacion() en cada uno de ellos, podemos esperar que la salida sea diferente según el subtipo del que se derivó cada elemento en la colección:

FicheroGenerico [] ficheros = {new FicheroImagen("EjemploFicheroImagen", 200, 100, 
  new BufferedImage(100, 200, BufferedImage.TYPE_INT_RGB).toString() 
  .getBytes(), "v1.0.0"), new FicheroTexto("EjemploFicheroTexto", 
  "Este es un contenido de texto de ejemplo", "v1.0.0")};
 
for (int i = 0; i < ficheros.length; i++) {
    ficheros[i].obtenerInformacion();
}

El polimorfismo de subtipo se hace posible mediante una combinación de conversión ascendente y enlace tardío. La conversión ascendente implica la conversión de la jerarquía de herencia de un supertipo a un subtipo:

FicheroImagen ficheroImagen = new FicheroImagen();
FicheroGenerico fichero = ficheroImagen;

El efecto resultante de lo anterior es que los métodos específicos de FicheroImagen no se pueden invocar en el nuevo FicheroGenerico convertido hacia arriba. Sin embargo, los métodos en el subtipo anulan los métodos similares definidos en el supertipo.

Para resolver el problema de no poder invocar métodos específicos del subtipo al convertir hacia arriba a un supertipo, podemos hacer una conversión hacia abajo de la herencia de un supertipo a un subtipo. Esto se hace mediante:

FicheroImagen ficheroImagen = (FicheroImagen) fichero;

La estrategia de enlace tardío ayuda al compilador a resolver cuál método activar después de la conversión hacia arriba (upcasting). En el caso de ficheroImagen.obtenerInformacion frente a fichero.obtenerInformacion en el ejemplo anterior, el compilador mantiene una referencia al método obtenerInformacion de FicheroImagen.

5. Problemas con el Polimorfismo

Echemos un vistazo a algunas ambigüedades en el polimorfismo que podrían llevar a errores en tiempo de ejecución si no se verifican adecuadamente.

5.1. Identificación de Tipo Durante la Conversión Hacia Abajo

Recuerda que anteriormente perdimos el acceso a algunos métodos específicos del subtipo después de realizar una conversión hacia arriba. Aunque pudimos resolver esto con una conversión hacia abajo, esto no garantiza una verificación real del tipo.

Por ejemplo, si realizamos una conversión hacia arriba y luego una conversión hacia abajo:

FicheroGenerico fichero = new FicheroGenerico();
FicheroImagen ficheroImagen = (FicheroImagen) fichero;
System.out.println(ficheroImagen.getAltura());

Notamos que el compilador permite una conversión hacia abajo de un FicheroGenerico a un FicheroImagen, incluso cuando la clase en realidad es un FicheroGenerico y no un FicheroImagen.

En consecuencia, si intentamos invocar el método getAltura() en la clase ficheroImagen, obtenemos una excepción de ClassCastException ya que FicheroGenerico no define el método getAltura:

Excepción en el subproceso "main" java.lang.ClassCastException:
FicheroGenerico no se puede convertir en FicheroImagen

Para resolver este problema, la JVM realiza una verificación de información de tipo en tiempo de ejecución (siglas en inglés RTTI). También podemos intentar una identificación explícita del tipo utilizando la palabra clave instanceof de la siguiente manera:

FicheroImagen ficheroImagen;
if (fichero instanceof FicheroImagen) {
    ficheroImagen = (FicheroImagen) fichero;
}

Lo anterior ayuda a evitar una excepción de ClassCastException en tiempo de ejecución. Otra opción que se puede usar es envolver la conversión dentro de un bloque try-catch y capturar la excepción de ClassCastException.

Es importante tener en cuenta que la verificación de RTTI es costosa debido al tiempo y los recursos necesarios para verificar eficazmente que un tipo sea correcto. Además, el uso frecuente de la palabra clave instanceof generalmente implica un diseño deficiente.

5.2. Problema de la Clase Base Frágil

Según Wikipedia, las clases base o superclases se consideran frágiles si las modificaciones aparentemente seguras en una clase base pueden hacer que las clases derivadas funcionen incorrectamente.

Consideremos una declaración de una superclase llamada FicheroGenerico y su subclase FicheroTexto:

public class FicheroGenerico {
    private String contenido;

    void escribirContenido(String contenido) {
        this.contenido = contenido;
    }
    void toString(String str) {
        str.toString();
    }
}

public class FicheroTexto extends FicheroGenerico {
    @Override
    void escribirContenido(String contenido) {
        toString(contenido);
    }
}

Cuando modificamos la clase FicheroGenerico de la siguiente manera:

public class FicheroGenerico {
    //...

    void toString(String str) {
        escribirContenido(str);
    }
}

Observamos que la modificación anterior deja a FicheroTexto en una recursión infinita en el método escribirContenido(), lo que eventualmente provoca un desbordamiento de la pila.

Para abordar un problema de clase base frágil, podemos usar la palabra clave final para evitar que las subclases anulen el método escribirContenido(). La documentación adecuada también puede ayudar. Y, por último, la composición generalmente debe preferirse sobre la herencia.

6. Conclusión

En este artículo, discutimos el concepto fundamental del polimorfismo, centrándonos en ventajas y desventajas.