05
Herencia e interfaces

Herencia y la clase Object

Herencia, polimorfismo e interfaces

Semana 5 — Herencia y la clase Object

Objetivos

  • Definir una subclase (subclass) con extends, reutilizar el constructor de la superclase con super(...) y hacer override de métodos.
  • Explicar qué se hereda, qué no, y qué controla protected y final.
  • Conocer los métodos de Object que toda clase hereda y hacer override de toString, equals y hashCode de forma correcta.
  • Comprobar con pruebas el contrato de equals: reflexivo, simétrico, transitivo, consistente y falso con null.

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

Sesión A — Herencia: extends, super, override

Lecturas:

Lecturas complementarias:

Temas:

  • Herencia (inheritance) como relación “es un”: la subclase recibe los campos y métodos de la superclase (superclass) y agrega o cambia comportamiento. Ejemplo propio: BankAccount y SavingsAccount, que agrega una tasa de interés y un método para aplicarla.
  • Constructores: no se heredan. La primera línea de un constructor de la subclase es super(...), explícito o implícito. Error común: la superclase no tiene constructor sin parámetros y la subclase no llama a super(...): no compila.
  • Override frente a overloading: misma firma frente a distinta lista de parámetros. @Override le pide al compilador que verifique que de verdad hay un override. Llamar a la versión de la superclase con super.metodo().
  • private no se ve desde la subclase; protected sí (y desde el paquete). Usar protected con moderación: es parte de la interfaz que se le ofrece a quien extienda la clase.
  • final en métodos y clases: impedir el override o la herencia. Por qué String es final.
  • Jerarquía de clases y diagrama UML con la flecha de herencia. Toda clase hereda, al final, de Object.

Sesión B — Object: toString, equals, hashCode

Lecturas:

Lecturas complementarias:

Temas:

  • toString: la versión heredada (NombreDeClase@hash) y por qué conviene hacerle override para depurar y para los mensajes de las pruebas.
  • equals: la versión heredada es ==. Igualdad de valor: dos Fraction de la semana 3 con el mismo numerador y denominador son iguales. La firma correcta es equals(Object o); error común: escribir equals(Fraction f), que es un overload y no un override (@Override lo detecta).
  • El contrato: reflexivo, simétrico, transitivo, consistente, x.equals(null) es false. Uso de instanceof (con la forma o instanceof Fraction f, que declara f si la prueba es verdadera; los patrones se estudian en la semana 8) o de getClass() dentro de equals.
  • Herencia y equals: un Point y un ColorPoint que agrega color. Comparar con instanceof en ambos rompe la simetría; con getClass() se pierde la sustitución. No hay solución perfecta con herencia: pista para la composición de la semana 7.
  • hashCode: objetos iguales deben tener el mismo hash. Si se hace override de equals y no de hashCode, el objeto “desaparece” de un HashSet. Los records de la semana 3 ya traen los tres métodos generados.
  • Igualdad e identidad de nuevo: qué campos definen la igualdad de una entidad (un código) frente a un valor (todos sus componentes).

Laboratorio — Jerarquías y el contrato de equals

Código inicial en code/week-05/ (BankAccount, Point, ColorPoint, ClockTime; FractionTest sirve de modelo).

  1. Extender BankAccount con una subclase propia (por ejemplo, CheckingAccount, una cuenta corriente con comisión mensual): constructor con super(...), un override marcado con @Override y otro método que llama a super.
  2. Escribir toString, equals y hashCode para ClockTime, una clase de valor, y probar cada propiedad del contrato con JUnit (una prueba por propiedad, incluida la de null y la de un objeto de otro tipo).
  3. Repetir el caso Point/ColorPoint: encontrar con una prueba la violación de simetría o de transitividad.
  4. Mostrar con una prueba que un objeto con equals y sin hashCode no se encuentra en un HashSet (solo se usan add y contains; las colecciones se estudian en la semana 11). Para compilar con -Werror hará falta @SuppressWarnings("overrides"): lean la advertencia antes de callarla.

Entregas

  • P1 en curso (Inventario). Entrega en semana 6. Revisar si el producto necesita equals y hashCode (¿dos productos con el mismo código son el mismo?) y agregar toString para que las pruebas fallidas se lean bien.

Preguntas de repaso

  1. ¿Por qué SavingsAccount no compila si BankAccount solo tiene BankAccount(String id) y el constructor de la subclase no llama a super?
  2. Expliquen la diferencia entre override y overloading con equals(Object) y equals(Fraction). ¿Cuál de los dos usa ArrayList.contains?
  3. Den un ejemplo de Point/ColorPoint en que p.equals(cp) es true y cp.equals(p) es false. ¿Qué propiedad se rompe?
  4. ¿Qué pasa si dos objetos iguales según equals tienen distinto hashCode y se guardan en un HashSet?
  5. ¿Cuándo marcarían una clase como final? ¿Qué pierde quien la usa?

Código de la semana

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

Ejemplos (12)Errores (2)Pruebas (2)Compilación (1)

Ejemplos

Los programas que usan los apuntes y la clase.

AccountDemo.javaDescargar AccountDemo.java
// Una SavingsAccount hereda lo de BankAccount, agrega applyInterest y
// hace override de withdraw y toString.
public class AccountDemo {
    public static void main(String[] args) {
        BankAccount plain = new BankAccount("C-001");
        plain.deposit(50_000);
        IO.println(plain);

        SavingsAccount savings = new SavingsAccount("A-001", 2);
        savings.deposit(100_000);           // heredado de BankAccount
        savings.applyInterest();            // propio de SavingsAccount
        IO.println(savings);                // override de toString

        for (int i = 1; i <= 4; i++) {      // override de withdraw
            IO.println("retiro " + i + ": " + savings.withdraw(1_000));
        }
        IO.println("saldo final: " + savings.balance());
    }
}
BankAccount.javaDescargar BankAccount.java
/** Una cuenta bancaria: el saldo, en céntimos, nunca es negativo. */
public class BankAccount {
    private final String id;
    private long balance;               // saldo en céntimos

    public BankAccount(String id) {
        if (id == null || id.isBlank()) {
            throw new IllegalArgumentException("código vacío");
        }
        this.id = id;
        this.balance = 0;
    }

    /** Agrega dinero a la cuenta. El monto tiene que ser positivo. */
    public void deposit(long cents) {
        if (cents <= 0) {
            throw new IllegalArgumentException("monto no positivo: " + cents);
        }
        balance += cents;
    }

    /** Retira dinero si alcanza el saldo; devuelve si se pudo retirar. */
    public boolean withdraw(long cents) {
        if (cents <= 0) {
            throw new IllegalArgumentException("monto no positivo: " + cents);
        }
        if (cents > balance) {
            return false;
        }
        balance -= cents;
        return true;
    }

    public String id() {
        return id;
    }

    public long balance() {
        return balance;
    }

    @Override
    public String toString() {
        return "BankAccount[" + id + ", saldo " + balance + "]";
    }
}
ClockTime.javaDescargar ClockTime.java
/**
 * Código inicial del laboratorio: una hora del día (horas y minutos).
 * Le faltan toString, equals y hashCode; el laboratorio los agrega con sus
 * pruebas.
 */
public final class ClockTime {
    private final int hours;
    private final int minutes;

    public ClockTime(int hours, int minutes) {
        if (hours < 0 || hours > 23 || minutes < 0 || minutes > 59) {
            throw new IllegalArgumentException("hora inválida: " + hours + ":" + minutes);
        }
        this.hours = hours;
        this.minutes = minutes;
    }

    public int hours() {
        return hours;
    }

    public int minutes() {
        return minutes;
    }

    /** Minutos desde la medianoche. */
    public int toMinutes() {
        return hours * 60 + minutes;
    }
}
ColorPoint.javaDescargar ColorPoint.java
/**
 * Un punto con color. Su equals también usa instanceof, y por eso la
 * igualdad entre Point y ColorPoint deja de ser simétrica (ver PointDemo).
 */
public class ColorPoint extends Point {
    private final String color;

    public ColorPoint(int x, int y, String color) {
        super(x, y);
        this.color = color;
    }

    public String color() {
        return color;
    }

    @Override
    public boolean equals(Object o) {
        return o instanceof ColorPoint cp && super.equals(cp) && color.equals(cp.color);
    }

    @Override
    public int hashCode() {
        return 31 * super.hashCode() + color.hashCode();
    }

    @Override
    public String toString() {
        return super.toString() + " " + color;
    }
}
ConstructorOrder.javaDescargar ConstructorOrder.java
// En qué orden corren los constructores de una jerarquía: el de la
// superclase termina antes de que empiece el cuerpo del de la subclase.
public class ConstructorOrder {
    static class Vehicle {
        Vehicle() {
            IO.println("Vehicle(): termina la parte de Vehicle");
        }
    }

    static class Car extends Vehicle {
        Car() {
            // aquí el compilador pone super(); aunque no lo escribamos
            IO.println("Car(): termina la parte de Car");
        }
    }

    static class SportsCar extends Car {
        SportsCar() {
            super();
            IO.println("SportsCar(): termina la parte de SportsCar");
        }
    }

    public static void main(String[] args) {
        new SportsCar();
    }
}
EqualsOverloadDemo.javaDescargar EqualsOverloadDemo.java
import java.util.ArrayList;

// Error común: equals(WrongFraction) es un overload, no un override. Quien
// llama con un Object (como ArrayList.contains) usa el equals de Object.
public class EqualsOverloadDemo {
    static final class WrongFraction {
        private final long num;
        private final long den;

        WrongFraction(long num, long den) {
            this.num = num;
            this.den = den;
        }

        // Sin @Override: el compilador no avisa que esto no es un override.
        public boolean equals(WrongFraction f) {
            return num == f.num && den == f.den;
        }

        @Override
        public int hashCode() {
            return 31 * Long.hashCode(num) + Long.hashCode(den);
        }
    }

    public static void main(String[] args) {
        WrongFraction a = new WrongFraction(1, 2);
        WrongFraction b = new WrongFraction(1, 2);
        Object o = b;
        IO.println("a.equals(b): " + a.equals(b));   // elige equals(WrongFraction)
        IO.println("a.equals(o): " + a.equals(o));   // elige equals(Object)

        ArrayList<WrongFraction> list = new ArrayList<>();
        list.add(a);
        IO.println("list.contains(b): " + list.contains(b));
    }
}
Fraction.javaDescargar Fraction.java
/**
 * Una fracción inmutable. Invariante: el denominador es positivo y la
 * fracción está reducida, así que dos fracciones iguales tienen los mismos
 * campos.
 */
public final class Fraction {
    private final long num;
    private final long den;

    public Fraction(long num, long den) {
        if (den == 0) {
            throw new IllegalArgumentException("denominador cero");
        }
        if (den < 0) {                  // el signo va en el numerador
            num = -num;
            den = -den;
        }
        long g = gcd(Math.abs(num), den);
        this.num = num / g;
        this.den = den / g;
    }

    private static long gcd(long a, long b) {
        while (b != 0) {
            long r = a % b;
            a = b;
            b = r;
        }
        return a;
    }

    public long num() {
        return num;
    }

    public long den() {
        return den;
    }

    public Fraction plus(Fraction other) {
        return new Fraction(num * other.den + other.num * den, den * other.den);
    }

    @Override
    public boolean equals(Object o) {
        if (this == o) {
            return true;                // el mismo objeto
        }
        if (!(o instanceof Fraction f)) {
            return false;               // null u otro tipo
        }
        return num == f.num && den == f.den;
    }

    @Override
    public int hashCode() {
        return 31 * Long.hashCode(num) + Long.hashCode(den);
    }

    @Override
    public String toString() {
        return den == 1 ? Long.toString(num) : num + "/" + den;
    }
}
FractionDemo.javaDescargar FractionDemo.java
import java.util.ArrayList;
import java.util.HashSet;

// equals, hashCode y toString de Fraction en acción.
public class FractionDemo {
    public static void main(String[] args) {
        Fraction a = new Fraction(1, 2);
        Fraction b = new Fraction(2, 4);
        IO.println("a = " + a + ", b = " + b);
        IO.println("a == b: " + (a == b));
        IO.println("a.equals(b): " + a.equals(b));
        IO.println("hash: " + a.hashCode() + " y " + b.hashCode());

        ArrayList<Fraction> list = new ArrayList<>();
        list.add(a);
        IO.println("list.contains(b): " + list.contains(b));

        HashSet<Fraction> set = new HashSet<>();
        set.add(a);
        set.add(b);
        IO.println("set: " + set + ", tamaño " + set.size());
    }
}
HashDemo.javaDescargar HashDemo.java
import java.util.HashSet;

// Error común: hacer override de equals y no de hashCode. Dos claves iguales
// caen en buckets distintos del HashSet, y contains no encuentra la segunda.
public class HashDemo {
    // javac -Xlint:all avisa [overrides]; aquí lo callamos a propósito.
    @SuppressWarnings("overrides")
    static final class Code {
        private final String value;

        Code(String value) {
            this.value = value;
        }

        @Override
        public boolean equals(Object o) {
            return o instanceof Code c && value.equals(c.value);
        }
        // falta hashCode: se hereda el de Object, que depende del objeto
    }

    public static void main(String[] args) {
        Code a = new Code("P-100");
        Code b = new Code("P-100");
        IO.println("a.equals(b): " + a.equals(b));
        IO.println("mismo hash: " + (a.hashCode() == b.hashCode()));

        HashSet<Code> set = new HashSet<>();
        set.add(a);
        IO.println("set.contains(a): " + set.contains(a));
        IO.println("set.contains(b): " + set.contains(b));
        set.add(b);
        IO.println("tamaño: " + set.size());
    }
}
Point.javaDescargar Point.java
/** Un punto del plano con coordenadas enteras. */
public class Point {
    private final int x;
    private final int y;

    public Point(int x, int y) {
        this.x = x;
        this.y = y;
    }

    public int x() {
        return x;
    }

    public int y() {
        return y;
    }

    @Override
    public boolean equals(Object o) {
        return o instanceof Point p && x == p.x && y == p.y;
    }

    @Override
    public int hashCode() {
        return 31 * x + y;
    }

    @Override
    public String toString() {
        return "(" + x + ", " + y + ")";
    }
}
PointDemo.javaDescargar PointDemo.java
// Point y ColorPoint comparan con instanceof: la igualdad no es simétrica.
public class PointDemo {
    public static void main(String[] args) {
        Point p = new Point(1, 2);
        ColorPoint red = new ColorPoint(1, 2, "rojo");
        IO.println("p.equals(red): " + p.equals(red));
        IO.println("red.equals(p): " + red.equals(p));
    }
}
SavingsAccount.javaDescargar SavingsAccount.java
/**
 * Una cuenta de ahorros: es una BankAccount que además paga un interés
 * mensual y permite a lo más tres retiros por mes.
 */
public class SavingsAccount extends BankAccount {
    public static final int MAX_WITHDRAWALS = 3;

    private final int ratePercent;      // interés mensual, en %
    private int withdrawals;            // retiros hechos este mes

    public SavingsAccount(String id, int ratePercent) {
        super(id);                      // primero se construye la parte BankAccount
        if (ratePercent < 0) {
            throw new IllegalArgumentException("tasa negativa: " + ratePercent);
        }
        this.ratePercent = ratePercent;
        this.withdrawals = 0;
    }

    /** Agrega el interés del mes y reinicia el contador de retiros. */
    public void applyInterest() {
        long interest = balance() * ratePercent / 100;
        if (interest > 0) {
            deposit(interest);
        }
        withdrawals = 0;
    }

    /** Igual que en BankAccount, pero solo MAX_WITHDRAWALS veces por mes. */
    @Override
    public boolean withdraw(long cents) {
        if (withdrawals == MAX_WITHDRAWALS) {
            return false;
        }
        boolean ok = super.withdraw(cents);
        if (ok) {
            withdrawals++;
        }
        return ok;
    }

    public int ratePercent() {
        return ratePercent;
    }

    @Override
    public String toString() {
        return super.toString() + " (ahorros, " + ratePercent + " % mensual)";
    }
}

Errores

Ejemplos con errores a propósito: no compilan, fallan o dan un resultado incorrecto. Los apuntes explican cada uno.

errores/protegido/bank/Account.javaDescargar errores/protegido/bank/Account.java
package bank;

public class Account {
    protected long balance;   // en céntimos
}
errores/protegido/savings/Savings.javaDescargar errores/protegido/savings/Savings.java
package savings;

import bank.Account;

// No compila: desde otro paquete, una subclase usa el campo protegido de su
// propio tipo (this, otro Savings), pero no el de un Account cualquiera.
public class Savings extends Account {
    void addInterest() {
        balance += balance / 100;   // bien: es this.balance
    }

    void copyFrom(Savings other) {
        balance = other.balance;    // bien: other es un Savings
    }

    void drain(Account other) {
        other.balance = 0;          // error: other puede ser cualquier Account
    }
}

Pruebas

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

FractionTest.javaDescargar FractionTest.java
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;

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

// Una prueba por cada propiedad del contrato de equals y hashCode.
class FractionTest {
    private final Fraction a = new Fraction(1, 2);
    private final Fraction b = new Fraction(2, 4);
    private final Fraction c = new Fraction(-3, -6);

    @Test
    void reflexive() {
        assertTrue(a.equals(a));
    }

    @Test
    void symmetric() {
        assertTrue(a.equals(b));
        assertTrue(b.equals(a));
    }

    @Test
    void transitive() {
        assertTrue(a.equals(b));
        assertTrue(b.equals(c));
        assertTrue(a.equals(c));
    }

    @Test
    void consistent() {
        for (int i = 0; i < 3; i++) {
            assertTrue(a.equals(b));
        }
    }

    @Test
    void notEqualToNull() {
        assertFalse(a.equals(null));
    }

    @Test
    void notEqualToOtherType() {
        assertFalse(a.equals("1/2"));
    }

    @Test
    void differentValuesAreNotEqual() {
        assertNotEquals(new Fraction(1, 3), a);
    }

    @Test
    void equalObjectsHaveEqualHashCodes() {
        assertEquals(a.hashCode(), b.hashCode());
        assertEquals(a.hashCode(), c.hashCode());
    }

    @Test
    void hashSetFindsAnEqualFraction() {
        HashSet<Fraction> set = new HashSet<>();
        set.add(a);
        assertTrue(set.contains(new Fraction(5, 10)));
    }

    @Test
    void toStringShowsReducedForm() {
        assertEquals("1/2", b.toString());
        assertEquals("-2", new Fraction(4, -2).toString());
    }
}
SavingsAccountTest.javaDescargar SavingsAccountTest.java
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;

import org.junit.jupiter.api.Test;

class SavingsAccountTest {

    @Test
    void inheritsDepositAndBalance() {
        SavingsAccount s = new SavingsAccount("A-001", 2);
        s.deposit(10_000);
        assertEquals(10_000, s.balance());
        assertEquals("A-001", s.id());
    }

    @Test
    void applyInterestAddsMonthlyRate() {
        SavingsAccount s = new SavingsAccount("A-001", 2);
        s.deposit(100_000);
        s.applyInterest();
        assertEquals(102_000, s.balance());
    }

    @Test
    void fourthWithdrawalInAMonthIsRejected() {
        SavingsAccount s = new SavingsAccount("A-001", 0);
        s.deposit(10_000);
        for (int i = 0; i < SavingsAccount.MAX_WITHDRAWALS; i++) {
            assertTrue(s.withdraw(100));
        }
        assertFalse(s.withdraw(100));
        assertEquals(9_700, s.balance());
    }

    @Test
    void applyInterestResetsWithdrawals() {
        SavingsAccount s = new SavingsAccount("A-001", 0);
        s.deposit(10_000);
        for (int i = 0; i < SavingsAccount.MAX_WITHDRAWALS; i++) {
            s.withdraw(100);
        }
        s.applyInterest();
        assertTrue(s.withdraw(100));
    }

    @Test
    void failedWithdrawalDoesNotCount() {
        SavingsAccount s = new SavingsAccount("A-001", 0);
        assertFalse(s.withdraw(500));       // no hay saldo
        s.deposit(10_000);
        for (int i = 0; i < SavingsAccount.MAX_WITHDRAWALS; i++) {
            assertTrue(s.withdraw(100));
        }
    }

    @Test
    void constructorRejectsNegativeRate() {
        assertThrows(IllegalArgumentException.class, () -> new SavingsAccount("A-001", -1));
    }

    @Test
    void constructorOfSuperclassStillValidatesId() {
        assertThrows(IllegalArgumentException.class, () -> new SavingsAccount(" ", 2));
    }

    @Test
    void toStringExtendsTheInheritedOne() {
        SavingsAccount s = new SavingsAccount("A-001", 2);
        assertEquals("BankAccount[A-001, saldo 0] (ahorros, 2 % mensual)", s.toString());
    }
}

Compilación

Cómo se compilan y ejecutan los ejemplos.

MakefileDescargar Makefile
# Semana 5: herencia y los métodos de Object (toString, equals, hashCode).
#   make         compila todo a build/
#   make test    corre las demostraciones, el ejemplo que no compila y las pruebas JUnit
#   make errores muestra el error de compilación de errores/protegido/ (protected entre paquetes)
#   make clean   borra build/
JAVAC  = javac
JFLAGS = -Xlint:all -Werror
SRC    = $(wildcard *.java)
DEMOS  = AccountDemo ConstructorOrder FractionDemo EqualsOverloadDemo PointDemo HashDemo
PROTEGIDO = $(sort $(shell find errores/protegido -name '*.java'))

all: build/.stamp

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

test: all errores
	@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

# errores/protegido/ no debe compilar, y debe fallar por el acceso protected.
errores: all
	@echo "== errores/protegido (no debe compilar)"
	@if $(JAVAC) $(JFLAGS) -d build/errores $(PROTEGIDO) 2> build/errores.txt; then exit 1; fi
	@cat build/errores.txt
	@grep -q 'has protected access' build/errores.txt

clean:
	rm -rf build

.PHONY: all test errores clean