poniedziałek, 17 stycznia 2011

Metoda equals, LSP i Twoje IDE

Jeżeli chodzi o metodę equals(Object obj) w Javie, to wiadomo jedno - wcale nie jest taka trywialna do napisania, zwłaszcza że jednocześnie trzeba pamiętać o hashCode(). Na szczęście nowoczesne IDE takie jak Eclipse czy NetBeans potrafią same wygenerować poprawną parę tych metod. Ale czy aby na pewno?

Przypomnijmy sobie czego Sun (ups, Oracle) wymaga jako "kontraktu" metody equals (http://download.oracle.com/javase/1.4.2/docs/api/java/lang/Object.html#equals%28java.lang.Object%29):

1. Powinna to być relacja równoważności, czyli musi być:
a) zwrotna: x musi być równe x,
b) symetryczna: jeżeli x=y, to y=x,
c) przechodnia: jeżeli x=y i y=z, to x=z

2. Musi być spójna, tzn. ta sama para x i y musi zawsze dawać ten sam wynik.

3. I musi też zachodzić: x != null

Przesłaniając metodę equals trzeba pamiętać o wszystkich pięciu warunkach - na szczęście naszej pamięci przychodzą na pomoc nowoczesne IDE :)

I tak np. Eclipse dla klasy:

public class Number {
  private int x;
}


wygeneruje następującą metodę equals:

@Override
public boolean equals(Object obj) {
  if (this == obj)
    return true;
  if (obj == null)
    return false;
  if (getClass() != obj.getClass())
    return false;
  Number other = (Number) obj;
  if (x != other.x)
    return false;
  return true;
}


I wszystko na razie gra, mamy piękną klasę Number, którą Eclipse nam wygenerował prawie w całości. Teraz w naszym Programie napisaliśmy prostą metodę, która sprawdzi czy dana liczba jest małą liczbą pierwszą:

public class Program {

  private final static Set SMALL_PRIMES = new HashSet();

  static {
    SMALL_PRIMES.add(new Number(2));
    SMALL_PRIMES.add(new Number(3));
    SMALL_PRIMES.add(new Number(5));
    SMALL_PRIMES.add(new Number(7));
  }

  private static boolean isSmallPrime(Number number)
  {
    return SMALL_PRIMES.contains(number);
  }

  public static void main(String[] args)
  {
    Number number2 = new Number(2);
    System.out.println(isSmallPrime(number2));
  }
}


Nie pytajcie o sens tych programów, to tylko przykłady ;)

Dobra, wszystko świetnie, ale zmiana występuje zawsze i od dzisiaj nasze liczby będą mogły mieć również kolory ;) Rozszerzamy więc prostą klasę poprzez dziedziczenie:

import java.awt.Color;

public class ColorNumber extends Number {
  private Color color;

  public ColorNumber(int x, Color color) {
    super(x);
    this.color = color;
  }

  @Override
  public int hashCode() {
    final int prime = 31;
    int result = super.hashCode();
    result = prime * result + ((color == null) ? 0 : color.hashCode());
    return result;
  }

  @Override
  public boolean equals(Object obj) {
    if (this == obj)
      return true;
    if (!super.equals(obj))
      return false;
    if (getClass() != obj.getClass())
      return false;
    ColorNumber other = (ColorNumber) obj;
    if (color == null) {
      if (other.color != null)
        return false;
    } else if (!color.equals(other.color))
      return false;
    return true;
  }
}


Znowu metodę equals i prawie całą resztę wygenerował nasz kolega Eclipse. Pełni szczęścia dopisujemy do metody main:

Number blue2 = new ColorNumber(2, Color.BLUE);
System.out.println(isSmallPrime(blue2));


a naszym oczom ukazuje się

false

Eeee, ale co się stało? Najpierw trochę teorii...

Omawiając "problem kwadratu i prostokąta" (http://wojtek-m.blogspot.com/2010/07/problem-kwadratu-i-prostokata.html) przytoczyłem zasadę LSP (Liskov Substitution Principle, Zasadę Podstawienia Liskov). W praktyce (a nie teorii o literkach g, x, y, S i T) brzmi ona tak:

Funkcje instancji klas bazowych, muszą być w stanie używać również instancji klas dziedziczących po klasach bazowych, bez dokładnej znajomości dokładnego typu tych obiektów.

Czyli jak napiszemy kod, to powinno dać się go wykorzystać ponownie, nawet ze specjalizacjami klas będących argumentami. Oczywiście jeżeli te specjalizacje będą "sensownie" napisane - jeżeli ktoś mnożenie zaimplementuje w metodzie add(), no to sorry ;)

Więc nasza metoda isSmallPrime albo jest źle napisana albo coś z klasą ColorNumber jest nie tak. Hm... Na co stawiacie?

Zgadliście, problem tkwi w metodzie equals (wykorzystywanej do znalezienie elementu zbioru), a dokładniej w tej linijce klasy Number:

if (getClass() != obj.getClass()) return false;

No tak, przecież ColorNumber to nie ta sama klasa co Number. A więc pełni nadziei zamieniamy te linijki na operator instanceof, tak żeby liczby porównywać z kolorowymi krewniakami nie patrząc na kolory:

W Number (edit: jak Herbi dobrze zauważył, trzeba uważać na przepełnienie stosu):
if (!(obj instanceof Number)) return false;

I w ColorNumber:
if (!(obj instanceof ColorNumber)) return obj.equals(this); // w ColorNumber

Jednak teraz mamy znacznie większy problem. Nasza metoda equals nie jest już przechodnia:

Number red3 = new ColorNumber(3, Color.RED);
Number number3 = new Number(3);
Number blue3 = new ColorNumber(3, Color.BLUE);

System.out.println(red3.equals(number3)); // true
System.out.println(number3.equals(blue3)); // true
System.out.println(red3.equals(blue3)); // ups! false


Hm... Niestety z tego zaklętego kręgu nie ma wyjścia - kiedy chcemy przeciążyć equals w klasie, która dziedziczy po innej klasie konkretnej to
* albo nasz operator równości nie będzie przechodni
* albo istnieje niebezpieczeństwo złamania zasady LSP...

Łamanie zasady LSP to mniejsze zło, z tego też powodu Eclipse jak i NetBeans generują metodę equals z użyciem getClass() do dokładnego porównania typu parametru.

Szkoda tylko że Eclipse nie napisze żadnego komentarza z ostrzeżeniem, bo konia z rzędem temu, kto trafi na podobny błąd w prawdziwej aplikacji i potrafi znaleźć przyczynę...


Aha, ten post to nie pojedynczy wybryk :) Po blisko 4 miesięcznej przerwie powracamy ;) Jednak nie do systemu "nowy post co 2 dni", ale "nowy post raz na jakiś czas, może nawet raz na tydzień"

2 komentarze:

  1. no fakt, nie zrobisz metody, która w jednym przypadku będzie pomijała jedno z pól (pomijała, bo w klasie bazowej go nie ma), a w drugim przypadku będzie z niego korzystała, bo to będzie niezgodne z kontraktem, ale na coś trzeba się zdecydować, albo brać zawsze do porównania kolor (a jeśli go nie ma, zwracać false), albo nigdy go nie brać pod uwagę (i móc porównywać potomka z rodzicem).

    Co do:
    if (!(obj instanceof Number)) return obj.equals(this)

    to wszystko fajnie (poza tym, że - jak słusznie zauważyłeś - takie coś nie spełnia kontraktu), ale jak ktoś pójdzie twoim śladem i zrobi sobie klasę A, która będzie miała w metodzie equals:
    if (!(obj instanceof A)) return obj.equals(this)

    to wywołanie new Number.equals(new A) skończy się przepełnieniem stosu :)

    OdpowiedzUsuń
  2. Tak, racja, w Number

    return obj.equals(this);

    nie ma sensu, powinno być

    return false;

    Dzięki za zwrócenie uwagi ;) Co nie zmienia faktu, że i tak nie należy tak robić ;)

    OdpowiedzUsuń