Java: Entendiendo Polimorfismo
Java soporta polimorfismo, la propiedad de que un objeto adopte diferentes formas. Más precisamente, se puede acceder a un objeto Java utilizando una referencia del mismo tipo que un objeto, una referencia que es la superclase del objeto o una referencia que define una interfaz que el objeto implementa, ya sea directamente o a través de una superclase.
Además, no es necesaria una conversión si el objeto se convierte a un supertipo o a la interfaz del objeto.
Para este artículo, debe comprender lo siguiente:
- Una interfaz puede definir métodos abstractos;
- Una clase puede implementar cualquier cantidad de interfaces;
- Una clase implementa una interfaz anulando métodos abstractos heredados;
- Un objeto que implementa una interfaz se puede asignar como referencia a esa interfaz.
Ilustremos esta propiedad del polimorfismo con el siguiente ejemplo:
Este código compila e imprime el siguiente output:
Lo más importante a tener en cuenta sobre este ejemplo es que solo se crea y se hace referencia a un objeto, Lemur. El polimorfismo permite transferir o pasar una instancia de Lemur a un método utilizando uno de sus supertipos, como Primate o HasTail.
Una vez que al objeto se le ha asignado un nuevo tipo de referencia, solo los métodos y variables estarán disponibles para el nuevo tipo de referencia y se podrán invocar en el objeto sin una conversión explícita. Por ejemplo, los siguientes fragmentos de código no se compilarán:
En este ejemplo, la referencia HasTail tiene acceso directo sólo a los métodos definidos con la interfaz HasTail; por lo tanto, no sabe que la variable edad es parte del objeto. Asimismo, la referencia primate tiene acceso solo a los métodos definidos en la clase Primate y no tiene acceso directo al método isTailStriped().
Objeto vs. Referencia
En Java, se accede a todos los objetos por referencia, por lo que, como desarrollador, nunca se tiene acceso directo al objeto en sí. Sin embargo, conceptualmente se debe considerar el objeto como una entidad que existe en la memoria, asignada en el entorno de ejecución de Java. Sin considerar el tipo de referencia del objeto que tienes en la memoria, el objeto en sí no cambia. Por ejemplo, todos los objetos heredan de java.lang.Object y todos pueden ser atribuidos a java.lang.Object, como se muestra aquí:
Aunque el objeto Lemur ha sido asignado como referencia con un tipo diferente, el objeto en sí no ha cambiado y continúa existiendo como un objeto Lemur en la memoria. Lo que ha cambiado, entonces, es nuestra capacidad de acceder a métodos dentro de la clase Lemur con la referencia lemurAsObject. Sin un cast explícito de regreso a Lemur, ya no tenemos acceso a las propiedades del objeto.
Podemos resumir este principio con las dos reglas siguientes:
- El tipo de objeto determina qué propiedades existen dentro del objeto en la memoria;
- El tipo de referencia de objeto determina qué métodos y variables son accesibles para el programa Java.
Por lo tanto, un cambio exitoso en la referencia de un objeto a un nuevo tipo de referencia puede brindarle acceso a las nuevas propiedades del objeto, pero recuerde, esas propiedades existían antes de que ocurriera el cambio de referencia.
Dependiendo del tipo de referencia, es posible que solo tengamos acceso a determinados métodos. Por ejemplo, la referencia hasTail tiene acceso al isTailStriped() pero no tiene acceso a la variable age, definida en la clase Lemur.
Casting de Objetos (Objects)
En el ejemplo anterior, creamos una única instancia de un objeto Lemur y accedemos a ella a través de referencias de interfaz y superclase. Sin embargo, una vez que cambiamos el tipo de referencia, perdemos acceso a miembros más específicos definidos en la subclase que todavía existen dentro del objeto. Podemos recuperar esas referencias volviendo a convertir el objeto a la subclase específica de la que procede.
En este ejemplo, primero creamos un objeto Lemur y lo convertimos implícitamente a una referencia de Primate. Como Lemur es una subclase de Primate, esto se puede hacer sin un operador de transmisión. A continuación, intentamos convertir la referencia de Primate nuevamente en una referencia de Lemur o lémur, lemur2, sin una conversión explícita.
El resultado es que el código no se compilará. En el segundo ejemplo, sin embargo, lanzamos explícitamente a una subclase del objeto Primate y obtenemos acceso a todos los métodos y campos disponibles de la clase Lemur.
Cuando haces casting de objetos, no necesitas un operador de conversión si la referencia actual es un subtipo del tipo de destino. Esto se conoce como conversión implícita o conversión de tipos. Alternativamente, si la referencia actual no es un subtipo del tipo de destino, entonces deberá convertirla explícitamente a un tipo compatible. Si el objeto subyacente no es compatible con el tipo, se generará una ClassCastException en tiempo de ejecución.
En resumen, estos conceptos se pueden explicar en las siguientes reglas:
- Hacer cast de una referencia de un subtipo a un supertipo no requiere una conversión explícita;
- Hacer un cast de una referencia de un supertipo a un subtipo requiere una conversión explícita;
- El compilador no permite la conversión a una clase no relacionada;
- En runtime, un cast inválido de una referencia para un tipo no relacionado resulta en una ClassCastException siendo lanzada.
En este exemplo, las clases Fish y Bird no están relacionados a través de ninguna jerarquía de clases que el compilador conozca; por lo tanto, el código no se compilará. Si bien ambos extienden Object implícitamente, se consideran tipos no relacionados ya que no pueden ser subtipos entre sí.
Aunque dos clases comparten una jerarquía relacionada, esto no significa que una instancia de una pueda convertirse automáticamente en otra. Veamos un ejemplo:
Este código crea una instancia de Rodent y entonces intenta hacer el cast de él para una subclase de Rodent, Capybara. Sin embargo, este código no se compilará, sino que arrojará una ClassCastException. Es importante tener en cuenta en este ejemplo que el objeto creado (Rodent) no hereda de la clase Capybara bajo ninguna circunstancia.
El operador instanceof
El operador instanceof se puede utilizar para comprobar si un objeto pertenece a una clase o interfaz particular y evitar ClassCastExceptions en runtime. A diferencia del código anterior, el siguiente código no arroja un exception y ejecuta la conversión solo si el operador instanceof retorna true:
Así como el compilador no te permite hacer el casting de un objeto a tipos no relacionados, tampoco permite utilizar el instanceof con tipos no relacionados. Podemos demostrar esto con nuestras clases no relacionadas: Bird y Fish:
En este fragmento de código, ni el operador instanceof ni tampoco la operación de cast explícito compilan.
Polimorfismo y Overriding de Método
En Java, el polimorfismo establece que cuando se anula un método, se anulan todas las llamadas al mismo, incluso aquellas definidas en la clase principal. Como ejemplo, ¿qué opinas de los resultados del siguiente fragmento de código?
En este exemplo, el objeto siendo operado en memoria es el EmperorPenguin. El método getHeight se anula en la subclase, lo que significa que todas las llamadas a ese método se anularán en runtime. A pesar de que el método printInfo() es definido en la clase Penguin, llamando el getHeight en el objeto, llama a su vez al método asociado con el objeto preciso, en memoria, no la referencia corriente del tipo donde es llamado. Lo mismo aplica usando la referencia this, la cual es opcional en este ejemplo: ella no llama a la clase padre porque se sustituyó el método.
La faceta del polimorfismo que reemplaza los métodos mediante anulación es una de las propiedades más importantes de Java. Permite crear estructuras de herencia complejas, con subclases que tienen sus propias implementaciones personalizadas de métodos anulados. Significan que no es necesario actualizar la clase principal para utilizar la anulación de métodos o personalizados. Si el método se anula correctamente, la versión anulada se utilizará dondequiera que se llame. Recuerda, puedes optar por limitar el comportamiento polimórfico declarando métodos con final, lo que evita que una subclase los anule.
Sobreescritura vs. Miembros escondidos (Hiding)
Mientras que la anulación de métodos reemplaza el método en todos los lugares donde se llama, el método estático (static) y la variable escondida (hiding) no se reemplazan. Estrictamente hablando, los métodos ocultos no son una forma de polimorfismo ya que los métodos y las variables conservan sus propiedades individuales. A diferencia de la anulación de métodos, los miembros ocultos son bastante sensibles a los tipos de referencia y la ubicación donde se utilizan los miembros. Echemos un vistazo a este ejemplo:
El ejemplo CrestedPenguin es prácticamente idéntico al de nuestro ejemplo anterior, EmperorPenguin. Sin embargo, como probablemente ya habrás adivinado, imprime 3 en lugar de 8. El método getHeight() es estático y por lo tanto está oculto (hidden), no sobreescrito. El resultado es que llamando a getHeight() en CrestedPeguin devuelve un valor diferente que llamarlo en Penguin, incluso si el objeto subyacente es el mismo. En contraste con un método anulado, donde devuelve el mismo valor para un objeto sin considerar qué clase se está invocando.
De hecho, el compilador le avisará cuando acceda a miembros estáticos de forma no estática. En este caso, esta referencia no tendrá ningún impacto en el resultado del programa.
Además de la ubicación, el tipo de referencia también puede determinar el valor que recupera cuando trabaja con miembros ocultos (hidden). Veamos un ejemplo más complejo:
El resultado de este programa es el siguiente:
Recuerda: en este ejemplo, solo un objeto de tipo Kangaroo es creado y almacenado en memoria. Dado que los métodos estáticos pueden simplemente ocultarse (hidden), no sobreescritos, Java usa el tipo de referencia para determinar cuál versión de isBiped() deberá ser llamada, resultando que joey.isBiped() arroje true y moey.isBiped() sea false.
De la misma forma, la variable está oculta (hidden), no sobrescrita, luego, el tipo de referencia se utiliza para determinar qué valor devolver como output. Esto resulta en joey.age dando 6 y moey.age 2.
Espero que este artículo haya sido útil para comprender cómo funciona el polimorfismo.
¡Hasta la próxima!
Listopro Community da la bienvenida a todas las razas, etnias, nacionalidades, credos, géneros, orientaciones, puntos de vista e ideologías, siempre y cuando promuevan la diversidad, la equidad, la inclusión y el crecimiento profesional de los profesionales en tecnología.