Uguaglianza ed ereditarietà

Questa breve nota discute i limiti imposti dall'ereditarietà al soddisfacimento del contratto imposto dal metodo equals) della classe Object.

La nota segue l'esposizione della Sezione 7.9.3 del testo "Program Development in Java" (di Barbara H. Liskov et al.) e dell'Item 10 del testo "Effective Java" (di Joshua Bloch).

Simmetria

L'implementazione ovvia non funziona: se un sottotipo aggiunge un "valore", non basta controllare con instanceof e verificare che siano uguali i valori in gioco…

Consideriamo il seguente tipo T

public class T {
  private final int a;

  public T(int a) {
    this.a = a;
  }

  public boolean equals(Object o) {
    if (o instanceof T) {
      final T t = (T) o;
      return a == t.a;
    }
    return false;
  }
}

[sorgente]

e il suo sottotipo S

public class S extends T {
  private final int b;

  public S(int a, int b) {
    super(a);
    this.b = b;
  }

  public boolean equals(Object o) {
    if (o instanceof S) {
      final S s = (S) o;
      return super.equals(o) && b == s.b;
    }
    return false;
  }
}

[sorgente]

Entrambi i tipi sono dotati di una implementazione di equals (qui evidenziata in giallo) che, dopo aver verificato il tipo dell'argomento o procedono all'ovvio controllo dell'identità dei valori del/i campo/i corrispondente/i.

Tale implementazione viola la proprietà di simmetria, ossia $t \sim s$ non implica $s \sim t$, come dimostrato dal seguente metodo main

public static void main(String[] args) {
  S s = new S(1, 2);
  T t = new T(1);
  System.out.println(t.equals(s));
  System.out.println(s.equals(t));
}

[sorgente]

che produce l'output

true
false

Quel che accade è che t instanceof S è falso, ma s instanceof T è vero, per cui l'implementazione di equals di S non sa cosa fare con t, mentre quella di T funziona con s (ovviamente trascurando b, di cui ignora l'esistenza).

Transitività

Non è facile aggiustare l'implementazione di equals di S gestendo accortamente il caso in cui o ha il tipo effettivo di T imitando il suo comportamento, ossia "scordandosi" di b, come (nella parte evidenziata) nel seguente codice

public boolean equals(Object o) {
  if (o instanceof S) {
    final S s = (S) o;
    return super.equals(o) && b == s.b;
  }
  if (o instanceof T) return super.equals(o);
  return false;
}

[sorgente]

Così facendo infatti, la simmetria è rispettata, ma risulterà violata la proprietà transitiva, ovvero anche avendo $s \sim t$ e $t \sim u$ non è detto che $s \sim u$. Questo è dimostrato dalla seguente porzione di codice

public static void main(String[] args) {
  S s = new S(1, 2);
  T t = new T(1);
  S u = new S(1, 3);
  System.out.println(s.equals(t));
  System.out.println(t.equals(u));
  System.out.println(s.equals(u));
}

[sorgente]

che produce l'output

true
true
false

Il principio di sostituzione

Si potrebbe pensare che il problema sia instanceof che è antisimmetrico, per cui una soluzione che viene talvolta suggerita è di sostituire nell'implementazione di equals di T la condizione o instanceof T con la condizione getClass() == o.getClass() che è simmetrica (e risulta vera se e solo se gli oggetti hanno identico tipo concreto).

public boolean equals(Object o) {
  if (getClass() == o.getClass()) {
    final T t = (T) o;
    return a == t.a;
  }
  return false;
}

[sorgente]

Si può applicare la stessa scelta anche a S, sebbene questo sia utile solo qualora tale classe venisse estesa.

public boolean equals(Object o) {
  if (getClass() == o.getClass()) {
    final S s = (S) o;
    return super.equals(o) && b == s.b;
  }
  return false;
}

[sorgente]

In questo modo la simmetria e la transitività sono rispettate perché equals funziona solo per oggetti del medesimo tipo, avendo valore false per oggetti di tipo diverso (anche se sottotipi l'uno dell'altro).

Questo però viola il principio di sostituzione!

Per cogliere meglio questo aspetto consideriamo R, una ulteriore estensione di T che non aggiunga alcun "valore", ma solo un "comportamento" e che, pertanto, non ridefinisca neppure equals.

public class R extends T {
  public R(int a) {
    super(a);
  }

  public void sayHi() {
    System.out.println("Hi!");
  }
}

[sorgente]

Supponiamo ora di sviluppare una classe di utilità con un metodo isSmall che restituisca true per tutti gli oggetti per cui a è 1, oppure 2 e supponiamo (sebbene questo sia estremamente innaturale e inefficiente) di implementare tale metodo verificando l'appartenenza a un insieme SMALLS che contiene gli unici due oggetti che soddisfano tale requisito.

public class Client {
  private static final Set<T> SMALLS = Set.of(new T(1), new T(2));

  public static boolean isSmall(T t) {
    return SMALLS.contains(t);
  }
}

[sorgente]

La seguente porzione di codice mostra che il confronto tra oggetti di tipo diverso (anche se uno è sottotipo dell'altro) restituisce false (il che risolve i problemi di simmetria e transitività); mostra però anche che sostituendo il sottotipo R a T (vedi il codice evidenziato) il comportamento cambia!

public static void main(String[] args) {
  S s = new S(1, 2);
  T t = new T(1);
  System.out.println(t.equals(s));
  System.out.println(s.equals(t));

  R r = new R(1);
  System.out.println(Client.isSmall(t));
  System.out.println(Client.isSmall(r));
}

[sorgente]

L'output infatti è

false
false
true
false

Questo accade perché l'implementazione di Set si basa sul metodo equals per decidere se un oggetto appartiene all'insieme, ma gli elementi di SMALLS (che sono di tipo T) non risultano mai uguali a oggetti di tipo R!