Porównywanie obiektów w Javie

Mój Drogi Czytelniku, znów pojawiła się dłuższa przerwa niż to przewidywałem. Już w poniedziałek pojawił się w mej głowie pomysł na ten wpis. Niestety wciągnąłem się w oglądanie serialu Dark. To jest serial, który wciągnął mnie totalnie i ciężko mi było myśleć o czymkolwiek innym, tylko o kolejnym odcinku. Mam nadzieję, że choć trochę mnie rozumiesz.

Dziś chciałbym pochylić się nad tematem, który bardzo często pojawia się w rozmowach kwalifikacyjnych na programistę Javy – metody equals i hashCode. Tworząc ten wpis opieram się w dużej mierze na książce

która od dawna należy do mojej biblioteczki. Przygotowując ten artykuł odkryłem, że przetłumaczono na polski wydanie III tej książki. Opierając się na krótkim opisie na stronie wydawnictwa Helion muszę stwierdzić, że czuję się zainteresowany zakupem. Książkę z czystym sumieniem mogę polecić wszystkim adeptom programowania w Javie. To nie jest książka do nauki programowania od zera. Ale skorzystają z niej wszyscy, którzy znają podstawy i chcą się doskonalić. Chyba najwyższy czas zakończyć tę reklamę – nikt mi za nią nie zapłacił (ale jestem otwarty na propozycje 😉 ) – i przejść do tematu.

Porównywanie obiektów w Javie

Weźmy na start prostą klasę:

public class SimpleClass01 {
    private int a;

    public SimpleClass01() {
        a = 0;
    }

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

    public int getA() {
        return a;
    }

    public void setA(int a) {
        this.a = a;
    }
}

Napiszemy teraz prosty test:

@Test
void comparingTheSameObjectUsingOperator() {
    // given
    SimpleClass01 o = new SimpleClass01();

    // when
    boolean result = o == o;

    // then
    assertTrue(result);
}

Wszystko jest zgodne z oczekiwaniami. Obiekt jest równy samemu sobie. Pójdźmy krok dalej:

@Test
void comparingTwoEqualsObjectsUsingOperator() {
    // given
    SimpleClass01 o1 = new SimpleClass01();
    SimpleClass01 o2 = new SimpleClass01();

    // when
    boolean result = o1 == o2;

    // then
    assertTrue(result);
}

Stworzyliśmy dwa identyczne obiekty klasy SimpleClass01. Identyczne, bo w obu przypadkach wykorzystaliśmy konstruktor domyślny, który ustawia wartość pola a na 0. Ale utworzyliśmy dwa nowe obiekty. Użycie operatora == oznacza tak naprawdę weryfikację, czy referencje o1 i o2 wskazują na ten sam obiekt. W tym wypadku tak nie jest, więc wartość zmiennej result to false. Jeżeli masz już jakieś doświadczenie w programowaniu w Javie, to pewnie obiło Ci się o uszy, że równość dwóch obiektów powinno się sprawdzać metodą equlas. Sprawdźmy to:

@Test
void comparingTwoEqualsObjectsUsingMethod() {
    // given
    SimpleClass01 o1 = new SimpleClass01();
    SimpleClass01 o2 = new SimpleClass01();

    // when
    boolean result = o1.equals(o2);

    // then
    assertTrue(result);
}

Okaże się, że ten test sfailuje – czyli asercja się nie powiedzie. Innymi słowy wartością zmiennej result będzie false. Dlaczego? Przyjrzyj się definicji klasy SimpleClass01. Nie zdefiniowaliśmy w niej metody equals! Ale jednak możemy taką metodą wywołać. Przypomnij sobie o klasie Object. Każda klasa domyślnie dziedziczy po klasie Object. Nasz test użył metody equals zdefiniowanej w klasie Object, która jest zdefiniowana w następujący sposób:

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

To oznacza, że jeżeli chcemy sprawdzać, czy obiekty naszej klasy są sobie równe, to musimy sami zadbać o właściwą implementację metody equals. Zdaję sobie sprawę z tego, że każde IDE (Zintegrowane Środowisko Programistyczne) wygeneruje nam właściwą metodą po kilku kliknięciach. Ale chciałbym Ci pokazać, jak taka metoda powinna zostać zaimplementowana.

Joshua Bloch w swojej książce przedstawia warunki matematyczne, które musi spełniać prawidłowa metoda equals. Te same warunki można znaleźć w dokumentacji Javy. Sam też pozwolę je sobie tutaj przytoczyć:

  • Zwrotność – w skrócie oznacza, że obiekt musi być zawsze równy samemu sobie. Wydaje się to dość naturalne
  • Symetria – ten warunek wymaga aby metoda equals zwracała ten sam wynik niezależnie od kolejności obiektów
    x.equals(y) == y.equals(x)
  • Przechodniość – jeżeli obiekt pierwszy jest równy obiektowi drugiemu, a drugi obiekt jest równy obiektowi trzeciemu to obiekty pierwszy i trzeci też muszą być sobie równe:
    x.equals(y) && y.equals(z) ==> x.equals(z)
  • Spójność – dla ustalonych obiektów metoda equals zawsze będzie zwracała taką samą wartość (oczywiście tak długo, jak nie zmodyfikujemy obiektów)
  • Żaden obiekt nie jest równy obiektowi null.

Instrukcja implementacji metody equals

  1. Sprawdź, czy referencja this i referencja przekazana jako argument metody wskazują na ten sam obiekt. Użyj operatora ==.
  2. Sprawdź, czy obiekt przekazany w argumencie metody equals jest obiektem właściwego typu. Użyj do tego operatora instanceof.
  3. Rzutuj argument przekazany w argumencie metody equals na właściwy typ – nie dostaniesz błędu bo przecież sprawdziłeś, czy ta operacja jest dozwolona w kroku 2
  4. Dla każdego pola sprawdź czy wartość pola w obiekcie this jest taka sama jak w obiekcie przekazanym do metody equals:
    • Dla typów prostych różnych od float i double użyj po prostu operatora ==
    • Dla typu float przekształć wartości pola używając metody Float.floatToIntBits i porównaj wynikowe wartości int używając operatora ==
    • Dla typy double przekształć wartości pola używając metody Double.doubleToLongBits i porównaj wynikowe wartości long używając operatora ==
    • Dla typów referencyjnych użyj metody equals. Pamiętaj o możliwych wartościach null – wywołanie metody equals na referencji wskazującej na null spowoduje rzucenie wyjątku NullPointerException
    • Pola-tablice, rządzą się trochę swoimi prawami, ale pamiętaj, że zawsze możesz wykorzystać jedną z wielu metod statycznych equals z klasy Arrays.

Po skorzystaniu z tej instrukcji możemy zdefiniować w naszej klasie następującą metodę:

@Override
public boolean equals(Object o) {
    if (this == o) {
        return true;
    }
    if (o == null) {
        return false;
    }
    if (! (o instanceof SimpleClass01)) {
        return false;
    }
    SimpleClass01 s = (SimpleClass01) o;
    return this.a == s.a;
}

Na co powinniśmy zwrócić uwagę. Przede wszystkim powinniśmy sprawdzić warunek zwrotności i symetrii (przechodniość i spójność z reguły są wtedy zachowane). Powinniśmy zdefiniować metodę equals, która jako argument przyjmuje referencję typu Object. Użycie innego typu argumentu nie spowoduje błędu kompilacji (no chyba, że położymy annotację @Override). Ale w dalszej perspektywie może spowodować trudne do znalezienia błędy w działaniu aplikacji. Na przykład kolekcje korzystają z metod equals, której argument jest typu Object.

Jest jeszcze jedna bardzo ważna rzecz, którą musimy zrobić po zaimplementowaniu metody equals. Musimy nadpisać metodę hashCode, która jest zdefiniowana w klasie Object. W dalszej części artykułu wyjaśnię, dlaczego jest to takie istotne.

Moje IDE (czyli IntelliJ) daje mi możliwość wygenerowania metod equals i hashCode jednocześnie (w sensie nie mogę wygenerować jednej bez tej drugiej). Dostaję następującą metodę equals:

@Override
public boolean equals(Object o) {
    if (this == o) return true;
    if (o == null || getClass() != o.getClass()) return false;
    SimpleClass01 that = (SimpleClass01) o;
    return a == that.a;
}

W metodzie wygenerowanej przez IntelliJ rzuca się w oczy brak użycia operatora instanceof. Zamiast niego jest sprawdzana równość

getClass() != o.getClass()

Metoda getClass() jest metodą zdefiniowaną w klasie Object. Tej metody nie należy nadpisywać – zawsze będzie działała prawidłowo. Metoda ta zwraca obiekt klasy Class. Co musimy wiedzieć o tej klasie?

  • Dla każdej klasy, którą zdefiniujemy w naszym programie Java wygeneruje jeden obiekt klasy Class. W związku z tym uzasadnione jest użycie operatora == to sprawdzenia, czy klasa obiektu this jest taka sama jak obiektu 0.
  • Klasa ta zwiera matadane na temat naszej klasy (na przykład listę wszystkich pól zdefiniowanej w naszej klasie – włącznie z polami prywatnymi)

Widzisz dwie bardzo podobne metody, które różnią się tak naprawdę jednym szczegółem. Na pewno się teraz zastanawiasz, które rozwiązanie jest tym prawidłowym. Odpowiedź jest jedną z moich ulubionych odpowiedzi. Żeby na nie odpowiedzieć musimy rozważyć aspekty filozoficzne związane z programowaniem obiektowym.

Rozważmy klasę, która będzie rozszerzała klasę SimpleClass01, którą przedstawiłem na początku tego wpisu:

public class SimpleClass02 extends SimpleClass01 {
    private int b;

    public SimpleClass02() {
        super(0);
        this.b = 1;
    }

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

    public int getB() {
        return b;
    }

    public void setB(int b) {
        this.b = b;
    }
}

I zastanówmy się nad testem:

@Test
void simpleComparingTest() {
    // given
    SimpleClass01 o1 = new SimpleClass01();
    SimpleClass02 o2 = new SimpleClass02();

    // when
    boolean result = o1.equals(o2);

    // then
    assertTrue(result);
}

Jaki wynik powinno dać porównanie obiektów o1 i o2? Pierwsza myśli powinna brzmieć false. Przecież obiekt o1 ma jedno pole a o wartości 0, a obiekt o2 ma jedno pole a o wartości 0 i jedno pole b o wartości 1. Czyli jakby nie patrzeć, obiekty są różne i nie ma szans, żeby traktować te obiekty jako równe.

Ale czy aby na pewno? Zgodnie z regułą podstawiania Liskov, każdy obiekt podklasy powinniśmy móc traktować jako obiekt nadklasy. W związku z tym obiekt o2 możemy potraktować jako obiekt klasy SimpleClass01. A jeżeli właśnie tak będziemy traktować obiekt o2, to nie będziemy w stanie zauważyć różnicy pomiędzy obiektami o1 i o2. Jest to argumentacja, która przemawia za użyciem operatora instanceof. I w jako takiej, nie ma w niej błędu logicznego. Jest po prostu trochę inne podejście do kwestii dziedziczenia.

Podejście z użyciem operatora instanceof jednak nie jest w pełni poprawne. No bo zgodnie z nim wartość zmiennej

boolean result1 = o1.equals(o2);

powinna wynosić true. Ale wartość zmiennej

boolean result2 = o2.equals(o1);

wyniesie false. Oczywiście mówimy o przypadku, że do definicji klasy SimpleClass02 dodamy definicję metody equals wykorzystującej operator instanceof.

A to możemy rozważyć jako naruszenie wymogu symetrii, który stawiamy metodzie equals. Można jednak się z tym kłócić, bo przecież do obliczenia wartości zmiennej result1 zostanie użyta metoda z klasy SimpleClass01, a do obliczenia wartości zmiennej result2 zostanie użyta metoda z klasy SimpleClass02.

Moim zdaniem takie niejednoznaczności w programowaniu, dodają wiele uroku w życiu programisty. I dają okazję, do długich dyskusji, jak należy do problemu podejść.

Funkcja skrótu

Funkcje hashujące, po polsku nazywa się funkcjami skrótu lub funkcjami mieszającymi. Jest to specjalna funkcja, która dowolnemu obiektowi przypisuje liczbę całkowitą. Funkcje tego typu mają przeogromne zastosowanie w informatyce. Dziś ograniczę się do wymienienia ich zastosowania w strukturach danych. O tych strukturach napiszę jeszcze osobny artykuł (słowo!) Dziś tak w dużym skrócie.

Java udostępnia cały pakiet kolekcji – czyli klas, których obiekty są wykorzystywane do przechowywania innych obiektów. Różne kolekcje mają różne zastosowania. Dziś chciałbym tylko opowiedzieć o kolekcjach typu zbiór (Set) – są to takie kolekcje, które pozwalają na przechowywanie tylko unikalnych obiektów. To znaczy, że nie znajdą się w zbiorze dwa obiekty, które będą w relacji equals. Takie struktury danych wykorzystuje się w sytuacjach, gdy chcemy szybko sprawdzić, czy dany obiekt został dodany do kolekcji, czy nie. Oczywiście, zawsze możemy wykorzystać po prostu tablicę. Ale wtedy, żeby sprawdzić, czy dany obiekt znajduje się w kolekcji, czy też nie – musielibyśmy przejrzeć całą tablicę. Są metody dużo bardziej wydajne.

Jedna z nich zaimplementowana jest przez klasę HashSet. Jak sama nazwa wskazuje, klasa ta wykorzystuje funkcję hashCode obiektów, które w tej kolekcji są przechowywane. Możemy sobie wyobrazić, że taki obiekt klasy HashSet jak tablicę list. Wynik funkcji hashCode wskazuje, do której listy ma trafić dany obiekt. Jak wygląda dodawanie elementu do takiego zbioru?

  1. Wyznacz wartość funkcji hashCode
  2. Znajdź odpowiadającej tej wartości listę
  3. Przejrzyj wszystkie elementy przechowywane w liście i sprawdź, czy nie ma tam już takiego obiektu (przy wykorzystaniu metody equals)
  4. Jeżeli elementu nie ma, dodaj element na koniec listy

Algorytm sprawdzania, czy dany element znajduje się w zbiorze polega na wykonaniu 3 pierwszych kroków powyższego algorytmu i zwróceniu właściwej wartości. Najdłużej może potrwać wykonanie kroku trzeciego – bo potencjalnie funkcja haszująca może zwracać takie same wartości dla wielu różnych obiektów. Jakie wymagania dotyczące funkcji hashCode wynikają z tego, co tu powiedzieliśmy?

  1. Dla zadanego obiektu (tak długo, jak stan obiektu się nie zmienia) metoda hashCode musi za każdym razem zwrócić taką samą liczbę
  2. Metoda hashCode powinna wykonywać się możliwie szybko
  3. Im funkcja hashCode lepiej rozrzuca obiekty (różne obiekty trafiają do różnych list), tym cała struktura działa wydajniej

Mniej oczywista jest cecha pod numerem 4:

funkcja hashCode musi zwrócić taką samą liczbę dla obiektów, które są sobie równe w sensie metody equals.

Czy już widzisz, czym mogłoby się skończyć nie zachowanie tej właściwości? Dokładnie tak, moglibyśmy dodać do jednego zbioru dwa obiekty, które będą równe sobie w sensie metody equals.

Pogrubioną własność numer 4, możemy wyrazić za pomocą implikacji:

Jeżeli dwa obiekty są równe w sensie equals, to metoda hashCode musi dla obu tych obiektów zwrócić tą samą wartość.

Chciałbym zwrócić Waszą uwagę, że odwrotna implikacja, czyli

jeżeli dwa obiekty mają taką samą wartość metody hashCode, to muszą być sobie równe

nie jest wymagana. Taka własność nie mogłaby być prawdziwa. Dlaczego? Weźmy pod uwagę prostą matematykę.

Wynik metody hashCode jest liczbą typu int. Zgodnie z dokumentacją Javy wartość zmiennej typu int mieści się w przedziale od -231 do 231 – 1. Co daje dokładnie 232 możliwych wartości metody hashCode.

Jeżeli natomiast weźmiemy obiekt klasy SimpleClass02 – to wszystkich możliwych (parami różnych) obiektów może być 232 * 232 = 264. Więc nie ma fizycznej możliwości, żeby wszystkie obiekty klasy SimpleClass02 miały unikalne wartości metody hashCode.

Metoda hashCode, podobnie jak metoda equals, jest metodą zdefiniowaną w klasie Object. I podobnie jak metoda equals ma swoją implementację, która zostanie wywołana, jeżeli nie nadpiszemy tej metody w naszej klasie. Oczywiście, warunek 4 jest spełniony dla domyślnej implementacji. Przypomnijmy, domyślnie equals porównuje tylko, czy this i argument metody equals pokazują na ten sam obiekt. Metoda hashCode zwraca adres w pamięci, pod którym znajduje się obiekt, na rzecz którego wywoływana jest metoda. W związku z tym, jeżeli dwa obiekty są sobie równe w sensie metody equals (czyli to są referencje na ten sam obiekt w pamięci), to metoda hashCode zwróci taką samą wartość (dokładnie ten sam adres w pamięci).

Mam nadzieję, że już teraz rozumiesz, dlaczego jednoczesne nadpisywanie metod hashCode i equals jest takie ważne.

Instrukcja implementacji metody hashCode

Zdaję sobie sprawę, że we współczesnych czasach, nikt z palca nie pisze takich metod. Nie dość, że każde IDE generuje tą metodę automatycznie, to jeszcze od Javy 1.7 jest klasa Objects, która posiada metodę statyczną hash, która wyznacza funkcję haszującą dowolnego zbioru obiektów. Z kronikarskiego obowiązku czuję się zobowiązany przedstawić Ci sposób obliczania funkcji haszującej dla dowolnej klasy. Może kiedyś będziesz miał okazję zabłysnąć na rozmowie o pracę lub na randce. Nigdy nie wiesz, jaka wiedza przyda Ci się w przyszłości.

  1. Stwórz zmienną typu int o nazwie result i przypisz do tego pola wartość 17
  2. Dla każdego pola f w klasie wyznacz wartość c i zmodyfikuj wartość zmiennej result:
    result = 31 * result + c;
  3. Zwróć wartość result.

Teraz przyjrzyjmy się jak dla pola f możemy wyznaczyć wartość c:

  • boolean:
    (f ? 0 : 1)
  • byte, char, short, int:
    (int)f
  • long:
    (int)(f^(f>>>32))
  • float:
    Float.floatToIntBits(f)
  • double:
    Double.doubleToLongBits(f) i następnie otrzymana wartość typu long jak wyżej
  • typ referencyjny:
    wykorzystaj metodę hashCode zdefiniowaną we właściwej klasie, jeżeli pole ma wartość null to przyjmij 0
  • tablica:
    to zależy jak potraktowałeś tablicę w przypadku funkcji equals, ale najczęściej stosuje się mechanizm, że każdy element tablicy traktujemy jako kolejne pole w klasie

Podsumowanie

Bardzo dziękuję, że doszedłeś do końca tego wpisu. Najważniejsze przesłanie, które powinieneś zapamiętać, z lektury tego artykułu to to:

  • Zawsze, gdy nadpisujesz metodę equals to musisz nadpisać metodę hashCode
  • Nie zawsze musisz nadpisywać te metody
  • Gdy obiekty są sobie równe, to muszą mieć takie same hashe

Kod do tego artykułu jest jak zwykle na githubie.

Już niedługo napiszę ciąg dalszy tego artykułu, czyli opowiem o Javowych kolekcjach. Ale zanim to, to w planach mam kilka innych wpisów.

Aspirujący twórca internetowy, który zna się na programowaniu i chce się dzielić wiedzą

Dodaj komentarz

Twój adres email nie zostanie opublikowany. Pola, których wypełnienie jest wymagane, są oznaczone symbolem *

Scroll to top