Apuntes · Capítulo 5Herencia e interfaces
Herencia y la clase Object
Herencia y la clase Object
Las primeras cuatro semanas construimos clases una por una: TransitCard,
Fraction, BankAccount, la Playlist que encapsula su ArrayList<Song>.
Cada clase protegía su invariante y ofrecía una interfaz pública pequeña. En
la semana 4 dibujamos diagramas de clases con asociaciones entre ellas: una
clase tiene o usa a otra.
Esta semana aparece una relación distinta: una clase que es un caso
particular de otra. Una cuenta de ahorros es una cuenta bancaria que además
paga intereses. En lugar de copiar la clase BankAccount y agregarle cosas,
Java permite escribir solo la diferencia: eso es la herencia
(inheritance). La primera parte del capítulo cuenta cómo funciona: extends,
los constructores y super(...), el override de métodos, protected y
final.
La segunda parte trata de una clase que todos heredan sin pedirlo: Object.
De ella vienen toString, equals y hashCode, tres métodos de los que casi
toda clase de valor tiene que hacer override, y que tienen reglas (un
contrato) que hay que respetar. Ahí cerramos el tema que abrimos en la semana 2: la
diferencia entre que dos objetos sean el mismo y que sean iguales.
Lectura. Este capítulo acompaña a Eck, Introduction to Programming Using Java (versión 9): las subsecciones 5.5.1 a 5.5.3 y 5.5.6 (herencia, jerarquías y
final), 5.6.2 y 5.6.3 (super), la 5.3.2 (la claseObject), y los párrafos sobreequalsde la 10.1.6 y sobrehashCodede la 10.3.3. El contrato completo deequalsyhashCodeestá en la documentación deObject. El código del capítulo está encode/week-05y se prueba conmake testdentro del contenedor del curso.
Ruta de lectura.
- Antes de clase (obligatorio, 15–20 min): El problema: copiar y pegar,
extends: escribir solo la diferencia y la introducción de La claseObject.- En clase: en la sesión A, el resto de Herencia: una clase a partir de otra, de Qué se hereda y qué no a
final: hasta aquí llega la herencia; en la sesión B, La claseObject:toString,equalsy su contrato, Herencia yequals:PointyColorPointyhashCode. En el laboratorio, El código de la semana ymake test.- Después de clase: Identidad e igualdad, otra vez, el Resumen y los Ejercicios. Para ampliar, las secciones de Eck de la nota Lectura.
Herencia: una clase a partir de otra
El problema: copiar y pegar
Partimos de una BankAccount como la de la semana 3: un código, un saldo en
céntimos que nunca es negativo, y métodos con intención en lugar de setters
(BankAccount.java):
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;
}
public void deposit(long cents) { ... } // rechaza montos no positivos
public boolean withdraw(long cents) { ... } // false si no alcanza el saldo
public String id() { return id; }
public long balance() { return balance; }
}
Ahora el banco quiere cuentas de ahorros: hacen todo lo que hace una cuenta,
y además ganan un interés mensual y permiten a lo más tres retiros por mes.
La solución ingenua es copiar BankAccount.java en SavingsAccount.java,
cambiarle el nombre y agregar lo nuevo. Funciona el primer día. Después
alguien corrige un error en la validación de deposit de una de las dos
copias y se olvida de la otra, y desde ese momento las dos clases se
comportan distinto sin que nadie lo haya decidido. Además, un método que
recibe una BankAccount no acepta una SavingsAccount: para el compilador
son tipos que no tienen nada que ver.
extends: escribir solo la diferencia
Con herencia, SavingsAccount extiende a BankAccount
(SavingsAccount.java):
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;
}
// ratePercent() y toString() más abajo
}
BankAccount es la superclase (superclass) y SavingsAccount la
subclase (subclass). La subclase hereda los campos y los métodos de
la superclase, y puede hacer tres cosas con ellos:
- Usarlos tal cual.
SavingsAccountno declaradeposit,balanceniid, pero sus objetos los entienden:applyInterestllama abalance()y adeposit(...)como si fueran suyos, porque lo son. - Agregar campos y métodos nuevos:
ratePercent,withdrawals,applyInterest(). - Hacer override de un método heredado, es decir, dar su propia
versión:
withdrawcuenta los retiros ytoStringagrega la tasa.
En el diagrama de clases UML la herencia se dibuja con una línea que termina
en un triángulo hueco apuntando a la superclase. No hay que repetir en la
subclase los métodos heredados; solo se escriben los nuevos y los
overrides. Y arriba de todo hay una clase que no escribimos: Object.
Una clase sin extends extiende Object de forma implícita, así que toda
clase de Java desciende de Object. De ella vienen toString, equals y
hashCode, el tema de la segunda mitad del capítulo.
AccountDemo.java usa los tres tipos de
método:
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());
$ java -cp build AccountDemo
BankAccount[C-001, saldo 50000]
BankAccount[A-001, saldo 102000] (ahorros, 2 % mensual)
retiro 1: true
retiro 2: true
retiro 3: true
retiro 4: false
saldo final: 99000
(La primera línea es una BankAccount común que el programa crea antes.) El
interés del 2 % sobre 1000 soles agregó 20 soles; los tres primeros retiros
pasaron y el cuarto no, aunque el saldo alcanzaba.
Idea clave. La herencia expresa una relación “es un”: una cuenta de ahorros es una cuenta bancaria. Si la frase suena falsa (“una playlist es un
ArrayList”, “un auto es un motor”), la herencia no es la herramienta; lo que hay es una relación “tiene un”, y eso se resuelve con un campo. En la semana 7 volvemos a esta regla con más cuidado.
Qué se hereda y qué no
Un objeto SavingsAccount tiene cuatro campos: id y balance, que
vienen de BankAccount, más ratePercent y withdrawals. Los campos
heredados están dentro del objeto, pero siguen siendo private de
BankAccount: el código de SavingsAccount no puede nombrarlos. Si
applyInterest intentara sumar directamente al saldo:
public void applyInterest() {
balance += balance * 2 / 100;
}
$ javac -Xlint:all -d build *.java
SavingsAccount.java:7: error: balance has private access in BankAccount
balance += balance * 2 / 100;
^
SavingsAccount.java:7: error: balance has private access in BankAccount
balance += balance * 2 / 100;
^
2 errors
Esto no es un defecto de la herencia: es el encapsulamiento de la semana 3
funcionando. El invariante “el saldo nunca es negativo” es responsabilidad de
BankAccount, y lo sigue siendo aunque alguien la extienda. La subclase
cambia el saldo como cualquier otro cliente, con deposit y withdraw, que
validan. Si la subclase pudiera escribir balance directamente, cada subclase
que alguien escriba en el futuro sería un lugar más donde el invariante podría
romperse.
Tampoco se heredan los constructores. SavingsAccount no tiene un
constructor SavingsAccount(String id) solo porque BankAccount lo tenga:
cada clase declara los suyos.
Constructores y super(...)
Un objeto SavingsAccount contiene una “parte BankAccount”, y esa parte
tiene que quedar bien construida, con su invariante establecido, antes de que
la subclase haga nada. Por eso la primera instrucción de todo constructor
de una subclase es una llamada a un constructor de la superclase,
super(...). En SavingsAccount la escribimos: super(id) le pasa el código
al constructor de BankAccount, que lo valida y deja el saldo en cero.
Si no se escribe, el compilador inserta super();, sin argumentos. Eso
funciona solo si la superclase tiene un constructor sin parámetros.
BankAccount no lo tiene, así que olvidarse del super(id) no compila:
public SavingsAccount(String id, int ratePercent) {
this.ratePercent = ratePercent;
}
$ javac -Xlint:all -d build *.java
SavingsAccount.java:4: error: constructor BankAccount in class BankAccount cannot be applied to given types;
public SavingsAccount(String id, int ratePercent) {
^
required: String
found: no arguments
reason: actual and formal argument lists differ in length
1 error
Error común. Leer ese mensaje y pensar que el error está en la línea de
SavingsAccount. El compilador dice que el constructor deBankAccountno se puede llamar sin argumentos: la llamada que falla es elsuper()invisible que él mismo insertó. La solución no es agregarle aBankAccountun constructor vacío (eso permitiría cuentas sin código, y rompería su invariante), sino escribirsuper(id).
La cadena sigue hacia arriba: el constructor de BankAccount tampoco
escribe super(...), así que empieza con un super() implícito que llama al
de Object. Los constructores se llaman de abajo hacia arriba, pero sus
cuerpos terminan de arriba hacia abajo.
ConstructorOrder.java lo muestra
con tres clases que solo imprimen:
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");
}
}
$ java -cp build ConstructorOrder
Vehicle(): termina la parte de Vehicle
Car(): termina la parte de Car
SportsCar(): termina la parte de SportsCar
main hace new SportsCar(), y lo primero que se imprime es la línea de
Vehicle. Con la cuenta de ahorros pasa lo mismo:


Es el new paso a paso de la semana 1 con un detalle más: el paso
“ejecutar el constructor” ahora es una cadena de constructores. Fíjense en
el paso 1: new reserva espacio para los cuatro campos de una vez, también
para los heredados, aunque SavingsAccount no pueda nombrarlos.
En Java. En el bytecode, la llamada a
super(id)es una instruccióninvokespecialal constructor deBankAccount, que la JVM llama<init>. Es la primera instrucción del constructor; recién después viene la validación de la tasa:$ javap -c -cp build SavingsAccount ... public SavingsAccount(java.lang.String, int); Code: 0: aload_0 1: aload_1 2: invokespecial #1 // Method BankAccount."<init>":(Ljava/lang/String;)V 5: iload_2 6: ifge 23 ...En la semana 1 vimos la misma instrucción en
Hello, llamando aObject."<init>": también la clase más simple llama al constructor de su superclase.
Override de un método
Hacer override es declarar en la subclase un método con la misma
firma que uno heredado: mismo nombre y mismos tipos de parámetros. Cuando
se le envía withdraw(1_000) a un objeto SavingsAccount, se ejecuta la
versión de SavingsAccount, no la de BankAccount.
La versión nueva no tiene que rehacer todo. super.withdraw(cents) llama a
la versión de la superclase, que verifica el monto y el saldo; la subclase
solo agrega su regla (el límite de retiros) antes y su contabilidad (contar el
retiro) después. Lo mismo hace toString:
@Override
public String toString() {
return super.toString() + " (ahorros, " + ratePercent + " % mensual)";
}
@Override es una anotación: le pide al compilador que verifique que el
método de verdad hace override de uno heredado. Si nos equivocamos en el nombre o
en los parámetros (withdraw(int) en lugar de withdraw(long), tostring()
en lugar de toString()), sin @Override el compilador acepta el método
como uno nuevo, y el programa sigue ejecutando la versión heredada sin que
nadie se entere. Con @Override, no compila. Pónganla siempre.
No hay que confundir el override con el overloading (varios métodos con el mismo nombre y distintos parámetros), que vimos en la semana 2:
| Override | Overloading | |
|---|---|---|
| Firma | La misma que el método heredado | El mismo nombre con otros parámetros |
| Dónde | En una subclase | En la misma clase (o en una subclase) |
| Qué hace | Reemplaza la versión heredada | Agrega otro método, que convive con el original |
| Quién decide cuál se ejecuta | El objeto que recibe el mensaje, al ejecutar | El compilador, por los tipos de los argumentos |
La última fila es el tema central de la semana 6. Por ahora basta con la
regla práctica: si quieren cambiar lo que hace un método heredado, la firma
tiene que ser idéntica y @Override lo comprueba.
Un override tampoco puede ser menos accesible que la
original: si toString() es public en Object, tiene que ser public en
toda subclase.
public class Shout {
@Override
String toString() {
return "HOLA";
}
}
$ javac -Xlint:all -d build Shout.java
Shout.java:3: error: toString() in Shout cannot override toString() in Object
String toString() {
^
attempting to assign weaker access privileges; was public
1 error
La razón es la sustitución: cualquier código que tenga una referencia a un
Object puede llamar a toString(), y eso tiene que seguir valiendo si el
objeto resulta ser un Shout.
protected: lo que se ofrece a las subclases
Entre private (solo esta clase) y public (todos) hay dos niveles más. El
acceso de paquete (sin modificador), de la semana 3, deja entrar a las clases
del mismo paquete. protected deja entrar a las del mismo paquete y
a las subclases, aunque estén en otro paquete.
| Modificador | La misma clase | El mismo paquete | Una subclase en otro paquete | Cualquiera |
|---|---|---|---|---|
private |
sí | no | no | no |
| (ninguno) | sí | sí | no | no |
protected |
sí | sí | sí | no |
public |
sí | sí | sí | sí |
La columna “una subclase en otro paquete” tiene letra chica. Desde otro
paquete, la subclase puede usar un campo o método de instancia protected de
su propio tipo: el de this o el de otro objeto de su clase. No puede
usarlo a través de una referencia de la superclase, porque ese objeto podría
ser de otra subclase que no es asunto suyo. En
errores/protegido/,
bank.Account declara protected long balance, y Savings está en otro
paquete:
package savings;
import bank.Account;
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
}
}
$ javac -Xlint:all -d build errores/protegido/bank/Account.java errores/protegido/savings/Savings.java
errores/protegido/savings/Savings.java:17: error: balance has protected access in Account
other.balance = 0; // error: other puede ser cualquier Account
^
1 error
Dentro del mismo paquete esta restricción no existe: ahí protected incluye el
acceso de paquete.
Podríamos declarar protected long balance en BankAccount para que
applyInterest lo modifique directamente. No lo hacemos, por la misma razón
de antes: un campo protected es tan difícil de proteger como uno public,
porque cualquiera puede escribir una subclase. Úsenlo con moderación:
protected es parte de la interfaz que la clase ofrece a quien la extienda,
y una vez que alguien lo usa ya no se puede cambiar sin romperle el código.
Es más razonable para métodos que una subclase pueda necesitar y que el
resto del programa no (un método auxiliar que valida, por ejemplo) que para
campos.
Error común. Volver
protectedtodos los campos “por si alguna subclase los necesita”. Eso deshace el encapsulamiento: cualquier subclase, escrita por cualquiera, puede dejar el objeto en un estado inválido. Empiecen conprivatey abran solo lo que una subclase concreta de verdad necesita.
final: hasta aquí llega la herencia
final ya lo usamos en campos (no cambian después del constructor). En un
método, final impide hacerle override; en una clase, impide
extenderla.
¿Para qué cerrar una clase? Porque una clase que se puede extender le deja a
cualquiera la posibilidad de cambiar su comportamiento. String es final:
si no lo fuera, alguien podría escribir una subclase de String cuyo
length() mienta, o que cambie su contenido después de creada, y pasarla a
cualquier método que espere un String. Todo el código que confía en que un
String es inmutable (las claves de un mapa, los nombres de archivo que se
validan antes de abrirlos) quedaría expuesto.
public class Name extends String {
}
$ javac -Xlint:all -d build Name.java
Name.java:1: error: cannot inherit from final String
public class Name extends String {
^
La Fraction de este capítulo también es final: es una clase de valor
inmutable, y no hay ninguna razón para que alguien la extienda (en la segunda
parte veremos que, además, eso evita problemas con equals). Los records de
la semana 3 son final automáticamente.
Idea clave. Diseñar una clase para que otros la extiendan es trabajo extra: hay que decidir qué es
protected, documentar de qué métodos se puede hacer override y qué espera la clase de ellos. Si una clase no fue pensada para eso, es razonable marcarlafinal. Quien la usa pierde la posibilidad de extenderla, pero puede envolverla en otra clase (la composición de la semana 7).
La clase Object
Como toda clase hereda de Object, todo objeto entiende sus métodos. Los
que importan esta semana son tres:
| Método | Qué hace la versión de Object |
Cuándo hacerle override |
|---|---|---|
String toString() |
Nombre de la clase, @ y un número en hexadecimal |
Casi siempre |
boolean equals(Object o) |
this == o: identidad |
En clases de valor |
int hashCode() |
Un número que depende del objeto, no de sus campos | Siempre que se hace override de equals |
toString: cómo se ve un objeto
IO.println(x) y la concatenación "..." + x llaman a x.toString(). La
versión heredada no dice mucho. La ClockTime del laboratorio todavía no
hace override de toString:
jshell> /open ClockTime.java
jshell> ClockTime t = new ClockTime(9, 30)
t ==> ClockTime@31221be2
jshell> t.toString()
$3 ==> "ClockTime@31221be2"
jshell> Integer.toHexString(t.hashCode())
$4 ==> "31221be2"
El número después de la @ es el hashCode() del objeto en hexadecimal (la
documentación de Object.toString lo define así). No tiene nada que ver con
los campos: dos objetos con la misma hora tendrían números distintos, y nada
garantiza que el número se repita de una ejecución a otra. Es lo que mostraba
jshell con la TransitCard de la semana 1: jshell imprime los objetos con
toString.
Hacer override de toString cuesta tres líneas y paga mucho: para depurar
(imprimir un objeto y ver su estado), y para leer las pruebas que fallan,
porque JUnit muestra los objetos con toString. Un mensaje
expected: <1/2> but was: <2/3> se entiende; uno
expected: <Fraction@1b6d3586> but was: <Fraction@4554617c> no. La de
Fraction muestra la fracción reducida:
@Override
public String toString() {
return den == 1 ? Long.toString(num) : num + "/" + den;
}
equals: igualdad de valor
En la semana 2 vimos que == entre referencias pregunta por la identidad:
si las dos flechas llegan al mismo objeto. Y que para preguntar si dos
objetos son iguales está equals. Pero ¿qué significa “iguales” para una
clase que escribimos nosotros? Java no puede saberlo, así que la versión de
Object hace lo único seguro: compara identidades.
public boolean equals(Object obj) {
return (this == obj);
}
Para una clase de valor, como Fraction, eso no sirve: 1/2 y 2/4 son
la misma fracción aunque se hayan creado con dos new distintos. Como
nuestra Fraction mantiene el invariante “denominador positivo y reducida”
(de la semana 3), dos fracciones iguales tienen los mismos campos, y
equals puede compararlos directamente
(Fraction.java):
@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;
}
Por partes:
- El parámetro es
Object, noFraction: es la firma deObject.equals, y solo con esa firma hay un override. this == oes un atajo: un objeto siempre es igual a sí mismo.o instanceof Fraction fhace dos cosas: pregunta sioapunta a unaFractiony, si es así, declara la variablefde tipoFractionque apunta al mismo objeto. Connulldafalse, así que también cubre ese caso. Esta forma deinstanceofcon una variable es un patrón; los patrones se estudian en la semana 8.- Dentro de
Fractionse puede leerf.numaunque seaprivate: el acceso privado es por clase, no por objeto.
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());
$ java -cp build FractionDemo
a = 1/2, b = 1/2
a == b: false
a.equals(b): true
hash: 33 y 33
list.contains(b): true
set: [1/2], tamaño 1
Dos objetos (a == b es false) con el mismo valor (a.equals(b) es
true). Las dos últimas líneas muestran por qué esto importa más allá de
nuestro código: ArrayList.contains y HashSet usan equals (y
hashCode) para decidir si un elemento ya está. El conjunto quedó con un
solo elemento, porque 2/4 es igual a 1/2.
Error común. Escribir
equalscon el tipo de la clase como parámetro:public boolean equals(Fraction f) { return num == f.num && den == f.den; }Parece correcto, y
a.equals(b)con dos variablesFractiondatrue. Pero eso es un overload: un método nuevo, distinto deequals(Object), que la clase sigue heredando deObjectsin cambios.EqualsOverloadDemo.javalo muestra con una claseWrongFraction:$ java -cp build EqualsOverloadDemo a.equals(b): true a.equals(o): false list.contains(b): falseCon
bdeclarada comoObject(aunque apunte al mismo objeto), el compilador eligeequals(Object), el heredado, que compara identidades. YArrayList.containsrecibe unObjecty llama aequals(Object): no encuentra un elemento que “es igual”. Con@Override, el error aparece al compilar:$ javac -Xlint:all -d build Fraction.java Fraction.java:10: error: method does not override or implement a method from a supertype @Override ^ 1 error
El contrato de equals
equals no es un método cualquiera. Las colecciones de la biblioteca
(ArrayList, HashSet, HashMap) y cualquier código que compare objetos
confían en que se comporte como una igualdad. La documentación de Object
lo pide por escrito. Para referencias x, y, z que no son null:
- Reflexiva:
x.equals(x)estrue. - Simétrica:
x.equals(y)estruesi y solo siy.equals(x)estrue. - Transitiva: si
x.equals(y)yy.equals(z), entoncesx.equals(z). - Consistente: si no cambian los campos que usa
equals, llamarlo varias veces da siempre lo mismo. x.equals(null)esfalse, y no lanza excepción.
Las tres primeras son las de una relación de equivalencia, como la igualdad
de la matemática. Que un equals las cumpla no lo verifica el compilador:
lo verifican las pruebas. FractionTest.java
tiene una prueba por propiedad:
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 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 notEqualToNull() {
assertFalse(a.equals(null));
}
@Test
void notEqualToOtherType() {
assertFalse(a.equals("1/2"));
}
c está escrita con los dos signos negativos a propósito: el constructor la
normaliza a 1/2, y la prueba transitiva comprueba de paso que el invariante
hace su trabajo. La prueba con "1/2" comprueba que un objeto de otro tipo,
aunque se imprima igual, no es igual.
Herencia y equals: Point y ColorPoint
Hasta aquí Fraction era final. ¿Qué pasa con equals cuando hay
herencia? Tomemos un punto del plano y una subclase que le agrega un color
(Point.java,
ColorPoint.java):
public class Point {
// campos x e y, constructor y getters
@Override
public boolean equals(Object o) {
return o instanceof Point p && x == p.x && y == p.y;
}
}
public class ColorPoint extends Point {
private final String color;
// constructor con super(x, y)
@Override
public boolean equals(Object o) {
return o instanceof ColorPoint cp && super.equals(cp) && color.equals(cp.color);
}
}
Cada equals, mirado solo, parece razonable: dos puntos son iguales si
tienen las mismas coordenadas; dos puntos de color, si además tienen el mismo
color. Juntos, no.
PointDemo.java:
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));
$ java -cp build PointDemo
p.equals(red): true
red.equals(p): false
Se rompió la simetría. Las consecuencias no son teóricas: si red está
en una lista y se busca p con contains, el resultado depende de qué lado
de la comparación quede cada uno dentro de la implementación de la lista.
Hay dos arreglos tentadores, y ninguno funciona del todo:
- Que
ColorPointignore el color cuando lo comparan con unPointcomún. Se recupera la simetría, pero se pierde la transitividad: un punto rojo en (1, 2) sería igual ap,psería igual a un punto azul en (1, 2), y el rojo no sería igual al azul. - Comparar con
getClass()en lugar deinstanceofen las dos clases:if (o == null || getClass() != o.getClass()) return false;. Ahora unPointnunca es igual a unColorPoint, y las propiedades se cumplen. El precio es otro: unColorPointya no puede usarse donde se espera unPoint. UnHashSet<Point>con el punto (1, 2) no “contiene” unColorPointen (1, 2), aunque como punto sea exactamente ese; y una subclase que no agrega ningún campo (unPointque solo cuenta cuántas veces se lo creó, por ejemplo) tampoco sería igual a su equivalente.
Idea clave. No hay forma de extender una clase de valor, agregarle un campo que participe en la igualdad y conservar a la vez el contrato de
equalsy la posibilidad de usar la subclase donde se espera la superclase. Es una señal de que aquí la herencia no es la herramienta correcta. La semana 7 lo resuelve sin herencia: unColorPointque tiene unPointen lugar de ser uno. Por eso, también,Fractionesfinal.
hashCode: el compañero de equals
HashSet y HashMap no comparan el elemento buscado con todos los que
guardan; eso sería lento. Reparten los elementos en buckets, grupos que
se eligen según un número que les piden a cada uno, hashCode(), y al buscar
solo miran dentro del bucket que corresponde. Para que eso funcione, el contrato de
hashCode pide:
- Objetos iguales según
equalstienen el mismohashCode. Es la regla que importa. - Consistencia: si no cambian los campos que usa
equals, el hash no cambia. - Objetos distintos pueden tener el mismo hash (es inevitable: hay muchos
más objetos posibles que valores de
int), pero conviene que sea raro, para que los buckets tengan pocos elementos.
El hashCode heredado de Object depende del objeto, no de sus campos: dos
objetos creados con dos new tienen, casi siempre, hashes distintos. Si una
clase hace override de equals y se olvida de hashCode, rompe la primera regla.
HashDemo.java tiene una clase Code así:
static final class Code {
private final String value;
// constructor
@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
}
$ java -cp build HashDemo
a.equals(b): true
mismo hash: false
set.contains(a): true
set.contains(b): false
tamaño: 2
a y b son iguales, pero el conjunto no encuentra b, y después de
agregarlo tiene dos elementos iguales: justo lo que un conjunto promete
no hacer.


El compilador puede avisarnos. Con -Xlint:all, una clase que hace override de
equals y no de hashCode produce una advertencia:
$ javac -Xlint:all -d build HashDemo.java
HashDemo.java:7: warning: [overrides] Class Code overrides equals, but neither it nor any superclass overrides hashCode method
static final class Code {
^
1 warning
Como el Makefile del curso compila con -Werror, esa advertencia detiene
la compilación. En HashDemo la callamos con
@SuppressWarnings("overrides") porque el error es el tema del ejemplo; en
código de verdad, la respuesta es escribir hashCode.
¿Cómo se escribe? Combinando los hashes de los mismos campos que usa
equals (ni uno más: si usara un campo que equals ignora, dos objetos
iguales podrían tener hashes distintos). La receta clásica multiplica por un
primo pequeño y suma:
@Override
public int hashCode() {
return 31 * Long.hashCode(num) + Long.hashCode(den);
}
Para 1/2 da 31 * 1 + 2 = 33, el número de la salida de FractionDemo.
La biblioteca tiene un atajo,
Objects.hash(num, den),
que hace la misma cuenta con cualquier cantidad de campos (es algo más lento,
porque hace boxing de los primitivos, es decir, los convierte en objetos). Y
Objects.equals(a, b)
compara dos campos que pueden ser null sin lanzar NullPointerException.
En Java. Los records de la semana 3 traen
equals,hashCodeytoStringgenerados a partir de sus componentes, y cumplen el contrato. Unrecord Money(long cents, String currency)ya es una clase de valor correcta. Escribir estos tres métodos a mano tiene sentido para clases que no pueden ser records: las que normalizan sus datos con lógica propia (comoFraction) o las que tienen estado que cambia.
Error común. Usar en
equalsohashCodeun campo que cambia mientras el objeto está dentro de unHashSet. Si el hash cambia, el objeto queda en el bucket viejo y el conjunto ya no lo encuentra. Por eso las claves de los conjuntos y mapas casi siempre son objetos inmutables, comoString,Fractiono un record.
Identidad e igualdad, otra vez
¿Toda clase debería hacer override de equals? No. Hay que preguntarse qué
representa:
- Un valor (una fracción, una cantidad de dinero, una hora del día, un
punto) se define por sus componentes. Dos objetos con los mismos componentes
son intercambiables, y
equalsdebe compararlos todos. Suelen ser inmutables, y a menudo un record basta. - Una entidad (una cuenta bancaria, una persona, un producto del
inventario) tiene una identidad que dura aunque su estado cambie: la cuenta
A-001sigue siendo la misma después de un depósito. Dos cuentas distintas con el mismo saldo no son “iguales”. Para una entidad hay dos opciones razonables: no hacer override deequals(y quedarse con la identidad deObject), o comparar solo el campo que la identifica, como un código que no cambia.
Por eso BankAccount no hace override de equals ni de hashCode: una cuenta es
una entidad. Y en el proyecto, la pregunta de esta semana es: ¿dos objetos
Product con el mismo código son el mismo producto, aunque uno tenga otro
precio porque se actualizó?
El código de la semana y make test
$ cd code/week-05
$ make test
mkdir -p build
javac -Xlint:all -Werror -cp /opt/junit/junit.jar -d build AccountDemo.java BankAccount.java ClockTime.java ColorPoint.java ConstructorOrder.java EqualsOverloadDemo.java FractionDemo.java Fraction.java FractionTest.java HashDemo.java PointDemo.java Point.java SavingsAccount.java SavingsAccountTest.java
touch build/.stamp
== AccountDemo
...
╷
├─ JUnit Platform Suite ✔
├─ JUnit Jupiter ✔
│ ├─ SavingsAccountTest ✔
│ │ ├─ constructorRejectsNegativeRate() ✔
│ │ ├─ fourthWithdrawalInAMonthIsRejected() ✔
│ │ ...
│ └─ FractionTest ✔
│ ├─ transitive() ✔
│ ├─ reflexive() ✔
│ ...
└─ JUnit Vintage ✔
...
[ 18 tests successful ]
[ 0 tests failed ]
Además de las demostraciones, hay dos clases de prueba.
SavingsAccountTest.java prueba
la subclase: lo heredado (deposit, balance), lo nuevo (applyInterest) y
los overrides (el límite de retiros, que un retiro fallido no cuenta, que
applyInterest reinicia el contador). Una de sus pruebas comprueba algo que
no está escrito en SavingsAccount: que el constructor rechaza un código en
blanco. Esa validación la hace BankAccount, y la prueba confirma que
super(id) la ejecuta.
FractionTest.java prueba el contrato de
equals y hashCode, como vimos arriba. La clase
ClockTime.java es el código inicial del
laboratorio: una hora del día a la que le faltan toString, equals y
hashCode.
Resumen
- La herencia expresa una relación “es un”. Con
extends, la subclase recibe los campos y métodos de la superclase, y puede usarlos, agregar otros y hacer override de los heredados. - Los campos
privatede la superclase están en el objeto, pero la subclase no los nombra: los usa a través de los métodos públicos, que protegen el invariante. - Los constructores no se heredan. La primera instrucción de un constructor
es
super(...), explícito o implícito; si la superclase no tiene constructor sin parámetros, hay que escribirlo. Los cuerpos de los constructores terminan desdeObjecthacia abajo. - Un override repite la firma; un overload cambia los parámetros.
@Overridepide al compilador que verifique que de verdad hay un override.super.metodo()llama a la versión heredada. protectedabre un miembro a las subclases (y al paquete): úsenlo poco, sobre todo en campos.finalimpide hacer override de un método o extender una clase, comoString.- Toda clase desciende de
Object. Conviene hacer override detoStringsiempre.equalscompara identidades hasta que una clase de valor le hace override con la firmaequals(Object). - El contrato de
equals: reflexivo, simétrico, transitivo, consistente y falso connull. Con herencia y un campo nuevo no hay forma de cumplirlo sin perder la sustitución (PointyColorPoint). - Objetos iguales deben tener el mismo
hashCode; si no, se pierden en unHashSet. Se calcula con los mismos campos que usaequals. Los records traen los tres métodos hechos. - Un valor se compara por sus componentes; una entidad, por su identidad o por un código que no cambia.
Ejercicios
- Sin compilar, digan cuáles de estas declaraciones en una subclase de
BankAccountson overrides, cuáles son overloads y cuáles no compilan:public boolean withdraw(int cents),public boolean withdraw(long amount),boolean withdraw(long cents),public void withdraw(long cents),public String toString(String prefix). Comprueben conjavacagregando@Overridea cada una. - Escriban
CheckingAccount extends BankAccount, una cuenta corriente que cobra una comisión fija mensual con un métodochargeFee(). ¿Qué pasa si el saldo no alcanza para la comisión? Decidan una regla que respete el invariante deBankAccounty escríbanla con sus pruebas. - Agreguen un
IO.printlnal principio del constructor deBankAccounty otro al principio del deSavingsAccount(después desuper(id)). Antes de ejecutarAccountDemo, escriban en qué orden esperan ver los mensajes para la líneanew SavingsAccount("A-001", 2). Luego quiten losprintln. - En
ConstructorOrder, cambienVehicle()porVehicle(String name). ¿Qué clases dejan de compilar y por qué? Arréglenlo con el menor cambio posible. - ¿Por qué el
withdrawdeSavingsAccountllama asuper.withdraw(cents)en lugar dewithdraw(cents)? Prueben el cambio y expliquen lo que pasa. - Escriban
toString,equalsyhashCodeparaClockTime(lo que pide el laboratorio). Luego escriban una prueba por cada propiedad del contrato y una que use unHashSet<ClockTime>. - Una clase
Studenttiene código, nombre y promedio. ¿Es un valor o una entidad? Propongan suequalsy suhashCodey justifiquen qué campos usan. ¿Qué pasaría siequalsusara el promedio? - Den un ejemplo concreto, con tres objetos
Point/ColorPoint, en el que la versión deColorPoint.equalsque “ignora el color si el otro es unPointcomún” viola la transitividad. Escriban esa versión y comprueben su ejemplo con una prueba. - Este
hashCodeparaFractioncumple el contrato:return 42;. ¿Por qué? ¿Qué tiene de malo? Piensen en unHashSetcon diez mil fracciones distintas. - Una clase hace override de
equalsusando los camposayb, y dehashCodeusandoa,byc. ¿Qué regla del contrato puede romperse? Construyan dos objetos que lo muestren. Fractionesfinal. Supongan que no lo fuera, y que alguien escribeNamedFraction extends Fractioncon un camponame. ¿Qué problema de esta semana reaparece? ¿Qué le recomendarían a esa persona?- Lean la documentación de
Object.equalsy deObject.hashCode. Encuentren las frases que corresponden a cada propiedad de este capítulo. ¿Hay alguna que no vimos?