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 clase Object), y los párrafos sobre equals de la 10.1.6 y sobre hashCode de la 10.3.3. El contrato completo de equals y hashCode está en la documentación de Object. El código del capítulo está en code/week-05 y se prueba con make test dentro 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 clase Object.
  • 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 clase Object: toString, equals y su contrato, Herencia y equals: Point y ColorPoint y hashCode. En el laboratorio, El código de la semana y make 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. SavingsAccount no declara deposit, balance ni id, pero sus objetos los entienden: applyInterest llama a balance() y a deposit(...) 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: withdraw cuenta los retiros y toString agrega la tasa.
A la izquierda, tres clases UML: Object con toString, equals y hashCode; BankAccount con los campos privados id y balance y los métodos deposit, withdraw, balance, id y toString (override); SavingsAccount con ratePercent y withdrawals, applyInterest (nuevo) y withdraw y toString (overrides). Flechas con punta de triángulo hueco van de SavingsAccount a BankAccount y de BankAccount a Object. Abajo, una tabla: para cada mensaje que entiende un objeto SavingsAccount, la clase cuya versión se ejecuta: deposit, balance e id de BankAccount; withdraw, applyInterest y toString de SavingsAccount; equals y hashCode de ObjectA la izquierda, tres clases UML: Object con toString, equals y hashCode; BankAccount con los campos privados id y balance y los métodos deposit, withdraw, balance, id y toString (override); SavingsAccount con ratePercent y withdrawals, applyInterest (nuevo) y withdraw y toString (overrides). Flechas con punta de triángulo hueco van de SavingsAccount a BankAccount y de BankAccount a Object. Abajo, una tabla: para cada mensaje que entiende un objeto SavingsAccount, la clase cuya versión se ejecuta: deposit, balance e id de BankAccount; withdraw, applyInterest y toString de SavingsAccount; equals y hashCode de Object
En UML, la herencia es una flecha con punta de triángulo hueco que va de la subclase a la superclase. Un objeto SavingsAccount entiende todos los métodos de la cadena; para cada uno se ejecuta la versión de la clase más cercana a la suya.

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 de BankAccount no se puede llamar sin argumentos: la llamada que falla es el super() invisible que él mismo insertó. La solución no es agregarle a BankAccount un constructor vacío (eso permitiría cuentas sin código, y rompería su invariante), sino escribir super(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:

Animación: arriba, el código SavingsAccount s = new SavingsAccount("A-001", 2);. Paso 1: aparece en el heap un objeto SavingsAccount con dos partes, la heredada de BankAccount (id = null, balance = 0) y la de SavingsAccount (ratePercent = 0, withdrawals = 0). Paso 2: a la izquierda se apilan los constructores en curso: SavingsAccount("A-001", 2) ejecuta super(id), que llama a BankAccount("A-001"), que llama a Object(). Paso 3: Object() termina y el cuerpo de BankAccount pone id = "A-001" y balance = 0. Paso 4: BankAccount termina y el resto de SavingsAccount pone ratePercent = 2 y withdrawals = 0. Paso 5: la variable s recibe la referencia al objetoAnimación: arriba, el código SavingsAccount s = new SavingsAccount("A-001", 2);. Paso 1: aparece en el heap un objeto SavingsAccount con dos partes, la heredada de BankAccount (id = null, balance = 0) y la de SavingsAccount (ratePercent = 0, withdrawals = 0). Paso 2: a la izquierda se apilan los constructores en curso: SavingsAccount("A-001", 2) ejecuta super(id), que llama a BankAccount("A-001"), que llama a Object(). Paso 3: Object() termina y el cuerpo de BankAccount pone id = "A-001" y balance = 0. Paso 4: BankAccount termina y el resto de SavingsAccount pone ratePercent = 2 y withdrawals = 0. Paso 5: la variable s recibe la referencia al objeto
new reserva el objeto completo, también con los campos heredados. Cada constructor llama primero al de su superclase; por eso la parte de BankAccount queda inicializada antes de que corra el código propio de SavingsAccount.

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ón invokespecial al constructor de BankAccount, 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 a Object."<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 protected todos 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 con private y 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 marcarla final. 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, no Fraction: es la firma de Object.equals, y solo con esa firma hay un override.
  • this == o es un atajo: un objeto siempre es igual a sí mismo.
  • o instanceof Fraction f hace dos cosas: pregunta si o apunta a una Fraction y, si es así, declara la variable f de tipo Fraction que apunta al mismo objeto. Con null da false, así que también cubre ese caso. Esta forma de instanceof con una variable es un patrón; los patrones se estudian en la semana 8.
  • Dentro de Fraction se puede leer f.num aunque sea private: el acceso privado es por clase, no por objeto.

FractionDemo.java:

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 equals con 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 variables Fraction da true. Pero eso es un overload: un método nuevo, distinto de equals(Object), que la clase sigue heredando de Object sin cambios. EqualsOverloadDemo.java lo muestra con una clase WrongFraction:

$ java -cp build EqualsOverloadDemo
a.equals(b): true
a.equals(o): false
list.contains(b): false

Con b declarada como Object (aunque apunte al mismo objeto), el compilador elige equals(Object), el heredado, que compara identidades. Y ArrayList.contains recibe un Object y llama a equals(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) es true.
  • Simétrica: x.equals(y) es true si y solo si y.equals(x) es true.
  • Transitiva: si x.equals(y) y y.equals(z), entonces x.equals(z).
  • Consistente: si no cambian los campos que usa equals, llamarlo varias veces da siempre lo mismo.
  • x.equals(null) es false, 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
Dos objetos: p, un Point con x = 1 e y = 2, y red, un ColorPoint con x = 1, y = 2 y color "rojo". Arriba, una flecha de p a red rotulada p.equals(red): corre el equals de Point, red instanceof Point es sí, x e y son iguales, resultado true. Abajo, una flecha de red a p rotulada red.equals(p): corre el equals de ColorPoint, p instanceof ColorPoint es no, resultado false. Al pie: un ColorPoint es un Point, pero un Point no es un ColorPoint, y la igualdad no es simétricaDos objetos: p, un Point con x = 1 e y = 2, y red, un ColorPoint con x = 1, y = 2 y color "rojo". Arriba, una flecha de p a red rotulada p.equals(red): corre el equals de Point, red instanceof Point es sí, x e y son iguales, resultado true. Abajo, una flecha de red a p rotulada red.equals(p): corre el equals de ColorPoint, p instanceof ColorPoint es no, resultado false. Al pie: un ColorPoint es un Point, pero un Point no es un ColorPoint, y la igualdad no es simétrica
Cada llamada usa el equals de la clase del objeto que recibe el mensaje. El de Point acepta un ColorPoint (es un Point); el de ColorPoint rechaza un Point. La simetría se rompe.

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 ColorPoint ignore el color cuando lo comparan con un Point común. Se recupera la simetría, pero se pierde la transitividad: un punto rojo en (1, 2) sería igual a p, p sería igual a un punto azul en (1, 2), y el rojo no sería igual al azul.
  • Comparar con getClass() en lugar de instanceof en las dos clases: if (o == null || getClass() != o.getClass()) return false;. Ahora un Point nunca es igual a un ColorPoint, y las propiedades se cumplen. El precio es otro: un ColorPoint ya no puede usarse donde se espera un Point. Un HashSet<Point> con el punto (1, 2) no “contiene” un ColorPoint en (1, 2), aunque como punto sea exactamente ese; y una subclase que no agrega ningún campo (un Point que 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 equals y 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: un ColorPoint que tiene un Point en lugar de ser uno. Por eso, también, Fraction es final.

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 equals tienen el mismo hashCode. 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.

Animación con dos filas de 16 buckets numerados de 0 a 15. Arriba, Fraction con hashCode: set.add(1/2) calcula hash 33 y guarda 1/2 en el bucket 1; set.contains(2/4) calcula el mismo hash 33, va al bucket 1, compara con equals y responde true. Abajo, Code con equals pero sin hashCode: set.add(a) calcula el hash 31221be2 y guarda a en el bucket 0; set.contains(b) calcula el hash 4e50df2e, va al bucket 14, lo encuentra vacío y responde falseAnimación con dos filas de 16 buckets numerados de 0 a 15. Arriba, Fraction con hashCode: set.add(1/2) calcula hash 33 y guarda 1/2 en el bucket 1; set.contains(2/4) calcula el mismo hash 33, va al bucket 1, compara con equals y responde true. Abajo, Code con equals pero sin hashCode: set.add(a) calcula el hash 31221be2 y guarda a en el bucket 0; set.contains(b) calcula el hash 4e50df2e, va al bucket 14, lo encuentra vacío y responde false
El HashSet compara con equals solo dentro de un bucket, y el bucket lo elige el hash. Sin hashCode, dos objetos iguales van a buckets distintos y equals nunca llega a llamarse.

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, hashCode y toString generados a partir de sus componentes, y cumplen el contrato. Un record 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 (como Fraction) o las que tienen estado que cambia.

Error común. Usar en equals o hashCode un campo que cambia mientras el objeto está dentro de un HashSet. 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, como String, Fraction o 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 equals debe 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-001 sigue 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 de equals (y quedarse con la identidad de Object), 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 private de 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 desde Object hacia abajo.
  • Un override repite la firma; un overload cambia los parámetros. @Override pide al compilador que verifique que de verdad hay un override. super.metodo() llama a la versión heredada.
  • protected abre un miembro a las subclases (y al paquete): úsenlo poco, sobre todo en campos. final impide hacer override de un método o extender una clase, como String.
  • Toda clase desciende de Object. Conviene hacer override de toString siempre. equals compara identidades hasta que una clase de valor le hace override con la firma equals(Object).
  • El contrato de equals: reflexivo, simétrico, transitivo, consistente y falso con null. Con herencia y un campo nuevo no hay forma de cumplirlo sin perder la sustitución (Point y ColorPoint).
  • Objetos iguales deben tener el mismo hashCode; si no, se pierden en un HashSet. Se calcula con los mismos campos que usa equals. 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

  1. Sin compilar, digan cuáles de estas declaraciones en una subclase de BankAccount son 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 con javac agregando @Override a cada una.
  2. Escriban CheckingAccount extends BankAccount, una cuenta corriente que cobra una comisión fija mensual con un método chargeFee(). ¿Qué pasa si el saldo no alcanza para la comisión? Decidan una regla que respete el invariante de BankAccount y escríbanla con sus pruebas.
  3. Agreguen un IO.println al principio del constructor de BankAccount y otro al principio del de SavingsAccount (después de super(id)). Antes de ejecutar AccountDemo, escriban en qué orden esperan ver los mensajes para la línea new SavingsAccount("A-001", 2). Luego quiten los println.
  4. En ConstructorOrder, cambien Vehicle() por Vehicle(String name). ¿Qué clases dejan de compilar y por qué? Arréglenlo con el menor cambio posible.
  5. ¿Por qué el withdraw de SavingsAccount llama a super.withdraw(cents) en lugar de withdraw(cents)? Prueben el cambio y expliquen lo que pasa.
  6. Escriban toString, equals y hashCode para ClockTime (lo que pide el laboratorio). Luego escriban una prueba por cada propiedad del contrato y una que use un HashSet<ClockTime>.
  7. Una clase Student tiene código, nombre y promedio. ¿Es un valor o una entidad? Propongan su equals y su hashCode y justifiquen qué campos usan. ¿Qué pasaría si equals usara el promedio?
  8. Den un ejemplo concreto, con tres objetos Point/ColorPoint, en el que la versión de ColorPoint.equals que “ignora el color si el otro es un Point común” viola la transitividad. Escriban esa versión y comprueben su ejemplo con una prueba.
  9. Este hashCode para Fraction cumple el contrato: return 42;. ¿Por qué? ¿Qué tiene de malo? Piensen en un HashSet con diez mil fracciones distintas.
  10. Una clase hace override de equals usando los campos a y b, y de hashCode usando a, b y c. ¿Qué regla del contrato puede romperse? Construyan dos objetos que lo muestren.
  11. Fraction es final. Supongan que no lo fuera, y que alguien escribe NamedFraction extends Fraction con un campo name. ¿Qué problema de esta semana reaparece? ¿Qué le recomendarían a esa persona?
  12. Lean la documentación de Object.equals y de Object.hashCode. Encuentren las frases que corresponden a cada propiedad de este capítulo. ¿Hay alguna que no vimos?