Revela la magia detrás del polimorfismo de subtipo

La palabra polimorfismo proviene del griego y significa "muchas formas". La mayoría de los desarrolladores de Java asocian el término con la capacidad de un objeto para ejecutar mágicamente el comportamiento correcto del método en los puntos apropiados de un programa. Sin embargo, esa visión orientada a la implementación conduce a imágenes de hechicería, en lugar de una comprensión de los conceptos fundamentales.

El polimorfismo en Java es invariablemente polimorfismo de subtipo. Examinar de cerca los mecanismos que generan esa variedad de comportamiento polimórfico requiere que descartemos nuestras preocupaciones de implementación habituales y pensemos en términos de tipo. Este artículo investiga una perspectiva de objetos orientada a tipos, y cómo esa perspectiva separa qué comportamiento puede expresar un objeto de cómo el objeto realmente expresa ese comportamiento. Al liberar nuestro concepto de polimorfismo de la jerarquía de implementación, también descubrimos cómo las interfaces Java facilitan el comportamiento polimórfico entre grupos de objetos que no comparten ningún código de implementación.

Quattro polymorphi

El polimorfismo es un término amplio orientado a objetos. Aunque generalmente equiparamos el concepto general con la variedad de subtipos, en realidad hay cuatro tipos diferentes de polimorfismo. Antes de examinar el polimorfismo de subtipo en detalle, la siguiente sección presenta una descripción general del polimorfismo en lenguajes orientados a objetos.

Luca Cardelli y Peter Wegner, autores de "Sobre la comprensión de los tipos, la abstracción de datos y el polimorfismo" (ver Recursos para el enlace al artículo) dividen el polimorfismo en dos categorías principales, ad hoc y universal, y cuatro variedades: coerción, sobrecarga, paramétrico e inclusión. La estructura de clasificación es:

| - coerción | - ad hoc - | | - polimorfismo de sobrecarga - | | - paramétrico | - universal - | | - inclusión

En ese esquema general, el polimorfismo representa la capacidad de una entidad para tener múltiples formas. El polimorfismo universal se refiere a una uniformidad de estructura de tipos, en la que el polimorfismo actúa sobre un número infinito de tipos que tienen una característica común. El polimorfismo ad hoc menos estructurado actúa sobre un número finito de tipos posiblemente no relacionados. Las cuatro variedades pueden describirse como:

  • Coerción: una sola abstracción sirve a varios tipos a través de la conversión de tipos implícita
  • Sobrecarga: un solo identificador denota varias abstracciones
  • Paramétrico: una abstracción opera uniformemente en diferentes tipos
  • Inclusión: una abstracción opera a través de una relación de inclusión

Discutiré brevemente cada variedad antes de pasar específicamente al polimorfismo de subtipo.

Coerción

La coerción representa la conversión implícita del tipo de parámetro al tipo esperado por un método o un operador, evitando así errores de tipo. Para las siguientes expresiones, el compilador debe determinar si +existe un operador binario apropiado para los tipos de operandos:

 2,0 + 2,0 2,0 + 2 2,0 + "2" 

La primera expresión agrega dos doubleoperandos; el lenguaje Java define específicamente dicho operador.

Sin embargo, la segunda expresión agrega una doubley una int; Java no define un operador que acepte esos tipos de operandos. Afortunadamente, el compilador convierte implícitamente el segundo operando doubley usa el operador definido para dos doubleoperandos. Eso es tremendamente conveniente para el desarrollador; sin la conversión implícita, se produciría un error en tiempo de compilación o el programador tendría que convertir explícitamente el intto double.

La tercera expresión agrega ay doublea String. Una vez más, el lenguaje Java no define tal operador. Entonces, el compilador convierte el doubleoperando en a String, y el operador más realiza la concatenación de cadenas.

La coerción también ocurre en la invocación del método. Supongamos que la clase Derivedextiende la clase Basey la clase Ctiene un método con firma m(Base). Para la invocación del método en el código siguiente, el compilador convierte implícitamente la derivedvariable de referencia, que tiene tipo Derived, al Basetipo prescrito por la firma del método. Esa conversión implícita permite que el m(Base)código de implementación del método use solo las operaciones de tipo definidas por Base:

C c = nuevo C (); Derivado derivado = nuevo Derivado (); cm (derivado);

Una vez más, la coerción implícita durante la invocación del método evita una conversión de tipos engorrosa o un error innecesario en tiempo de compilación. Por supuesto, el compilador aún verifica que todas las conversiones de tipos se ajusten a la jerarquía de tipos definida.

Sobrecarga

La sobrecarga permite el uso del mismo nombre de operador o método para denotar significados de programas múltiples y distintos. El +operador usado en la sección anterior exhibía dos formas: una para agregar doubleoperandos, otra para concatear Stringobjetos. Existen otras formas para sumar dos enteros, dos largos, etc. Llamamos al operador sobrecargado y confiamos en el compilador para seleccionar la funcionalidad apropiada según el contexto del programa. Como se señaló anteriormente, si es necesario, el compilador convierte implícitamente los tipos de operandos para que coincidan con la firma exacta del operador. Aunque Java especifica ciertos operadores sobrecargados, no admite la sobrecarga de operadores definida por el usuario.

Java permite la sobrecarga de nombres de métodos definida por el usuario. Una clase puede poseer varios métodos con el mismo nombre, siempre que las firmas del método sean distintas. Eso significa que el número de parámetros debe diferir o al menos una posición de parámetro debe tener un tipo diferente. Las firmas únicas permiten al compilador distinguir entre métodos que tienen el mismo nombre. El compilador manipula los nombres de los métodos usando las firmas únicas, creando efectivamente nombres únicos. A la luz de eso, cualquier comportamiento polimórfico aparente se evapora tras una inspección más cercana.

Tanto la coerción como la sobrecarga se clasifican como ad hoc porque cada una proporciona un comportamiento polimórfico solo en un sentido limitado. Aunque se incluyen en una definición amplia de polimorfismo, estas variedades son principalmente conveniencias para los desarrolladores. La coerción evita las conversiones de tipos explícitas engorrosas o los errores de tipo de compilador innecesarios. La sobrecarga, por otro lado, proporciona azúcar sintáctico, lo que permite que un desarrollador use el mismo nombre para distintos métodos.

Paramétrico

El polimorfismo paramétrico permite el uso de una única abstracción en muchos tipos. Por ejemplo, una Listabstracción, que representa una lista de objetos homogéneos, podría proporcionarse como módulo genérico. Reutilizaría la abstracción especificando los tipos de objetos contenidos en la lista. Dado que el tipo parametrizado puede ser cualquier tipo de datos definido por el usuario, existe un número potencialmente infinito de usos para la abstracción genérica, lo que hace que este sea posiblemente el tipo de polimorfismo más poderoso.

At first glance, the above List abstraction may seem to be the utility of the class java.util.List. However, Java does not support true parametric polymorphism in a type-safe manner, which is why java.util.List and java.util's other collection classes are written in terms of the primordial Java class, java.lang.Object. (See my article "A Primordial Interface?" for more details.) Java's single-rooted implementation inheritance offers a partial solution, but not the true power of parametric polymorphism. Eric Allen's excellent article, "Behold the Power of Parametric Polymorphism," describes the need for generic types in Java and the proposals to address Sun's Java Specification Request #000014, "Add Generic Types to the Java Programming Language." (See Resources for a link.)

Inclusion

El polimorfismo de inclusión logra un comportamiento polimórfico a través de una relación de inclusión entre tipos o conjuntos de valores. Para muchos lenguajes orientados a objetos, incluido Java, la relación de inclusión es una relación de subtipo. Entonces, en Java, el polimorfismo de inclusión es polimorfismo de subtipo.

Como se señaló anteriormente, cuando los desarrolladores de Java se refieren genéricamente al polimorfismo, invariablemente se refieren al polimorfismo de subtipo. Obtener una sólida apreciación del poder del polimorfismo de subtipo requiere ver los mecanismos que producen el comportamiento polimórfico desde una perspectiva orientada al tipo. El resto de este artículo examina esa perspectiva de cerca. Para mayor brevedad y claridad, utilizo el término polimorfismo para referirme al polimorfismo de subtipo.

Vista orientada a tipos

The UML class diagram in Figure 1 shows the simple type and class hierarchy used to illustrate the mechanics of polymorphism. The model depicts five types, four classes, and one interface. Although the model is called a class diagram, I think of it as a type diagram. As detailed in "Thanks Type and Gentle Class," every Java class and interface declares a user-defined data type. So from an implementation-independent view (i.e., a type-oriented view) each of the five rectangles in the figure represents a type. From an implementation point of view, four of those types are defined using class constructs, and one is defined using an interface.

The following code defines and implements each user-defined data type. I purposely keep the implementation as simple as possible:

/* Base.java */ public class Base { public String m1() { return "Base.m1()"; } public String m2( String s ) { return "Base.m2( " + s + " )"; } } /* IType.java */ interface IType { String m2( String s ); String m3(); } /* Derived.java */ public class Derived extends Base implements IType { public String m1() { return "Derived.m1()"; } public String m3() { return "Derived.m3()"; } } /* Derived2.java */ public class Derived2 extends Derived { public String m2( String s ) { return "Derived2.m2( " + s + " )"; } public String m4() { return "Derived2.m4()"; } } /* Separate.java */ public class Separate implements IType { public String m1() { return "Separate.m1()"; } public String m2( String s ) { return "Separate.m2( " + s + " )"; } public String m3() { return "Separate.m3()"; } } 

Using these type declarations and class definitions, Figure 2 depicts a conceptual view of the Java statement:

Derived2 derived2 = new Derived2(); 

The above statement declares an explicitly typed reference variable, derived2, and attaches that reference to a newly created Derived2 class object. The top panel in Figure 2 depicts the Derived2 reference as a set of portholes, through which the underlying Derived2 object can be viewed. There is one hole for each Derived2 type operation. The actual Derived2 object maps each Derived2 operation to appropriate implementation code, as prescribed by the implementation hierarchy defined in the above code. For example, the Derived2 object maps m1() to implementation code defined in class Derived. Furthermore, that implementation code overrides the m1() method in class Base. A Derived2 reference variable cannot access the overridden m1() implementation in class Base. That does not mean that the actual implementation code in class Derived can't use the Base class implementation via super.m1(). But as far as the reference variable derived2 is concerned, that code is inaccessible. The mappings of the other Derived2 operations similarly show the implementation code executed for each type operation.

Now that you have a Derived2 object, you can reference it with any variable that conforms to type Derived2. The type hierarchy in Figure 1's UML diagram reveals that Derived, Base, and IType are all super types of Derived2. So, for example, a Base reference can be attached to the object. Figure 3 depicts the conceptual view of the following Java statement:

Base base = derived2; 

There is absolutely no change to the underlying Derived2 object or any of the operation mappings, though methods m3() and m4() are no longer accessible through the Base reference. Calling m1() or m2(String) using either variable derived2 or base results in execution of the same implementation code:

String tmp; // Derived2 reference (Figure 2) tmp = derived2.m1(); // tmp is "Derived.m1()" tmp = derived2.m2( "Hello" ); // tmp is "Derived2.m2( Hello )" // Base reference (Figure 3) tmp = base.m1(); // tmp is "Derived.m1()" tmp = base.m2( "Hello" ); // tmp is "Derived2.m2( Hello )" 

Realizing identical behavior through both references makes sense because the Derived2 object does not know what calls each method. The object only knows that when called upon, it follows the marching orders defined by the implementation hierarchy. Those orders stipulate that for method m1(), the Derived2 object executes the code in class Derived, and for method m2(String), it executes the code in class Derived2. The action performed by the underlying object does not depend on the reference variable's type.

Sin embargo, no todo es igual cuando usa las variables de referencia derived2y base. Como se muestra en la Figura 3, una Basereferencia de tipo solo puede ver las Baseoperaciones de tipo del objeto subyacente. Entonces, aunque Derived2tiene asignaciones para métodos m3()y m4(), la variable baseno puede acceder a esos métodos:

String tmp; // Referencia derivada2 (Figura 2) tmp = derivada2.m3 (); // tmp es "Derived.m3 ()" tmp = derivado2.m4 (); // tmp es "Derived2.m4 ()" // Referencia base (Figura 3) tmp = base.m3 (); // Error en tiempo de compilación tmp = base.m4 (); // Error en tiempo de compilación

El tiempo de ejecución

Derived2

El objeto sigue siendo plenamente capaz de aceptar

m3()

o

m4()

llamadas a métodos. Las restricciones de tipo que no permiten esos intentos de llamadas a través del

Base