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á en code/week-06 y se prueba con make test dentro 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 instanceof y Lo que no es polimórfico: static y 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 el new, 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:

  1. Toma la referencia que está en el stack (la de n) y llega al objeto.
  2. Mira la clase del objeto: todo objeto en el heap sabe de qué clase es.
  3. 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.
Animación: arriba, el código for (Notification n : inbox) con IO.println(n.send()) y, a la derecha, el bytecode invokevirtual Notification.send. Paso 1: el compilador solo mira el tipo estático de n, Notification, y encuentra send() en la clase Notification. Paso 2: n apunta a un objeto EmailNotification; la JVM va a su clase y encuentra send() ahí. Paso 3: n apunta a un objeto SmsNotification, y se ejecuta SmsNotification.send(). Paso 4: n apunta a un objeto PushNotification; su clase no declara send(), y la búsqueda sube por la flecha de herencia hasta Notification.send()Animación: arriba, el código for (Notification n : inbox) con IO.println(n.send()) y, a la derecha, el bytecode invokevirtual Notification.send. Paso 1: el compilador solo mira el tipo estático de n, Notification, y encuentra send() en la clase Notification. Paso 2: n apunta a un objeto EmailNotification; la JVM va a su clase y encuentra send() ahí. Paso 3: n apunta a un objeto SmsNotification, y se ejecuta SmsNotification.send(). Paso 4: n apunta a un objeto PushNotification; su clase no declara send(), y la búsqueda sube por la flecha de herencia hasta Notification.send()
Al compilar cuenta el tipo de la variable; al ejecutar, la clase del objeto. Si esa clase no declara el método, la búsqueda sube por la jerarquía.

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, y invokevirtual se 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.)

Tabla con una columna por clase: Notification, EmailNotification, SmsNotification y PushNotification. La primera fila son los métodos de Object. La fila i, resaltada, es send(): apunta a Notification, EmailNotification, SmsNotification y, en PushNotification, a Notification (heredado). La fila i+1 es cost(), con el mismo patrón. La fila i+2, phoneNumber(), solo existe en SmsNotificationTabla con una columna por clase: Notification, EmailNotification, SmsNotification y PushNotification. La primera fila son los métodos de Object. La fila i, resaltada, es send(): apunta a Notification, EmailNotification, SmsNotification y, en PushNotification, a Notification (heredado). La fila i+1 es cost(), con el mismo patrón. La fila i+2, phoneNumber(), solo existe en SmsNotification
La vtable de cada clase en HotSpot. send() está en la misma fila en todas; PushNotification copia la entrada de su superclase porque no le hace override.

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:

  1. g.greet(s): al compilar, s es String, y la firma más específica es greet(String). Al ejecutar, el objeto es un LoudGreeter, que no hace override de greet(String): se sube y se ejecuta la de Greeter.
  2. g.greet(x): al compilar, x es Object (aunque apunte a un String), así que solo sirve greet(Object). Al ejecutar, LoudGreeter sí hace override de esa firma: se ejecuta la suya.
  3. g.greet((String) x): el casting cambia el tipo estático de la expresión a String, y vuelve a elegirse greet(String), como en la línea 1.
  4. plain.greet(x): firma greet(Object), objeto Greeter: la versión de Greeter.

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;)....

Animación en dos pasos para g.greet(x), con g de tipo Greeter apuntando a un LoudGreeter y x de tipo Object. Paso 1, al compilar: los candidatos en Greeter son greet(Object) y greet(String); greet(String) se tacha porque un Object no es necesariamente String, y queda invokevirtual Greeter.greet:(Object). Paso 2, al ejecutar: el objeto es un LoudGreeter, que hace override de greet(Object), y se ejecuta LoudGreeter.greet(Object)Animación en dos pasos para g.greet(x), con g de tipo Greeter apuntando a un LoudGreeter y x de tipo Object. Paso 1, al compilar: los candidatos en Greeter son greet(Object) y greet(String); greet(String) se tacha porque un Object no es necesariamente String, y queda invokevirtual Greeter.greet:(Object). Paso 2, al ejecutar: el objeto es un LoudGreeter, que hace override de greet(Object), y se ejecuta LoudGreeter.greet(Object)
Primero javac fija la firma con los tipos estáticos; después la JVM elige la versión de esa firma según la clase del objeto. (El descriptor del bytecode aparece abreviado.)

Error común. Es exactamente lo que pasaba en la semana 5 con equals(Fraction f). Ese método es un overload de equals(Object) en lugar de un override. ArrayList.contains llama a equals sobre elementos de tipo estático Object, así que el compilador de la biblioteca eligió la firma equals(Object), y la de Fraction nunca se ejecuta. @Override convierte 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 sms pregunta por el tipo dinámico: ¿el objeto es un SmsNotification (o de una subclase suya)? Si lo es, declara la variable sms, ya del tipo de la subclase. Es la misma forma que usamos en equals la semana 5; en la semana 8 la generalizamos a otros patrones.
  • (SmsNotification) other compila, porque el compilador acepta que podría ser un SMS. La JVM verifica el casting al ejecutarlo y, como el objeto es un EmailNotification, lanza ClassCastException. (El try/catch está ahí para que la demostración siga; las excepciones son el tema de la semana 9.)

Error común. Usar casting e instanceof donde iba un override en cada subclase. CostByInstanceof.java calcula 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 NotificationTest comprueban que da lo mismo que n.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 agregar WhatsAppNotification hay que encontrar todas las cadenas y cambiarlas, y si se olvida una, el compilador no avisa: el aviso nuevo cae en el else y cuesta 1 céntimo sin que nadie lo haya decidido. Con el override de cost(), la decisión vive en la clase de cada aviso, y la clase nueva trae la suya.

A la izquierda, tres funciones cost(n), report(n) e icon(n), cada una con una cadena de if instanceof; al agregar la clase nueva WhatsAppNotification, flechas punteadas llegan a las tres: hay que encontrar y cambiar cada cadena, y si se olvida una nadie avisa. A la derecha, el cliente total += n.cost() no cambia; la jerarquía Notification con subclases Email, Sms, Push y la nueva WhatsApp, que trae su propio cost()A la izquierda, tres funciones cost(n), report(n) e icon(n), cada una con una cadena de if instanceof; al agregar la clase nueva WhatsAppNotification, flechas punteadas llegan a las tres: hay que encontrar y cambiar cada cadena, y si se olvida una nadie avisa. A la derecha, el cliente total += n.cost() no cambia; la jerarquía Notification con subclases Email, Sms, Push y la nueva WhatsApp, que trae su propio cost()
Agregar un tipo nuevo: con cadenas de instanceof hay que tocar cada cadena; con un override en cada subclase solo se escribe la clase nueva.

¿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 hacer new.
  • 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.

Diagrama de clases UML. Arriba, la clase abstracta Employee, con «abstract» y el nombre en cursiva, el campo - name: String y los métodos + name(): String, + monthlyPay(): long en cursiva por ser abstracto, y + payslip(): String. Abajo, SalariedEmployee con - monthlySalary: long y + monthlyPay(): long, y HourlyEmployee con - hourlyRate: long, - hours: int, + monthlyPay(): long y + payslip(): String; ambas heredan de Employee. Desde payslip() en Employee salen dos flechas this.monthlyPay(): una llega al monthlyPay() de SalariedEmployee si el objeto es Salaried, la otra al de HourlyEmployee si es HourlyDiagrama de clases UML. Arriba, la clase abstracta Employee, con «abstract» y el nombre en cursiva, el campo - name: String y los métodos + name(): String, + monthlyPay(): long en cursiva por ser abstracto, y + payslip(): String. Abajo, SalariedEmployee con - monthlySalary: long y + monthlyPay(): long, y HourlyEmployee con - hourlyRate: long, - hours: int, + monthlyPay(): long y + payslip(): String; ambas heredan de Employee. Desde payslip() en Employee salen dos flechas this.monthlyPay(): una llega al monthlyPay() de SalariedEmployee si el objeto es Salaried, la otra al de HourlyEmployee si es Hourly
En UML, la clase y el método abstractos van en cursiva. payslip() está escrito una vez en Employee; su llamada a monthlyPay() llega a la subclase del objeto.

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 Employee ya no necesita ser abstracta, y el compilador deja de vigilar: una subclase que olvida hacer override de monthlyPay() compila y paga cero, y el error aparece (con suerte) cuando alguien reclama su sueldo. Lo mismo pasa con un cuerpo que lanza UnsupportedOperationException: el error se mueve de la compilación a la ejecución. Si toda subclase concreta tiene que dar el método, declárenlo abstract.

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 invokevirtual con 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. instanceof pregunta por el tipo dinámico. Una cadena de instanceof suele ser un override que falta.
  • Los métodos static y 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

  1. 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());
  2. Escriban WhatsAppNotification con su propio send() y un costo de 2 céntimos, agréguenla a la lista de NotificationDemo y ejecuten. ¿Qué líneas de NotificationDemo tuvieron que cambiar? ¿Qué devuelve ahora CostByInstanceof.cost para el aviso nuevo, y qué prueba de NotificationTest lo detecta?

  3. Compilen NotificationDemo y busquen con javap -c la llamada a cost(). ¿Qué clase aparece en la instrucción invokevirtual? ¿Cambia si el for recorre una lista de SmsNotification? Pruébenlo y expliquen.

  4. Agreguen a Greeter un tercer método greet(CharSequence c) (String implementa CharSequence) y vuelvan a ejecutar TraceDemo. ¿Cambió alguna línea? Ahora quiten greet(String). ¿Qué imprime cada línea y por qué?

  5. Expliquen con sus palabras por qué super.payslip() dentro de HourlyEmployee.payslip() no produce una recursión infinita, y qué pasaría si escribieran this.payslip() en su lugar.

  6. En StaticDemo, cambien p.kind() por Parent.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?

  7. Escriban una clase CommissionEmployee que cobre un sueldo base más el 5 % de sus ventas del mes (todo en céntimos). Agréguenla a PayrollDemo y escriban sus pruebas en EmployeeTest. ¿Tuvieron que modificar Employee o payslip()?

  8. Supongan que en Employee reemplazan public abstract long monthlyPay(); por un método concreto que devuelve 0, y que CommissionEmployee del ejercicio anterior escribe por error monthlypay() (con p minúscula). ¿Qué pasa al compilar, con y sin @Override? ¿Y al ejecutar PayrollDemo?

  9. Corrijan Tag para 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?)

  10. Una clase abstracta Shape tiene abstract double area() y un método concreto String describe() que devuelve "figura de área " + area(). Escriban Circle y RectangleShape, una lista de Shape con 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.

  11. ¿Puede un método abstracto ser private? ¿Y static? ¿Y final? Prueben cada combinación con javac, lean el mensaje de error y expliquen por qué cada una no tiene sentido con lo que vieron sobre el dynamic dispatch.

  12. 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.