Apuntes · Capítulo 6Herencia e interfaces
Polimorfismo y clases abstractas
Polimorfismo y clases abstractas
La semana pasada construimos jerarquías: una subclase recibe los campos y
métodos de su superclase, agrega los suyos y hace override de los que quiere
cambiar. También vimos que toda clase hereda de Object, y que hacer override de
toString, equals y hashCode cambia cómo se comporta un objeto dentro de
la biblioteca estándar. Pero hay una pregunta que dejamos sin responder del
todo: si una variable de tipo BankAccount apunta a un SavingsAccount,
¿cuál withdraw se ejecuta, el de la superclase o el de la subclase? ¿Y
quién lo decide, el compilador o la máquina virtual?
Este capítulo responde esa pregunta, y de la respuesta sale la idea más importante de la segunda parte del curso: el polimorfismo (polymorphism). Un mismo mensaje, enviado a objetos de clases distintas, hace en cada uno lo que corresponde, y el código que envía el mensaje no necesita saber de qué clase es cada objeto. Eso permite agregar casos nuevos (un tipo de aviso, un tipo de empleado, un tipo de producto) sin tocar el código que ya funciona. En la segunda parte del capítulo vemos las clases abstractas (abstract classes): clases que existen solo para que otras las extiendan, y que obligan a cada subclase a completar lo que falta.
Lectura. Este capítulo acompaña a Eck, Introduction to Programming Using Java (versión 9), subsecciones 5.5.3 Example: Vehicles (relectura: asignación entre tipos, casting e
instanceof), 5.5.4 Polymorphism y 5.5.5 Abstract Classes. La regla exacta de qué método se ejecuta está en la especificación del lenguaje, JLS 25, §15.12.4. El código del capítulo está encode/week-06y se prueba conmake testdentro del contenedor del curso (env/run.sh).
Ruta de lectura.
- Antes de clase (obligatorio, 15–20 min): Sustituir: donde se espera un aviso, sirve cualquier aviso y Tipo estático y tipo dinámico.
- En clase: en la sesión A, Dynamic dispatch, Overloading en compilación, override en ejecución, Casting e
instanceofy Lo que no es polimórfico:staticy campos; en la sesión B, Clases y métodos abstractos, con sus constructores y El patrón Template Method. En el laboratorio, Probar una jerarquía.- Después de clase: Error común: llamar desde el constructor a un método que admite override, el Resumen y los Ejercicios. Para ampliar, las secciones de Eck de la nota Lectura.
Sustituir: donde se espera un aviso, sirve cualquier aviso
Una bodega avisa cosas a sus clientes y a su personal: que un pedido salió,
que un producto se está acabando. Algunos avisos van por correo, otros por
SMS y otros aparecen en la pantalla de la aplicación. Todos tienen un
destinatario y un texto, y todos se pueden enviar, pero cada uno se envía de
otra forma. Es una jerarquía como las de la semana 5
(Notification.java):
public class Notification {
private final String recipient;
private final String text;
public Notification(String recipient, String text) {
this.recipient = recipient;
this.text = text;
}
public String recipient() { return recipient; }
public String text() { return text; }
/** Envía el aviso; devuelve lo que se mostró, para poder probarlo. */
public String send() {
return "[aviso] " + recipient + ": " + text;
}
/** Costo del envío, en céntimos. */
public long cost() {
return 1;
}
}
send devuelve el texto en lugar de imprimirlo para que las pruebas puedan
verificarlo. Las subclases cambian cómo se envía y cuánto cuesta
(EmailNotification.java,
SmsNotification.java):
public class EmailNotification extends Notification {
public EmailNotification(String address, String text) {
super(address, text);
}
@Override
public String send() {
return "[correo] para " + recipient() + ": " + text();
}
@Override
public long cost() {
return 0;
}
}
public class SmsNotification extends Notification {
public SmsNotification(String phoneNumber, String text) {
super(phoneNumber, text);
}
/** Solo existe en SmsNotification, no en Notification. */
public String phoneNumber() {
return recipient();
}
@Override
public String send() {
return "[SMS] al " + phoneNumber() + ": " + text();
}
@Override
public long cost() {
return 5;
}
}
Hay una tercera, PushNotification,
que no hace override de nada: el aviso en pantalla usa el send() y el cost()
que hereda de Notification. Nos va a servir para ver qué pasa cuando la
clase del objeto no declara el método que se llama.
Como un SmsNotification es un Notification, se puede guardar en una
variable de tipo Notification, pasar a un método que recibe un
Notification o meter en un ArrayList<Notification>:
Notification n = new SmsNotification("987654321", "Código 4821");
Esa conversión de subclase a superclase se llama upcasting y es
implícita: no hay que escribir nada, porque siempre es segura. Todo lo que se
le puede pedir a un Notification se le puede pedir a un SmsNotification.
Es la idea de sustitución: donde el programa espera un objeto de la
superclase, sirve un objeto de cualquier subclase.
Tipo estático y tipo dinámico
En la línea anterior hay dos tipos, y conviene separarlos desde ya:
- El tipo estático es el de la variable, el que está escrito en el código:
Notification. No cambia mientras el programa corre, y el compilador lo conoce. - El tipo dinámico es la clase del objeto al que apunta la variable en un
momento dado:
SmsNotification. Se decide cuando se ejecuta elnew, y la misma variable puede apuntar más tarde a un objeto de otra clase.
Cada tipo responde una pregunta distinta. El compilador usa el tipo
estático para decidir qué mensajes se pueden enviar. Con
Notification n, el compilador solo sabe que n apunta a algún
Notification; no sabe a cuál. Por eso esto no compila, aunque el objeto sí
tenga el método:
Notification n = new SmsNotification("987654321", "Código 4821");
IO.println(n.phoneNumber());
$ javac -Xlint:all Wrong.java ...
Wrong.java:4: error: cannot find symbol
IO.println(n.phoneNumber());
^
symbol: method phoneNumber()
location: variable n of type Notification
El mensaje lo dice con precisión: se buscó phoneNumber() en el tipo de la
variable, Notification, y no está. La máquina virtual, en cambio, usa
el tipo dinámico para decidir qué versión del método se ejecuta. Eso es lo
que estudiamos ahora.
Idea clave. El tipo estático (el de la variable) decide qué se puede llamar; lo verifica el compilador. El tipo dinámico (la clase del objeto) decide qué código se ejecuta; lo decide la JVM al momento de la llamada.
Dynamic dispatch
NotificationDemo.java guarda tres
avisos de clases distintas en una sola lista y los recorre:
ArrayList<Notification> inbox = new ArrayList<>();
inbox.add(new EmailNotification("alice@example.com", "Tu pedido salió"));
inbox.add(new SmsNotification("987654321", "Código 4821"));
inbox.add(new PushNotification("bob", "Stock bajo: arroz"));
long total = 0;
for (Notification n : inbox) {
IO.println(n.send()); // ¿qué send()? el del objeto
total += n.cost();
}
IO.println("Costo total: " + total + " céntimos");
$ java -cp build NotificationDemo
[correo] para alice@example.com: Tu pedido salió
[SMS] al 987654321: Código 4821
[aviso] bob: Stock bajo: arroz
Costo total: 6 céntimos
La línea n.send() es una sola, escrita una vez, y en cada vuelta del for
ejecuta un método distinto. A ese mecanismo se le llama dynamic dispatch:
el método que se ejecuta se elige en tiempo de ejecución,
según la clase del objeto que recibe el mensaje. La palabra polimorfismo
(del griego, “muchas formas”) nombra el resultado: la variable n toma
muchas formas, y el mismo mensaje se comporta según la forma que tenga en
ese momento.
Qué decide el compilador
Veamos qué dejó javac en el .class. Igual que en la semana 1, javap -c
muestra el bytecode; aquí está solo la parte del for:
$ javap -c -cp build NotificationDemo
...
76: invokeinterface #47, 1 // InterfaceMethod java/util/Iterator.next:()Ljava/lang/Object;
81: checkcast #51 // class Notification
84: astore 5
86: aload 5
88: invokevirtual #53 // Method Notification.send:()Ljava/lang/String;
91: invokestatic #57 // Method java/lang/IO.println:(Ljava/lang/Object;)V
94: lload_2
95: aload 5
97: invokevirtual #63 // Method Notification.cost:()J
...
La instrucción 88 es la llamada: invokevirtual Notification.send, un método
sin parámetros (()) que devuelve un String (Ljava/lang/String;). El
compilador escribió Notification.send porque es lo único que sabe: el tipo
estático de n es Notification, y Notification tiene un send(). En el
bytecode no aparece EmailNotification ni SmsNotification. Lo que sí quedó
fijo es la firma del método: el nombre y los tipos de los parámetros.
Qué decide la JVM
Al ejecutar invokevirtual, la JVM hace lo siguiente:
- Toma la referencia que está en el stack (la de
n) y llega al objeto. - Mira la clase del objeto: todo objeto en el heap sabe de qué clase es.
- Busca en esa clase un método con la firma
send(). Si la clase lo declara, ese es el que se ejecuta. Si no, sube a la superclase y busca ahí, y así hasta encontrarlo.


La tercera línea de la salida, [aviso] bob: ..., es el caso de
PushNotification: la clase no declara send(), la búsqueda sube a
Notification y se ejecuta la versión heredada. Esta regla, buscar el
método empezando por la clase del objeto y subiendo, es la que define el
lenguaje en la JLS (§15.12.4.4, Locate Method to Invoke).
En Java. Recorrer la jerarquía en cada llamada sería lento, así que HotSpot, la JVM del JDK, prepara el resultado de esa búsqueda cuando carga cada clase. Cada clase tiene una vtable: un arreglo con una entrada por cada método de instancia del que se puede hacer override, y cada entrada apunta al código que corresponde a esa clase. Una subclase empieza copiando la tabla de su superclase, reemplaza las entradas de los métodos a los que hace override y agrega al final los suyos. Así,
send()ocupa la misma posición en la tabla de todas las clases de la jerarquía, yinvokevirtualse reduce a leer esa posición en la tabla de la clase del objeto y saltar. El efecto es exactamente el de la regla de la JLS; la tabla es solo una forma rápida de cumplirla. (Detalles en la wiki de OpenJDK, Virtual Calls.)
Por qué importa
El for de NotificationDemo no menciona ninguna subclase. Si mañana la
bodega agrega avisos por WhatsApp, basta con escribir una clase
WhatsAppNotification extends Notification con su propio send() y su
cost(). El for, el cálculo del total y cualquier otro código escrito en
términos de Notification funcionan con la clase nueva sin cambiar ni
recompilar: el invokevirtual Notification.send que ya está en el bytecode
encontrará el método nuevo en la tabla de la clase nueva. Es lo que el
proyecto Bodega necesita en P2: que agregar un tipo de producto no obligue a
modificar el código que vende.
Overloading en compilación, override en ejecución
En la semana 5 distinguimos overloading (mismo nombre, distinta lista de parámetros) y override (misma firma en una subclase). Ahora podemos decir cuándo se resuelve cada uno, y la respuesta explica muchas trazas que parecen raras.
- El overloading se resuelve al compilar. El compilador mira los tipos estáticos de los argumentos y elige la firma más específica que les sirve. Esa firma queda escrita en el bytecode.
- El override se resuelve al ejecutar. Entre los métodos con esa firma ya elegida, la JVM ejecuta el de la clase del objeto.
Greeter.java tiene dos versiones
de greet (overloading), y LoudGreeter.java
hace override solo de una de ellas:
public class Greeter {
public String greet(Object o) { return "Greeter.greet(Object)"; }
public String greet(String s) { return "Greeter.greet(String)"; }
}
public class LoudGreeter extends Greeter {
@Override
public String greet(Object o) { return "LoudGreeter.greet(Object)"; }
}
TraceDemo.java los llama de cuatro
formas. Antes de mirar la salida, intenten predecirla:
Greeter g = new LoudGreeter();
String s = "hola";
Object x = s; // el mismo String, visto como Object
IO.println("1. " + g.greet(s));
IO.println("2. " + g.greet(x));
IO.println("3. " + g.greet((String) x));
Greeter plain = new Greeter();
IO.println("4. " + plain.greet(x));
$ java -cp build TraceDemo
1. Greeter.greet(String)
2. LoudGreeter.greet(Object)
3. Greeter.greet(String)
4. Greeter.greet(Object)
Línea por línea, en dos pasos cada una:
g.greet(s): al compilar,sesString, y la firma más específica esgreet(String). Al ejecutar, el objeto es unLoudGreeter, que no hace override degreet(String): se sube y se ejecuta la deGreeter.g.greet(x): al compilar,xesObject(aunque apunte a unString), así que solo sirvegreet(Object). Al ejecutar,LoudGreetersí hace override de esa firma: se ejecuta la suya.g.greet((String) x): el casting cambia el tipo estático de la expresión aString, y vuelve a elegirsegreet(String), como en la línea 1.plain.greet(x): firmagreet(Object), objetoGreeter: la versión deGreeter.
La línea 2 es la que más sorprende: el objeto apuntado por x es un
String, y aun así se llama a greet(Object). El tipo dinámico del
argumento no participa en la elección; solo el del objeto que recibe el
mensaje (el que va antes del punto). javap lo confirma: la llamada 2 quedó
compilada como invokevirtual Greeter.greet:(Ljava/lang/Object;)Ljava/lang/String;
y las llamadas 1 y 3 como Greeter.greet:(Ljava/lang/String;)....


Error común. Es exactamente lo que pasaba en la semana 5 con
equals(Fraction f). Ese método es un overload deequals(Object)en lugar de un override.ArrayList.containsllama aequalssobre elementos de tipo estáticoObject, así que el compilador de la biblioteca eligió la firmaequals(Object), y la deFractionnunca se ejecuta.@Overrideconvierte ese error en un error de compilación.
Casting e instanceof
El upcasting es implícito y siempre funciona. El camino inverso, de
superclase a subclase, se llama downcasting, y hay que pedirlo con un
casting explícito, porque puede fallar: no todo Notification es un
SmsNotification.
CastDemo.java:
Notification n = new SmsNotification("987654321", "Código 4821"); // upcasting implícito
// n.phoneNumber() no compila: Notification no tiene ese método.
if (n instanceof SmsNotification sms) {
IO.println("Es un SMS para " + sms.phoneNumber());
}
Notification other = new EmailNotification("alice@example.com", "Hola");
try {
SmsNotification wrong = (SmsNotification) other; // compila, pero...
IO.println(wrong.phoneNumber());
} catch (ClassCastException e) {
IO.println("ClassCastException: " + e.getMessage());
}
$ java -cp build CastDemo
Es un SMS para 987654321
ClassCastException: class EmailNotification cannot be cast to class SmsNotification (EmailNotification and SmsNotification are in unnamed module of loader 'app')
n instanceof SmsNotification smspregunta por el tipo dinámico: ¿el objeto es unSmsNotification(o de una subclase suya)? Si lo es, declara la variablesms, ya del tipo de la subclase. Es la misma forma que usamos enequalsla semana 5; en la semana 8 la generalizamos a otros patrones.(SmsNotification) othercompila, porque el compilador acepta que podría ser un SMS. La JVM verifica el casting al ejecutarlo y, como el objeto es unEmailNotification, lanzaClassCastException. (Eltry/catchestá ahí para que la demostración siga; las excepciones son el tema de la semana 9.)
Error común. Usar casting e
instanceofdonde iba un override en cada subclase.CostByInstanceof.javacalcula el costo de un aviso así:public static long cost(Notification n) { if (n instanceof EmailNotification) { return 0; } else if (n instanceof SmsNotification) { return 5; } else { return 1; } }Funciona, y las pruebas de
NotificationTestcomprueban que da lo mismo quen.cost(). El problema aparece al crecer: cada decisión que depende del tipo de aviso (el costo, el ícono, el reporte) termina con su propia cadena, repartida por el programa. Al agregarWhatsAppNotificationhay que encontrar todas las cadenas y cambiarlas, y si se olvida una, el compilador no avisa: el aviso nuevo cae en elelsey cuesta 1 céntimo sin que nadie lo haya decidido. Con el override decost(), la decisión vive en la clase de cada aviso, y la clase nueva trae la suya.
¿Cuándo es razonable un instanceof? Cuando la jerarquía no es nuestra y no
podemos agregarle un método, en equals (donde hay que preguntar si el otro
objeto es comparable), o cuando la operación de verdad no le pertenece a la
clase. En la semana 8 veremos los tipos sealed, con los que
el compilador sí avisa si un switch sobre tipos olvida un caso.
Lo que no es polimórfico: static y campos
El dynamic dispatch se aplica a los métodos de instancia. Los métodos
static pertenecen a la clase y no a un objeto, así que no hay objeto cuya
clase consultar: se resuelven por el tipo estático. Con los campos pasa lo
mismo. Si una subclase declara un método static o un campo con el mismo
nombre que uno de su superclase, no le hace override: lo oculta (hiding):
los dos conviven, y cuál se usa depende del tipo estático.
StaticDemo.java:
static class Parent {
String label = "campo de Parent";
static String kind() { return "Parent.kind()"; }
String describe() { return "Parent.describe()"; }
}
static class Child extends Parent {
String label = "campo de Child"; // oculta (no es override) el de Parent
static String kind() { return "Child.kind()"; }
@Override
String describe() { return "Child.describe()"; }
}
// en main:
Parent p = new Child();
IO.println(p.describe()); // de instancia: decide el objeto
IO.println(p.kind()); // static: decide el tipo de p
IO.println(p.label); // campo: decide el tipo de p
IO.println(((Child) p).label);
$ java -cp build StaticDemo
Child.describe()
Parent.kind()
campo de Parent
campo de Child
p.kind() se ve como una llamada a través de un objeto, pero no lo es: el
compilador la traduce a Parent.kind() usando el tipo de p. Es tan
engañoso que javac -Xlint:all lo marca. En el código del curso suprimimos
la advertencia con @SuppressWarnings("static") porque queremos mostrar el
efecto; sin esa línea, el compilador dice:
StaticDemo.java:34: warning: [static] static method should be qualified by type name, Parent, instead of by an expression
IO.println(p.kind()); // static: decide el tipo de p
^
La recomendación es la del mensaje: llamar a los métodos static con el
nombre de la clase (Parent.kind()), y no repetir en una subclase el nombre
de un campo de la superclase.
Error común: llamar desde el constructor a un método que admite override
Este error es difícil de ver porque cada clase, leída por separado, parece
correcta. Tag.java llama a describe() desde
su constructor, y LabeledTag.java le
hace override usando un campo propio:
public class Tag {
public Tag() {
IO.println("Tag(): " + describe());
}
public String describe() {
return "etiqueta";
}
}
public final class LabeledTag extends Tag {
private final String label;
public LabeledTag(String label) {
super(); // aquí corre Tag(), que llama a describe()
this.label = label;
IO.println("LabeledTag(): " + describe());
}
@Override
public String describe() {
return "etiqueta " + label;
}
}
$ java -cp build ConstructorTrap
Tag(): etiqueta null
LabeledTag(): etiqueta A-7
¿De dónde salió ese null? new LabeledTag("A-7") reserva el objeto con
sus campos en el valor por defecto (label es null), y lo primero que
hace el constructor de LabeledTag es llamar a super(). El constructor de
Tag llama a describe(), y el dynamic dispatch funciona también aquí: el
objeto ya es un LabeledTag, así que se ejecuta la versión de
LabeledTag, que lee label antes de que nadie le haya asignado nada. Un
campo final se ve con dos valores distintos durante la vida del objeto.
Desde el JDK 21, javac -Xlint:all detecta este patrón. En Tag.java lo
suprimimos para mostrar el efecto; sin la supresión dice:
Tag.java:10: warning: [this-escape] possible 'this' escape before subclass is fully initialized
IO.println("Tag(): " + describe());
^
This escape significa que la referencia al objeto (this) “escapa” del
constructor antes de que el objeto esté completo. La regla práctica: un
constructor solo llama a métodos private, static o final de su propia
clase. (LabeledTag también llama a describe() en su constructor, pero es
una clase final, nadie puede hacer override de su método, y el compilador no se
queja.)
Clases y métodos abstractos
Pasemos a la planilla de la bodega. Hay empleados con sueldo fijo y
empleados que cobran por hora. Los dos tienen nombre, los dos reciben una
boleta de pago, y el pago del mes se calcula distinto en cada uno. La
superclase común es Employee, pero ¿qué debería devolver el pago de un
“empleado en general”? No hay respuesta sensata, porque no existen empleados
en general: todo empleado es de algún tipo concreto.
Para eso están las clases abstractas. Employee.java:
public abstract class Employee {
private final String name;
protected Employee(String name) {
this.name = name;
}
public String name() {
return name;
}
/** Pago del mes, en céntimos. Cada subclase concreta dice cómo se calcula. */
public abstract long monthlyPay();
/** Template Method: el formato es común; el monto lo da la subclase. */
public String payslip() {
return name + ": " + soles(monthlyPay());
}
protected static String soles(long cents) {
return String.format("S/ %d.%02d", cents / 100, cents % 100);
}
}
Hay dos cosas nuevas:
abstract class: la clase no se puede instanciar. Sirve como tipo (para variables, parámetros y listas) y como superclase, pero no para hacernew.abstract long monthlyPay();: un método abstracto, sin cuerpo, solo la firma y un punto y coma. La clase promete la operación sin implementarla. Una clase con algún método abstracto tiene que ser abstracta.
El compilador hace cumplir las dos reglas:
Employee e = new Employee("carol");
Wrong.java:5: error: Employee is abstract; cannot be instantiated
Employee e = new Employee("carol");
^
Y obliga a cada subclase concreta (no abstracta) a dar el método. Una subclase que lo olvida no compila:
public class Contractor extends Employee {
public Contractor(String name) {
super(name);
}
}
Contractor.java:1: error: Contractor is not abstract and does not override abstract method monthlyPay() in Employee
public class Contractor extends Employee {
^
Las dos salidas posibles son las que dice el mensaje: implementar
monthlyPay() o declarar Contractor también abstracta (y dejar la tarea a
sus subclases).
Las subclases concretas son cortas, porque lo común ya está en Employee
(SalariedEmployee.java,
HourlyEmployee.java):
public class SalariedEmployee extends Employee {
private final long monthlySalary; // céntimos
public SalariedEmployee(String name, long monthlySalary) {
super(name);
this.monthlySalary = monthlySalary;
}
@Override
public long monthlyPay() {
return monthlySalary;
}
}
public class HourlyEmployee extends Employee {
private final long hourlyRate; // céntimos por hora
private final int hours;
public HourlyEmployee(String name, long hourlyRate, int hours) {
super(name);
this.hourlyRate = hourlyRate;
this.hours = hours;
}
@Override
public long monthlyPay() {
return hourlyRate * hours;
}
@Override
public String payslip() {
return super.payslip() + " (" + hours + " h)";
}
}
Idea clave. Una clase abstracta fija el qué (todo empleado tiene un pago mensual y una boleta) y deja a cada subclase el cómo (cómo se calcula ese pago). El compilador garantiza que ninguna subclase concreta se olvide de completar su parte.
Constructores en una clase abstracta
Nadie puede hacer new Employee(...), pero Employee tiene constructor. ¿Para
qué? Para lo mismo que en la semana 5: inicializar la parte del objeto que le
corresponde (name). Cada subclase lo llama con super(name). Lo declaramos
protected porque solo las subclases lo van a usar; si fuera public, daría
igual, porque new Employee(...) no compila de todos modos.
El patrón Template Method
payslip() es un método concreto de la clase abstracta que usa un método
abstracto. Se escribe una sola vez, en Employee, y arma la boleta de
cualquier empleado: el formato es siempre el mismo y lo único que cambia es
el monto, que pide a monthlyPay(). Como monthlyPay() es una llamada a un
método de instancia (this.monthlyPay()), el dynamic dispatch la lleva a la
subclase del objeto. A este diseño se le llama Template Method: la superclase escribe el algoritmo y deja huecos que las subclases
llenan. Es uno de los patrones de diseño (design patterns) clásicos, el tema
de la última parte del curso.
HourlyEmployee además hace override de payslip() para agregar las horas, y
reutiliza la versión de la superclase con super.payslip(). super. es la
excepción al dynamic dispatch: llama a la versión de la superclase
directamente, sin mirar la clase del objeto (por eso no hay recursión
infinita).
PayrollDemo.java recorre la planilla
como una lista de Employee:
ArrayList<Employee> staff = new ArrayList<>();
staff.add(new SalariedEmployee("alice", 350_000));
staff.add(new HourlyEmployee("bob", 1_250, 96));
long total = 0;
for (Employee e : staff) {
IO.println(e.payslip());
total += e.monthlyPay();
}
IO.println("Total de la planilla: " + total + " céntimos");
$ java -cp build PayrollDemo
alice: S/ 3500.00
bob: S/ 1200.00 (96 h)
Total de la planilla: 470000 céntimos
Bob trabajó 96 horas a S/ 12.50 la hora: 1 250 × 96 = 120 000 céntimos.
En UML, una clase o un método abstracto se escribe en cursiva. Como la
cursiva se pierde fácil en un dibujo a mano o en una pizarra, también se
usa el estereotipo «abstract» sobre el nombre de la clase, como en la
figura. En Mermaid, la notación que usamos desde la semana 4, la clase se
marca con la anotación <<Abstract>> y el método abstracto con un * al
final: +monthlyPay() long*.
Error común. Escribir en la superclase un método concreto que devuelve un valor de relleno, “para que compile”:
public long monthlyPay() { return 0; // las subclases le hacen override }Ahora
Employeeya no necesita ser abstracta, y el compilador deja de vigilar: una subclase que olvida hacer override demonthlyPay()compila y paga cero, y el error aparece (con suerte) cuando alguien reclama su sueldo. Lo mismo pasa con un cuerpo que lanzaUnsupportedOperationException: el error se mueve de la compilación a la ejecución. Si toda subclase concreta tiene que dar el método, declárenloabstract.
Probar una jerarquía
Las pruebas de una jerarquía se escriben, en lo posible, contra el tipo de la
superclase: es así como la usa el resto del programa.
EmployeeTest.java declara las
variables como Employee y verifica que cada subclase cumpla su parte y que
el Template Method use el monto de la subclase:
@Test
void payslipUsesTheSubclassPay() {
Employee e = new SalariedEmployee("alice", 350_000);
assertEquals("alice: S/ 3500.00", e.payslip());
}
@Test
void totalOverAListOfEmployees() {
List<Employee> staff = List.of(
new SalariedEmployee("alice", 350_000),
new HourlyEmployee("bob", 1_250, 96));
long total = 0;
for (Employee e : staff) {
total += e.monthlyPay();
}
assertEquals(470_000, total);
}
List.of(...) crea una lista que no se puede modificar, cómoda para los
datos de una prueba; las listas se estudian a fondo en la semana 11.
El código de la semana y make test
Dentro del contenedor del curso:
$ cd code/week-06
$ make test
...
== NotificationDemo
[correo] para alice@example.com: Tu pedido salió
[SMS] al 987654321: Código 4821
[aviso] bob: Stock bajo: arroz
Costo total: 6 céntimos
...
│ ├─ EmployeeTest ✔
│ ├─ ShippingCalculatorTest ✔
│ └─ NotificationTest ✔
...
[ 11 tests successful ]
[ 0 tests failed ]
Además del código de los apuntes, la carpeta trae el código inicial del
laboratorio: LabTrace.java, una traza con
overloading, override y casting que no está en make test para que
puedan predecir su salida antes de verla (java -cp build LabTrace), y
ShippingCalculator.java, con una
cadena de instanceof sobre envíos (Letter, Parcel, Pallet) que hay
que reemplazar por un override en cada subclase sin que fallen sus pruebas.
Resumen
- Sustitución: un objeto de una subclase sirve donde se espera uno de la superclase. El upcasting es implícito y siempre seguro.
- El tipo estático (de la variable) decide qué mensajes se pueden enviar; lo verifica el compilador. El tipo dinámico (la clase del objeto) decide qué código se ejecuta.
- Dynamic dispatch: el compilador deja
invokevirtualcon la firma elegida; al ejecutar, la JVM busca esa firma desde la clase del objeto hacia arriba. HotSpot lo implementa con una vtable por clase. - El overloading se resuelve al compilar, con los tipos estáticos de los argumentos; el override, al ejecutar, con la clase del objeto que recibe el mensaje.
- Downcasting con casting explícito: si el objeto no es del tipo pedido,
ClassCastException.instanceofpregunta por el tipo dinámico. Una cadena deinstanceofsuele ser un override que falta. - Los métodos
staticy los campos no son polimórficos: se resuelven por el tipo estático, y en una subclase se ocultan (hiding), no se les hace override. - Un constructor no debe llamar a métodos que una subclase puede cambiar con
un override: la subclase
todavía no inicializó sus campos (
[this-escape]). - Una clase abstracta no se instancia; un método abstracto no tiene cuerpo y obliga a cada subclase concreta a implementarlo. La clase abstracta puede tener campos, constructores y métodos concretos, como un Template Method que llama a los abstractos.
Ejercicios
-
Con las clases de este capítulo, digan si cada línea compila y, si compila, qué imprime. Después compruébenlo.
Notification a = new SmsNotification("912345678", "Hola"); SmsNotification b = new SmsNotification("912345678", "Hola"); Object c = b; IO.println(a.send()); IO.println(a.phoneNumber()); IO.println(b.phoneNumber()); IO.println(c.send()); IO.println(((Notification) c).cost()); -
Escriban
WhatsAppNotificationcon su propiosend()y un costo de 2 céntimos, agréguenla a la lista deNotificationDemoy ejecuten. ¿Qué líneas deNotificationDemotuvieron que cambiar? ¿Qué devuelve ahoraCostByInstanceof.costpara el aviso nuevo, y qué prueba deNotificationTestlo detecta? -
Compilen
NotificationDemoy busquen conjavap -cla llamada acost(). ¿Qué clase aparece en la instruccióninvokevirtual? ¿Cambia si elforrecorre una lista deSmsNotification? Pruébenlo y expliquen. -
Agreguen a
Greeterun tercer métodogreet(CharSequence c)(StringimplementaCharSequence) y vuelvan a ejecutarTraceDemo. ¿Cambió alguna línea? Ahora quitengreet(String). ¿Qué imprime cada línea y por qué? -
Expliquen con sus palabras por qué
super.payslip()dentro deHourlyEmployee.payslip()no produce una recursión infinita, y qué pasaría si escribieranthis.payslip()en su lugar. -
En
StaticDemo, cambienp.kind()porParent.kind()y quiten el@SuppressWarnings. ¿Compila sin advertencias? ¿Cuál de las cuatro líneas de la salida mostraría el error de quien esperaba polimorfismo? -
Escriban una clase
CommissionEmployeeque cobre un sueldo base más el 5 % de sus ventas del mes (todo en céntimos). Agréguenla aPayrollDemoy escriban sus pruebas enEmployeeTest. ¿Tuvieron que modificarEmployeeopayslip()? -
Supongan que en
Employeereemplazanpublic abstract long monthlyPay();por un método concreto que devuelve 0, y queCommissionEmployeedel ejercicio anterior escribe por errormonthlypay()(conpminúscula). ¿Qué pasa al compilar, con y sin@Override? ¿Y al ejecutarPayrollDemo? -
Corrijan
Tagpara que no llame desde su constructor a un método del que una subclase puede hacer override, pero siga imprimiendo una descripción al crear el objeto. (Pista: ¿quién conoce la descripción completa cuando termina de construirse el objeto?) -
Una clase abstracta
Shapetieneabstract double area()y un método concretoString describe()que devuelve"figura de área " + area(). EscribanCircleyRectangleShape, una lista deShapecon dos objetos de cada una, y un programa que imprima la descripción de cada figura y la suma de las áreas. Dibujen el diagrama de clases en Mermaid. -
¿Puede un método abstracto ser
private? ¿Ystatic? ¿Yfinal? Prueben cada combinación conjavac, lean el mensaje de error y expliquen por qué cada una no tiene sentido con lo que vieron sobre el dynamic dispatch. -
Lean la subsección 5.5.4 de Eck. Él dice que el polimorfismo hace posible escribir código que funciona con clases que todavía no existen. Den un ejemplo con la jerarquía de productos que van a diseñar para P2.