Semana 5 — Herencia y la clase Object
Objetivos
- Definir una subclase (subclass) con
extends, reutilizar el constructor de la superclase consuper(...)y hacer override de métodos. - Explicar qué se hereda, qué no, y qué controla
protectedyfinal. - Conocer los métodos de
Objectque toda clase hereda y hacer override detoString,equalsyhashCodede forma correcta. - Comprobar con pruebas el contrato de
equals: reflexivo, simétrico, transitivo, consistente y falso connull.
Apuntes de la semana: notes/week-05.md · Código: code/week-05
Sesión A — Herencia: extends, super, override
Lecturas:
- Eck, sec. 5.5 Inheritance, Polymorphism, and Abstract Classes, subsecciones 5.5.1 a 5.5.3 y 5.5.6.
- Eck, sec. 5.6 this and super, subsecciones 5.6.2 y 5.6.3.
Lecturas complementarias:
- dev.java, Inheritance y Overriding and Hiding Methods.
- JLS 25, §8.4.8 Inheritance, Overriding, and Hiding.
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:
BankAccountySavingsAccount, 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 asuper(...): no compila. - Override frente a overloading: misma firma frente a distinta lista de parámetros.
@Overridele pide al compilador que verifique que de verdad hay un override. Llamar a la versión de la superclase consuper.metodo(). privateno se ve desde la subclase;protectedsí (y desde el paquete). Usarprotectedcon moderación: es parte de la interfaz que se le ofrece a quien extienda la clase.finalen métodos y clases: impedir el override o la herencia. Por quéStringesfinal.- 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:
- Eck, subsec. 5.3.2 The class “Object”.
- Eck, subsec. 10.1.6 Equality and Comparison, solo la parte de
equals(la deComparablese lee en la semana 11). - Eck, subsec. 10.3.3 Hash Tables and Hash Codes, solo los párrafos sobre
hashCode()y su relación conequals.
Lecturas complementarias:
- dev.java, Object as a Superclass.
- API de Java 25:
Object.equalsyObject.hashCode(el contrato completo), yObjects(Objects.equals,Objects.hash). - Opcional: J. Bloch, Effective Java, 3.ª ed., ítems 10 a 12.
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: dosFractionde la semana 3 con el mismo numerador y denominador son iguales. La firma correcta esequals(Object o); error común: escribirequals(Fraction f), que es un overload y no un override (@Overridelo detecta).- El contrato: reflexivo, simétrico, transitivo, consistente,
x.equals(null)esfalse. Uso deinstanceof(con la formao instanceof Fraction f, que declarafsi la prueba es verdadera; los patrones se estudian en la semana 8) o degetClass()dentro deequals. - Herencia y
equals: unPointy unColorPointque agrega color. Comparar coninstanceofen ambos rompe la simetría; congetClass()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 deequalsy no dehashCode, el objeto “desaparece” de unHashSet. 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).
- Extender
BankAccountcon una subclase propia (por ejemplo,CheckingAccount, una cuenta corriente con comisión mensual): constructor consuper(...), un override marcado con@Overridey otro método que llama asuper. - Escribir
toString,equalsyhashCodeparaClockTime, una clase de valor, y probar cada propiedad del contrato con JUnit (una prueba por propiedad, incluida la denully la de un objeto de otro tipo). - Repetir el caso
Point/ColorPoint: encontrar con una prueba la violación de simetría o de transitividad. - Mostrar con una prueba que un objeto con
equalsy sinhashCodeno se encuentra en unHashSet(solo se usanaddycontains; las colecciones se estudian en la semana 11). Para compilar con-Werrorhará falta@SuppressWarnings("overrides"): lean la advertencia antes de callarla.
Entregas
- P1 en curso (Inventario). Entrega en semana 6. Revisar si el producto necesita
equalsyhashCode(¿dos productos con el mismo código son el mismo?) y agregartoStringpara que las pruebas fallidas se lean bien.
Preguntas de repaso
- ¿Por qué
SavingsAccountno compila siBankAccountsolo tieneBankAccount(String id)y el constructor de la subclase no llama asuper? - Expliquen la diferencia entre override y overloading con
equals(Object)yequals(Fraction). ¿Cuál de los dos usaArrayList.contains? - Den un ejemplo de
Point/ColorPointen quep.equals(cp)estrueycp.equals(p)esfalse. ¿Qué propiedad se rompe? - ¿Qué pasa si dos objetos iguales según
equalstienen distintohashCodey se guardan en unHashSet? - ¿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.
// 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());
}
}/** 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 + "]";
}
}/**
* 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;
}
}/**
* 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;
}
}// 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();
}
}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));
}
}/**
* 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;
}
}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());
}
}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());
}
}/** 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 + ")";
}
}// 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));
}
}/**
* 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.
package bank;
public class Account {
protected long balance; // en céntimos
}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.
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());
}
}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.
# 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