Arturo Parra
Arturo Parra
Ingeniero de Software
Mar 27, 2023 12 min read

Polimorfismo en Java

thumbnail for this post

El polimorfismo es uno de los conceptos fundamentales de la programación orientada a objetos y es una de las características principales de Java.

El polimorfismo permite que un objeto pueda tomar varias formas o comportarse de diferentes maneras según el contexto en el que se utilice. En Java, el polimorfismo se logra mediante el uso de clases y objetos, y se basa en dos mecanismos: sobrecarga y sobreescritura de métodos.

La sobrecarga de métodos permite definir varios métodos con el mismo nombre pero con diferentes parámetros. Java utiliza la sobrecarga de métodos para proporcionar diferentes formas de invocar un método en función de los argumentos que se le pasan.

Por otro lado, la sobreescritura de métodos se utiliza para proporcionar una implementación específica de un método en una clase hija, que reemplaza a la implementación de la clase padre. Puedes ver el artículo sobre herencia en Java para aprender más acerca de la sobreescritura de métodos.

Una de las ventajas clave del polimorfismo en Java es que permite escribir código más genérico y reutilizable. Por ejemplo, puedes definir un método que acepte un objeto de una clase padre, y este método puede ser llamado desde alguna otra parte de tu programa, pasando cualquier objeto que sea una instancia de alguna de las clases descendientes de esa clase padre. Esto significa que el mismo código puede ser utilizado con diferentes tipos de objetos, lo que hace que el código sea más flexible y escalable.

No te preocupes si esto parece complicado, en este artículo te explicaré el concepto de polimorfismo en Java más a detalle y con ejemplos sencillos y prácticos. También exploraremos cómo el polimorfismo puede ser utilizado para escribir código más efectivo y eficiente, y finalmente proporcionaremos algunas recomendaciones para utilizar correctamente este concepto en tus programas de Java.

¿Cómo se Consigue el Polimorfismo en Java?

El polimorfismo en Java se consigue aprovechando la capacidad que tienen los objetos de adoptar múltiples formas, por ejemplo, mediante el uso de parámetros polimórficos, o la utilización de la sobrecarga y/o sobreescritura de métodos.

En las siguientes subsecciones revisaremos los conceptos importantes del polimorfismo en Java.

Objetos y Variables de Referencia

Cuando trabajamos con un objeto, no trabajamos directamente con el objeto, sino a través de una variable, que a su vez contiene una referencia al objeto.

Digamos que creamos un objeto de una clase llamada MiClase:

MiClase miObjeto = new MiClase();

Con esto, Java hace dos cosas:

  1. Crea un objeto de tipo MiClase en el heap
  2. Crea una variable llamada miObjeto en el stack, que también es de tipo MiClase, y contiene una referencia al objeto del heap

Además, un objeto en Java puede ser accedido de tres formas:

  1. Con una variable de referencia del mismo tipo que la clase del objeto.
  2. Con una variable de referencia del mismo tipo que alguna superclase del objeto, dentro de su jerarquía de clases.
  3. Con una variable de referencia del mismo tipo que alguna de las interfaces que implementa la clase del objeto, o la interfaz que implementa alguna de las superclases del objeto.

En otras palabras, en Java podemos crear múltiples variables de referencia que apunten al mismo objeto, y cada una de estas variables pueden ver una “cara” diferente del objeto, siendo cada una de esas caras, todos los tipos de las superclases del objeto y las interfaces que implementa el objeto, o las interfaces que implementan sus superclases.

Por ejemplo, dado el siguiente diagrama de clases:

Diagrama de clases de la evolución humana

Podríamos decir que un objeto de tipo HomoHeidelbergensis tiene tres “caras”: la cara de HomoHeidelbergensis, la cara de HomoRhodesiensis, y la cara de HomoErectus, como explicamos enseguida.

Un objeto de tipo HomoHeidelbergensis puede ser visto como lo que es: un HomoHeidelbergensis:

HomoHeidelbergensis pancho1 = new HomoHeidelbergensis();

Pero también puede ser visto como un HomoRhodesiensis:

HomoRhodesiensis pancho2 = new HomoHeidelbergensis();

o incluso como un HomoErectus:

HomoErectus pancho3 = new HomoHeidelbergensis();

Para los 3 objetos utilizamos new HomoHeidelbergensis(); del lado derecho del signo de igualdad, por lo que en los tres casos Java crea un objeto de tipo HomoHeidelbergensis en el heap.

Sin embargo, lo que hacemos en el lado izquierdo de la igualdad es diferente para cada caso, si bien en el primer caso creamos una variable de referencia del mismo tipo que el objeto del heap: HomoHeidelbergensis pancho1, en el segundo caso la variable de referencia es de otro tipo: HomoRhodesiensis pancho2, y en el tercer caso la variable de referencia es de un tercer tipo: HomoErectus pancho3.

En los tres casos la variable de referencia apunta a un objeto de tipo HomoHeidelbergensis, pero dado el tipo de la variable de referencia, cada una tendrá acceso a una “cara” distinta del objeto: pancho1 tendrá acceso a todo lo que un objeto de tipo HomoHeidelbergensis tiene que ofrecer, además podrá acceder a ciertas propiedades y métodos públicos de los otros dos tipos; pancho2 tendrá acceso únicamente a lo que un objeto de tipo HomoRhodesiensis y HomoErectus tienen que ofrecer, pero no podrá acceder a las características y/o comportamientos de HomoHeidelbergensis; y pancho3 solo podrá acceder a lo que un HomoErectus tiene que ofrecer, pero no lo que los otros dos tipos ofrecen.

No solo los objetos pueden adoptar las “caras” de sus padres, sino también las “caras” de las interfaces que implementan ellos mismos o sus padres.

Moldeando Objetos en Java (Casting)

Imagina una interfaz llamada Figura, y una clase Circulo que implementa la interfaz Figura:

1.  public interface Figura {
2.     float calcularArea();
3.  }
4. 
5.  class Circulo implements Figura {
6.     final float PI = 3.1416f;
7.       
8.     private final float radio;
9.
10.    public Circulo(float radio) {
11.        this.radio = radio;
12.    }
13. 
14.    @Override
15.    public float calcularArea() {
16.        return PI * radio * radio;
17.    }
18. }

Podríamos convertir un objeto de tipo Circulo a un objeto de tipo Figura, porque la clase Circulo implementa la interfaz Figura, y lo hacemos como en el siguiente ejemplo:

1. Circulo circulo = new Circulo(10);
2. Figura figura = (Figura) circulo;

En la línea 1 del ejemplo anterior estamos creando un objeto de tipo Circulo, mientras que en la línea 2 convertimos el objeto circulo a un objeto de tipo Figura, que logramos mediante la expresión (Figura) circulo, a esto se le llama en inglés: casting.

Esto es posible porque la clase Circulo implementa la interfaz Figura, sin embargo, en este caso no es necesario utilizar casting explícitamente, porque Java sabe que Circulo implementa Figura, por lo que Java puede aplicar el casting implícitamente, así que podemos omitir la expresión (Figura), de hecho debemos omitir la expresión (en mi opinión), para mejorar la simplicidad del código.

El siguiente código es equivalente al anterior, pero más simple:

1. Circulo circulo = new Circulo(10);
2. Figura figura = circulo;

En el dado caso que los tipos sean incompatibles, por ejemplo si la clase Circulo no implementara la interfaz Figura, y aun asi quisiéramos aplicar casting (explícita o implícitamente), Java arrojaría un error de compilación.

También es posible convertir el objeto de tipo Figura de vuelta al tipo Circulo, pero en ese caso sí es forzoso utilizar casting explícitamente, es decir, anteponer la expresión (Circulo) al nombre del objeto.

Veamos cómo leer la “cara” original del objeto:

Circulo miCirculo = (Circulo) figura;

El decir que un objeto se convierte de un tipo a otro no es completamente acertado, porque cuando se crea un objeto por primera vez, Java almacena el objeto con su tipo original en el heap, y lo que en realidad sucede cuando utilizamos casting, es que estamos usando otra “cara” del objeto, a través de la variable de referencia, después, cuando regresamos el objeto a su “cara original”, Java simplemente toma el objeto que tiene almacenado en el heap, donde siempre ha existido desde su creación.

En Java es obligatorio utilizar casting explícito siempre que quieras pasar de una superclase a una clase descendiente, de lo contrario, Java arroja un error de compilación.

Ahora imagina que tenemos otra clase que también implementa la interfaz Figura:

public class Cuadrado implements Figura {
    private final float lado;
    
    public Cuadrado(float lado) {
        this.lado = lado;
    }

    @Override
    public float calcularArea() {
        return this.lado * this.lado;
    }
}

Si creamos un objeto originalmente como Circulo, y luego lo leemos con una variable de referencia de tipo Figura, para después intentar leerlo con una variable de referencia de tipo Cuadrado, Java no se “quejará”, al menos no en tiempo de compilación, porque en tiempo de compilación Java no sabe cuál es el tipo original que tendrá el objeto en el heap. ¡Pero sí que se quejará en tiempo de ejecución!

Ilustremos lo anterior con un ejemplo:

1. Figura figura = new Circulo(5);
2. Cuadrado cuadrado = (Cuadrado) figura; // Sin quejas en la compilación
3. System.out.println( cuadrado.calcularArea() );

El código anterior compila sin ningún problema, pero en tiempo de ejecución, aunque la línea 1 corre sin errores, la línea 2 arrojará una excepción, en concreto una excepción de tipo ClassCastException, y el programa ni siquiera llega a la línea 3.

Para proteger nuestros programas de Java contra este tipo de errores cuando trabajamos con polimorfismo, podemos hacer uso de la palabra reservada instanceof, de la siguiente forma:

if (figura instanceof Cuadrado) {
    Cuadrado cuadrado = (Cuadrado) figura;
} else {
    // Hacer otra cosa
}

De esta forma, antes de aplicar el casting revisamos si el objeto en el heap al que apunta la variable de referencia figura es de tipo Cuadrado, si resulta que no es de tipo Cuadrado, el casting no se intenta y no habrá excepciones durante la ejecución del programa.

Parámetros Polimórficos

Una forma de aprovechar el polimorfismo en Java es con los parámetros polimórficos. Esto se refiere a la posibilidad que tenemos de enviar objetos como argumentos en una invocación a un método que recibe parámetros de un supertipo de la clase del objeto que estamos enviando, o del tipo de una interfaz que implementa la clase o superclases del objeto que usamos como argumento.

Esto se logra gracias a que las referencias a objetos en Java pueden adquirir diferentes caras del objeto, como discutimos anteriormente en este artículo.

Considera el siguiente código:

1. public class Listas {
2.
3.   public static void imprimirValores(List<String> lista) {
4.      for (String valor : lista) {
5.         System.out.println(valor);
6.      }
7.   }
8.
9.   public static void main(String[] args) {
10.      var nombres = new ArrayList<String>();
11.      nombres.add("Arturo");
12.      nombres.add("Orlando");
13.      nombres.add("Abel");
14.      
15.      imprimirValores(nombres);
16.
17.      var apellidos = new Vector<String>();
18.      apellidos.add("Parra");
19.      apellidos.add("Valdez");
20.      apellidos.add("Fresnillo");
21.
22.      imprimirValores(apellidos);
23.   }
24. }

El método que se define en la línea 3: imprimirValores, recibe un parámetro de tipo List<String>, pero como puedes ver en la línea 10 se crea una referencia a un objeto de tipo ArrayList<String>, que después se envía al método imprimirValores. Podemos hacer tal cosa porque Java aplicará casting implícito de ArrayList (El tipo del objeto enviado) a List (El tipo aceptado por el método), sabiendo que la clase ArrayList implementa la interfaz List.

Luego podemos reutilizar el mismo método al enviar un objeto de tipo Vector, definido en la línea 17 y luego enviado como argumento en la línea 22. Aquí Java también realiza un casting implícito porque, al igual que la clase ArrayList, la clase Vector también implementa la interfaz List.

La forma en la que definimos un objeto de tipo ArrayList<String> en la línea 10:

var nombres = new ArrayList<String>();

es equivalente a:

ArrayList<String> nombres = new ArrayList<String>();

Esto es porque estamos aprovechando la inferencia de tipos en Java. Hacemos algo similar en la línea 17, con la referencia appellidos.

Sobrecarga de Métodos y el Polimorfismo en Java

En Java podemos tener varios métodos con el mismo nombre dentro de una clase, siempre y cuando el número y/o tipo de los parámetros sea distinto para cada método. A esto se le conoce como sobrecarga de métodos.

La sobrecarga de métodos es una manera de usar el polimorfismo en Java, cuando invocas un método sobrecargado, Java sabe a cuál método te refieres basado en el número y/o tipo de parámetros que estás pasando:

public class Saludador {
   public void saludar() {
      System.out.println("¡Hola!");
   }
   
   public void saludar(String nombre) {
      System.out.println("¡Hola, " + nombre + "!");
   }
   
   public static void main(String[] args) {
      var saludador = new Saludador();
      
      saludador.saludar(); // Imprime: "¡Hola!"
      saludador.saludar("Arturo Parra"); // Imprime: "¡Hola, Arturo Parra!"
   }
}

En este ejemplo, la clase Saludador tiene dos métodos llamados saludar que se sobrecargan. El primer método no tiene parámetros y simplemente imprime “¡Hola!” en la consola. El segundo método tiene un parámetro de tipo String llamado nombre e imprime “¡Hola, ” seguido del nombre recibido en el parámetro. En el método main se crea un objeto de la clase Saludador y se invocan los dos métodos.

En ambos casos Java sabe cuál método invocar. En el primer caso invoca al método sin parámetros porque, ¡pues no pasamos ningún parámetro!, y en el segundo caso invoca al método con el parámetro nombre porque, ¡pues pasamos un parámetro de tipo String!

Podemos decir que estamos aplicando polimorfismo porque es como si el método saludar tuviera dos “formas”, una sin parámetros y otra con un parámetro de tipo String.

Sobrescritura de Métodos y el Polimorfismo en Java

Otra forma en la que podemos implementar el polimorfismo en Java es a través de la sobrescritura de métodos, puedes leer el artículo sobre Herencia en Java para entender más a detalle la sobrescritura de métodos en Java.

En el siguiente ejemplo sobrescribimos el método saludar:

1.  class Saludador {
2.    public void saludar() {
3.        System.out.println("Hola");
4.    }
5.  }

6.  class SaludadorMejorado extends Saludador {
7.
8.    @Override
9.    public void saludar() {
10.        System.out.println("¡Hola, Mundo!");
11.   }
12. }
13.
14. public class Sobrescritura {
15.
16.    public static void decirHola(Saludador saludador) {
17.        saludador.saludar();
18.    }
19.
20.    public static void main(String[] args) {
21.
22.        var saludador = new Saludador();
23.        decirHola(saludador);
24.
25.        var saludadorMejorado = new SaludadorMejorado();
26.        decirHola(saludadorMejorado);
27.
28.    }
29. }

En la línea 2, la clase Saludador define el método saludar() que imprime “Hola” en la consola, luego en la línea 9, la clase SaludadorMejorado sobrescribe el método saludar() que imprime “¡Hola, Mundo!” en la consola.

En la línea 16 implemento un método que recibe como parámetro un objeto de tipo Saludador, que lo único que hace es invocar el método saludar() del objeto recibido. Como puedes ver en las líneas 23 y 26, estamos mandando llamar el método decirHola(...) con un objeto de tipo Saludador y SaludadorMejorado respectivamente.

Como te podrás imaginar, la línea 23 resultará con el mensaje “Hola” en la consola, mientras que la línea 26 resultará con el mensaje “¡Hola, Mundo!”, de esta forma estamos reutilizando el método decirHola de manera polimórfica, porque estamos enviando un objeto distinto en cada llamada.