Kontrakt hashCode i equals

blog.lantkowiak.pl 8 lat temu

Jedną z pierwszych rzeczy, o których się dowiadujemy ucząc się Javy są informacje na temat metod equals oraz hashCode. Informacje na temat kontraktu tych dwóch metod są bardzo istotne w programowaniu w Javie, szczególnie w przypadku kolekcji, ale o tym będzie kiedy indziej.

Kontrakt

Metody equals oraz hashCode powinny spełniać poniższe warunki:

  • Jeżeli x == y to x.equals(y) == true
  • Jeżeli x.equals(y) == true to x.hashCode() == y.hashCode()
  • Jeżeli x.hashCode() == y.hashCode() to x.equals(y) może zarówno zwrócić true, jak i może zwrócić false

Wszystkie wartości zwracane przez powyższe funkcje muszą być determistyczne – zawsze zwracać te same wartości dla tych samym parametrów.

Domyślna implementacja

Metody hashCode i equals są zdefiniowane w Object, czyli w klasie nadrzędnej dla wszystkich klas w Java. Domyślne implementacje wyglądają następująco:

public boolean equals(Object obj) { return (this == obj); }

Domyślna implementacja metody equals opiera się na sprawdzeniu czy przekazany obiekt jest tą samą instancją, co obiekt, na którym wywołano metodę equals. Jak widać domyślna implementacja w żaden sposób nie opiera się na atrybutach klasy.

public native int hashCode();

Klasa Object nie ma natomiast zdefiniowanego ciała metody hashCode. Każda implementacja JVM dostarcza swoją wersje tej metody. zwykle jest to adres pamięci, pod którym znajduje się obiekt, zamieniony na typ integer. Podejście takie byłoby prawidłowe, gdyby każdy obiekt był unikalny. W przypadku kiedy dwa obiekty są identyczne (bazując na atrybutach klasy) implementacja taka nie spełnia kontraktu tej metody.

Przykładowe implementacje

Oczywiście powyższe metody możemy (a często wręcz musimy) nadpisać i dostosować do naszych potrzeb. Załóżmy, iż mamy poniższą klasę, do której chcemy dopisać nasze dwie metody, o których rozmawiamy.

class Person { private final String name; private final int age; public Person(final String name, final int age) { this.name = name; this.age = age; } public String getName() { return name; } public int getAge() { return age; } }

Przykładowe implementacja metody hashCode mogą wyglądać następująco:

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

Jak widać wartość hashCode jest wyliczana przy użyciu mnożenia i dodawanie na podstawie wszystkich pól klasy.

Implementacja equals dla naszej klasy może wyglądać tak:

@Override public boolean equals(Object obj) { if (this == obj) { return true; } if (obj == null) { return false; } if (getClass() != obj.getClass()) { return false; } final Person that = (Person) obj; if (age != that.age) { return false; } if (name == null) { if (that.name != null) { return false; } } else if (!name.equals(that.name)) { return false; } return true; }

Tutaj już się dzieję trochę więcej… albo przynajmniej wygląda jakby się działo więcej.
W liniach 3-11 sprawdzamy identyczność obiektów na poziomie samego obiektu, a nie jego atrybutów. Sprawdzamy czy:

  • przekazany obiekt jest tym samym obiektem, na którym została wywołana metoda – o ile jest to oczywiście zwracamy true
  • przekazany obiekt nie jest nullem – o ile jest to zwracamy false – obiekt, na ktorym wywołaliśmy metodę z oczywistych powodów nie może być nullem
  • oba obiekty są tego samego typu – o ile nie są to oczywiście zwracamy false

Jeżeli obiekt przejdzie te 3 warunki to jest rzutowany na nasz typ klasy, a następnie każde pole klasy jest porównywane. Jeżeli, któreś z porównywanych pól jest inne to oczywiście zwracamy false, a w przeciwnym przypadku przechodzimy dalej. Kiedy już wszystkie pola zostały sprawdzony to zwracamy true.

Bardziej praktyczna implementacja

Implementacje powyższych metod nie są trudne, ale zajmują trochę linii kodu (szczególnie equals). Dodatkowo implementując manualnie te metody dla klas z większą ilością pół łatwo o pomyłke. Co prawda każde IDE potrafi wygenerować implementacje powyższych metod na podstawie pól klasy, ale wciąż zostaje problem pierwszy – dużo linii i brzydko to wygląda

Bardzo polecam używanie bibliotek, które zawierają wbudowany builder’y do tych metod. Jedną z takich bibliotek jest common-lang od Apache.
Implementacja hashCode przy użyciu tej metody wygląda następująco:

@Override public int hashCode() { return new HashCodeBuilder().append(age).append(name).toHashCode(); }

Jak widać kod dla tej metody został zdecydowanie uproszczony i jest bardziej przejrzysty.

Implementacja metody equals może wyglądać na przykład tak:

@Override public boolean equals(Object obj) { if (this == obj) { return true; } if (obj == null) { return false; } if (getClass() != obj.getClass()) { return false; } final Person that = (Person) obj; return new EqualsBuilder().append(this.age, that.age).append(this.name, that.name).isEquals(); }

Jak widać kod w tym przypadku również się trochę uprościł. Trzy pierwsze if’y co prawda zostały, ale reszta kodu została zdecydowanie skrócona i jest bardziej czytelna.

Jak widać dzięki użyciu kodu z biblioteki mogliśmy stworzyć nasze metody pisząc mniej kodu, który jest jednak bardziej czytelny i przejrzysty.

A może by tak jeszcze coś ulepszyć?

Patrząc na implementacje metody equals można by się zastanowić czy użycie instanceof zamiast drugiego i trzeciego if’a nie byłoby dobrym pomysłem. Wtedy nasza klasa mogłaby wyglądać w następujący sposób:

class Person { private final String name; private final int age; public Person(final String name, final int age) { this.name = name; this.age = age; } public String getName() { return name; } public int getAge() { return age; } @Override public int hashCode() { return new HashCodeBuilder().append(age).append(name).toHashCode(); } @Override public boolean equals(Object obj) { if (this == obj) { return true; } if (!(obj instanceof Person)) { return false; } final Person that = (Person) obj; return new EqualsBuilder().append(this.age, that.age).append(this.name, that.name).isEquals(); } }

Dzięki takiemu podejściu 'oszczędzamy’ jednego if’a, a wszystko działa jak działało. Chociaż…

Spójrzmy na poniższą klase:

class Student extends Person { private final String school; public Student(final String name, final int age, final String school) { super(name, age); this.school= school; } public String getSchool() { return school; } @Override public int hashCode() { return new HashCodeBuilder().appendSuper(super.hashCode()) .append(school).toHashCode(); } @Override public boolean equals(Object obj) { if (this == obj) { return true; } if (!(obj instanceof Student)) { return false; } final Student that = (Student) obj; return new EqualsBuilder().appendSuper(super.equals(that)) .append(this.school, that.school).isEquals(); } }

Stworzyliśmy klasę Student, która dziedziczy po Person. Nasza nowa klasa zawiera dodatkowe pole 'school’.

Zróbmy teraz coś takiego:

final Person p = new Person("Adam", 15); final Student s = new Student("Adam", 15, "uj"); System.out.println(p.equals(s)); // true System.out.println(s.equals(p)); // false

Jak widać dochodzimy do sytuacji, gdzie tworzymy dwa różne obiekty i w zależności od tego, który do którego porównamy to otrzymujemy różne wyniki. Równość powinna być relacją symetryczną, a taka sytuacja zdecydowanie ją narusza.

Czy to oznacza, iż to podejście jest błędne? Odpowiedź jest bardzo prosta i brzmi: to zależy

Wersja equals z getClass jest, powiedzmy, 'restrykcyjna’, ale za to prosta i intuicyjna. Porównywany obiekt musi być dokładnie tego samego typu co obiekt, do którego jest porównywany.

Druga wersje może narobić nam trochę ambarasu, ale zostawia trochę więcej swobody. Istotnym przypadkiem, kiedy takie podejście może być konieczne jest porównywanie obiektów, które są zarządzane przez jakiś framework np. część implementacji JPA opakowuje encje. W takiej sytuacji, dzięki użyciu instanceof w metodzie equals jesteśmy w stanie w poprawny sposób porównywać takie obiekty.

Jak widać obydwa sposoby implementacji metody equals mają swoje zastosowania. Najważniejsze, żeby świadomie wybierać podejście, którego potrzebujemy

Idź do oryginalnego materiału