06
Herencia e interfaces

Polimorfismo y clases abstractas

Herencia, polimorfismo e interfaces

Semana 6 — Polimorfismo y clases abstractas

Objetivos

  • Distinguir el tipo estático (el de la variable) del tipo dinámico (el del objeto) y predecir qué método se ejecuta.
  • Explicar el dynamic dispatch (la JVM elige el método según el objeto, no según la variable) y por qué permite agregar casos nuevos sin cambiar el código que los usa.
  • Usar upcasting, downcasting e instanceof sabiendo cuándo un casting es señal de mal diseño.
  • Diseñar con clases y métodos abstractos: la superclase fija el qué y las subclases el cómo.

Apuntes de la semana: notes/week-06.md · Código: code/week-06

Sesión A — Polimorfismo y dynamic dispatch

Lecturas:

Lecturas complementarias:

Temas:

  • Sustituir: donde se espera un Notification se puede pasar un EmailNotification, un SmsNotification o un PushNotification (ejemplo propio). Upcasting implícito.
  • Tipo estático y tipo dinámico: el compilador decide qué métodos se pueden llamar (según el tipo de la variable); la JVM decide qué versión se ejecuta (según el objeto). Por eso n.send() hace cosas distintas con cada objeto de una lista ArrayList<Notification>.
  • Qué deja el compilador en el bytecode (invokevirtual Notification.send, visto con javap -c) y cómo la JVM busca el método desde la clase del objeto hacia arriba; la vtable de HotSpot.
  • El overloading se resuelve en compilación; el override, en ejecución. Ejercicio de traza con ambos mezclados (Greeter y LoudGreeter).
  • Downcasting e instanceof: ClassCastException si el objeto no es del tipo pedido. Error común: una cadena de if (x instanceof A) ... else if (x instanceof B) que reemplaza al polimorfismo; agregar un tipo nuevo obliga a tocar cada cadena.
  • Campos y métodos static no son polimórficos: se resuelven por el tipo estático.
  • Llamar desde un constructor a un método del que una subclase puede hacer override: la subclase todavía no terminó de inicializarse. Error común difícil de ver, que javac -Xlint:all marca con [this-escape].

Sesión B — Clases y métodos abstractos

Lecturas:

Lecturas complementarias:

Temas:

  • Una clase que no tiene sentido instanciar: Employee con un método abstracto monthlyPay() y subclases SalariedEmployee y HourlyEmployee (ejemplo propio). new Employee(...) no compila.
  • Método abstracto: la superclase promete la operación y obliga a cada subclase concreta a darla. Una subclase que no la implementa también tiene que ser abstracta.
  • Una clase abstracta puede tener campos, constructores (llamados con super) y métodos concretos que usan a los abstractos: el patrón Template Method. Ejemplo: payslip() concreto arma el texto y llama a monthlyPay().
  • Diagrama UML con clases y métodos abstractos en cursiva (o con <<abstract>>).
  • Error común: poner en la superclase un método concreto que devuelve 0 o lanza una excepción “para que compile”, en lugar de declararlo abstracto: el compilador ya no avisa si una subclase lo olvida.

Laboratorio — Polimorfismo en el proyecto

Código inicial en code/week-06/.

  1. Traza en papel: predecir la salida de LabTrace.java (overloading, override y casting); luego correrlo con java -cp build LabTrace y comparar.
  2. Reemplazar la cadena de instanceof de ShippingCalculator.cost por un override de cost en cada subclase de Shipment (Letter, Parcel, Pallet). ShippingCalculatorTest debe seguir pasando sin cambios.
  3. Diseñar una jerarquía con una clase abstracta para un caso del proyecto (tipos de producto con reglas de precio distintas) y escribir pruebas que recorran una lista de la superclase.
  4. Preparar el arranque de P2: diagrama de clases de la jerarquía de productos.

Entregas

  • P1 entrega.
  • P2 publicado: Catálogo (jerarquía de productos, polimorfismo e interfaces para precios y descuentos). Entrega en semana 9. Ver projects/README.md.

Preguntas de repaso

  1. Notification n = new SmsNotification(); ¿Compila n.send()? ¿Y n.phoneNumber(), si ese método solo existe en SmsNotification? ¿Cuál send() se ejecuta?
  2. Una clase tiene void f(Object o) y void f(String s). Con Object x = "hola"; obj.f(x);, ¿cuál se llama? ¿Por qué no es dynamic dispatch?
  3. ¿Por qué una cadena de instanceof es más difícil de mantener que un override en cada subclase? ¿Cuándo es razonable de todos modos?
  4. ¿Puede una clase abstracta tener constructor si nadie puede hacer new de ella? ¿Para qué sirve?
  5. ¿Qué diferencia hay entre un método abstracto y un método concreto que lanza UnsupportedOperationException?

Código de la semana

Compilar y probar dentro del contenedor del curso con make y make test.

Ejemplos (24)Pruebas (3)Compilación (1)

Ejemplos

Los programas que usan los apuntes y la clase.

CastDemo.javaDescargar CastDemo.java
/** Upcasting, downcasting e instanceof. */
public class CastDemo {

    public static void main(String[] args) {
        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());
        }
    }
}
ConstructorTrap.javaDescargar ConstructorTrap.java
/** El override de describe() corre antes de que LabeledTag termine su constructor. */
public class ConstructorTrap {

    public static void main(String[] args) {
        new LabeledTag("A-7");
    }
}
CostByInstanceof.javaDescargar CostByInstanceof.java
/**
 * El costo de un aviso calculado con una cadena de instanceof. Funciona, pero
 * cada tipo nuevo de aviso obliga a volver aquí (y a cada cadena parecida).
 * Notification.cost(), con un override en cada subclase, hace lo mismo sin cadena.
 */
public class CostByInstanceof {

    public static long cost(Notification n) {
        if (n instanceof EmailNotification) {
            return 0;
        } else if (n instanceof SmsNotification) {
            return 5;
        } else {
            return 1;
        }
    }
}
EmailNotification.javaDescargar EmailNotification.java
/** Un aviso que se envía por correo electrónico. */
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;
    }
}
Employee.javaDescargar Employee.java
/**
 * Un empleado de la bodega. No tiene sentido crear un "empleado en general":
 * cada empleado concreto cobra de una forma, y la clase obliga a decir cuál.
 */
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);
    }
}
Greeter.javaDescargar Greeter.java
/** Overloading: dos métodos con el mismo nombre y distinta lista de parámetros. */
public class Greeter {

    public String greet(Object o) {
        return "Greeter.greet(Object)";
    }

    public String greet(String s) {
        return "Greeter.greet(String)";
    }
}
HourlyEmployee.javaDescargar HourlyEmployee.java
/** Un empleado que cobra por hora trabajada en el mes. */
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)";
    }
}
LabeledTag.javaDescargar LabeledTag.java
/** Hace override de describe() usando un campo propio. Es final: nadie la extiende. */
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;
    }
}
LabTrace.javaDescargar LabTrace.java
/**
 * Laboratorio, paso 1: antes de ejecutar este programa, escriban en papel
 * qué imprime cada línea y por qué. Después ejecútenlo con
 *     java -cp build LabTrace
 * y comparen. (No está en make test, para no ver la salida antes.)
 */
public class LabTrace {

    static class Animal {
        String sound() {
            return "...";
        }

        String meet(Animal other) {
            return "Animal saluda a Animal";
        }

        String meet(Dog other) {
            return "Animal saluda a Dog";
        }
    }

    static class Dog extends Animal {
        @Override
        String sound() {
            return "guau";
        }

        @Override
        String meet(Animal other) {
            return "Dog saluda a Animal";
        }
    }

    static class Puppy extends Dog {
        @Override
        String meet(Dog other) {
            return "Puppy saluda a Dog";
        }
    }

    public static void main(String[] args) {
        Animal a = new Dog();
        Animal b = new Puppy();
        Dog d = new Puppy();

        IO.println("1. " + a.sound());
        IO.println("2. " + b.sound());
        IO.println("3. " + a.meet(a));
        IO.println("4. " + a.meet(d));
        IO.println("5. " + b.meet(a));
        IO.println("6. " + b.meet(d));
        IO.println("7. " + d.meet(b));
        IO.println("8. " + d.meet((Dog) b));
        Object o = d;
        IO.println("9. " + ((Animal) o).meet(new Dog()));
    }
}
Letter.javaDescargar Letter.java
/** Laboratorio: un sobre. Tarifa plana. */
public class Letter extends Shipment {

    public Letter(int grams) {
        super(grams);
    }
}
LoudGreeter.javaDescargar LoudGreeter.java
/** Hace override solo de una de las dos versiones de greet. */
public class LoudGreeter extends Greeter {

    @Override
    public String greet(Object o) {
        return "LoudGreeter.greet(Object)";
    }
}
Notification.javaDescargar Notification.java
/** Un aviso para una persona. El envío general es un aviso en pantalla. */
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;
    }
}
NotificationDemo.javaDescargar NotificationDemo.java
import java.util.ArrayList;

/** Polimorfismo: una lista de Notification con objetos de tres clases. */
public class NotificationDemo {

    public static void main(String[] args) {
        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");
    }
}
Pallet.javaDescargar Pallet.java
/** Laboratorio: una paleta. Tarifa base más un recargo por kilo. */
public class Pallet extends Shipment {

    public Pallet(int grams) {
        super(grams);
    }
}
Parcel.javaDescargar Parcel.java
/** Laboratorio: un paquete. Paga por kilo o fracción. */
public class Parcel extends Shipment {

    public Parcel(int grams) {
        super(grams);
    }
}
PayrollDemo.javaDescargar PayrollDemo.java
import java.util.ArrayList;

/** Una planilla con empleados de dos clases, recorrida como Employee. */
public class PayrollDemo {

    public static void main(String[] args) {
        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");
    }
}
PushNotification.javaDescargar PushNotification.java
/**
 * Un aviso en la pantalla de la aplicación. No hace override de send() ni de cost():
 * usa las versiones que hereda de Notification.
 */
public class PushNotification extends Notification {

    public PushNotification(String user, String text) {
        super(user, text);
    }
}
SalariedEmployee.javaDescargar SalariedEmployee.java
/** Un empleado con sueldo fijo mensual. */
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;
    }
}
Shipment.javaDescargar Shipment.java
/** Laboratorio: un envío de la bodega, con su peso en gramos. */
public class Shipment {
    private final int grams;

    public Shipment(int grams) {
        this.grams = grams;
    }

    public int grams() {
        return grams;
    }
}
ShippingCalculator.javaDescargar ShippingCalculator.java
/**
 * Laboratorio, paso 2: el costo de cada envío se calcula con una cadena de
 * instanceof. Reemplácenla por un override de un método en cada subclase de
 * Shipment; ShippingCalculatorTest debe seguir pasando sin cambios.
 */
public class ShippingCalculator {

    /** Costo en céntimos. */
    public static long cost(Shipment s) {
        int kilos = (s.grams() + 999) / 1000;   // kilo o fracción
        if (s instanceof Letter) {
            return 500;
        } else if (s instanceof Parcel) {
            return 800L * kilos;
        } else if (s instanceof Pallet) {
            return 20_000 + 150L * kilos;
        } else {
            throw new IllegalArgumentException("tipo de envío desconocido");
        }
    }
}
SmsNotification.javaDescargar SmsNotification.java
/** Un aviso que se envía como mensaje de texto a un celular. */
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;
    }
}
StaticDemo.javaDescargar StaticDemo.java
/** Los métodos static y los campos no son polimórficos: cuenta el tipo estático. */
public class StaticDemo {

    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() {             // oculta (no es override) el de Parent
            return "Child.kind()";
        }

        @Override
        String describe() {
            return "Child.describe()";
        }
    }

    // Llamamos a kind() a través de una variable a propósito, para mostrar
    // que se usa el tipo de la variable; javac avisa con [static].
    @SuppressWarnings("static")
    public static void main(String[] args) {
        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);
    }
}
Tag.javaDescargar Tag.java
/**
 * Error común: un constructor que llama a un método del que una subclase puede
 * hacer override. Si lo hace, su versión corre antes de que el constructor de la
 * subclase haya inicializado sus campos.
 */
public class Tag {

    // javac avisa con [this-escape]; se suprime para poder mostrar el efecto.
    @SuppressWarnings("this-escape")
    public Tag() {
        IO.println("Tag(): " + describe());
    }

    public String describe() {
        return "etiqueta";
    }
}
TraceDemo.javaDescargar TraceDemo.java
/**
 * Overloading y override mezclados. El overloading se decide al compilar,
 * con los tipos estáticos de los argumentos; el override, al ejecutar,
 * con la clase del objeto que recibe el mensaje.
 */
public class TraceDemo {

    public static void main(String[] args) {
        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));
    }
}

Pruebas

Comprueban lo que afirman los apuntes; se ejecutan con make test.

EmployeeTest.javaDescargar EmployeeTest.java
import static org.junit.jupiter.api.Assertions.assertEquals;

import java.util.List;
import org.junit.jupiter.api.Test;

class EmployeeTest {

    @Test
    void salariedPaysFixedSalary() {
        Employee e = new SalariedEmployee("alice", 350_000);
        assertEquals(350_000, e.monthlyPay());
    }

    @Test
    void hourlyPaysRateTimesHours() {
        Employee e = new HourlyEmployee("bob", 1_250, 96);
        assertEquals(120_000, e.monthlyPay());
    }

    @Test
    void payslipUsesTheSubclassPay() {
        Employee e = new SalariedEmployee("alice", 350_000);
        assertEquals("alice: S/ 3500.00", e.payslip());
    }

    @Test
    void hourlyPayslipAddsHours() {
        Employee e = new HourlyEmployee("bob", 1_250, 96);
        assertEquals("bob: S/ 1200.00 (96 h)", 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);
    }
}
NotificationTest.javaDescargar NotificationTest.java
import static org.junit.jupiter.api.Assertions.assertEquals;

import java.util.ArrayList;
import org.junit.jupiter.api.Test;

class NotificationTest {

    @Test
    void eachObjectUsesItsOwnSend() {
        Notification email = new EmailNotification("alice@example.com", "Hola");
        Notification sms = new SmsNotification("987654321", "Hola");
        assertEquals("[correo] para alice@example.com: Hola", email.send());
        assertEquals("[SMS] al 987654321: Hola", sms.send());
    }

    @Test
    void pushUsesInheritedSend() {
        Notification push = new PushNotification("bob", "Hola");
        assertEquals("[aviso] bob: Hola", push.send());
    }

    @Test
    void overriddenCostAgreesWithInstanceofChain() {
        ArrayList<Notification> all = new ArrayList<>();
        all.add(new EmailNotification("alice@example.com", "a"));
        all.add(new SmsNotification("987654321", "b"));
        all.add(new PushNotification("bob", "c"));
        for (Notification n : all) {
            assertEquals(CostByInstanceof.cost(n), n.cost());
        }
    }
}
ShippingCalculatorTest.javaDescargar ShippingCalculatorTest.java
import static org.junit.jupiter.api.Assertions.assertEquals;

import org.junit.jupiter.api.Test;

// Laboratorio: estas pruebas fijan el comportamiento actual. Deben seguir
// pasando después de quitar la cadena de instanceof.
class ShippingCalculatorTest {

    @Test
    void letterHasFlatRate() {
        assertEquals(500, ShippingCalculator.cost(new Letter(40)));
    }

    @Test
    void parcelPaysPerKiloOrFraction() {
        assertEquals(800, ShippingCalculator.cost(new Parcel(1_000)));
        assertEquals(1_600, ShippingCalculator.cost(new Parcel(1_001)));
    }

    @Test
    void palletPaysBasePlusPerKilo() {
        assertEquals(20_000 + 150 * 120, ShippingCalculator.cost(new Pallet(120_000)));
    }
}

Compilación

Cómo se compilan y ejecutan los ejemplos.

MakefileDescargar Makefile
# Semana 6: polimorfismo, dynamic dispatch y clases abstractas.
#   make         compila todo a build/
#   make test    corre las demostraciones y las pruebas JUnit
#   make clean   borra build/
# LabTrace no está en DEMOS: es la traza del laboratorio (java -cp build LabTrace).
JAVAC  = javac
JFLAGS = -Xlint:all -Werror
SRC    = $(wildcard *.java)
DEMOS  = NotificationDemo TraceDemo StaticDemo ConstructorTrap CastDemo PayrollDemo

all: build/.stamp

build/.stamp: $(SRC)
	mkdir -p build
	$(JAVAC) $(JFLAGS) -cp $(JUNIT_JAR) -d build $(SRC)
	touch $@

test: all
	@for d in $(DEMOS); do echo "== $$d"; java -cp build $$d || exit 1; done
	java -jar $(JUNIT_JAR) execute --class-path build --scan-class-path \
	  --disable-banner --disable-ansi-colors --details=tree

clean:
	rm -rf build

.PHONY: all test clean