W tym wpisie nieco więcej o metodach hashCode i equals, dlaczego mówimy o nich tylko razem, oraz o najczęstszych błędach w kodzie z tym związanych.
Metody hashCode oraz equals
Obie metody są metodami klasy Object i najczęściej nie przejmujemy się ich implementacją. To może być akceptowalne w przypadku obiektów, które żyją tylko chwilę i nie przechowujemy ich w kolekcjach (np. obiekty transferowe), ale jeśli dochodzi serializacja obiektów lub ich przechowywanie np. w bazie danych czy nawet kolekcjach, implementacja obu tych metod powinna być jedną z pierwszych rzeczy, którą zrobimy. Z zasady jednak metody te powinny być implementowane dla każdego obiektu, niezależnie od jego przeznaczenia czy sposobu użycia i warto wyrabiać sobie tego rodzaju nawyki.
Tutaj czytelnikom naszego kursu należy się słowo wyjaśnienia — faktycznie w kursie przy obsłudze danych metody te nie są poruszane. Pominięcie to zostało wykonane celowo, ponieważ w przypadku użycia opisanym w kursie nie spowoduje to problemów, a celem lekcji było podstawowe zapoznanie z mechanizmami obsługi bazy danych. Jednak każde rozszerzenie funkcjonalności może spowodować nieoczekiwane efekty i dobrą praktyką jest tworzenie tych metod do każdego typu obiektów przechowujących dane, niezależnie od tego, czy obecnie z nich korzystamy czy też nie.
Porównywanie obiektów w Javie
Java oferuje standardowy operator porównania ==, jednak nie może on być używany do porównania czy obiekty są ‘takie same’. Operator == porównuje, czy dwie zmienne odnoszą się do dokładnie tego samego obiektu (tzn. dokładnie tego samego miejsca w pamięci komputera). Dlaczego jest to problemem? Weźmy np poniższy kod:
Integer integerOne = new Integer(12345678);
Integer integerTwo = new Integer(12345678);
if (integerOne == integerTwo) {...}
Teoretycznie oba obiekty reprezentują to samo, więc oczekiwalibyśmy, że porównanie w trzeciej linijce będzie prawdziwe. Otóż użycie konstruktora powoduje, że zawsze tworzony jest nowy obiekt — mamy więc dwa różne obiekty, które reprezentują to samo. Operator == sprawdza tylko czy są to dokładnie te same obiekty, a to nie jest prawda. Stąd metoda equals — jako mechanizm w Javie do porównania, czy obiekty ‘znaczą to samo’ bardziej niż ‘są tym samym obiektem’.
Kontrakt hashCode() i equals()
Metoda ta jednak często występuje w parze z metodą hashCode, obie te metody często opisuje się w kontekście kontraktu.
Na początku zastanówmy się, co to znaczy, że obiekty są równe — w praktyce wymaga to często porównania wszystkich pól, zagłębiając się, jeśli pola te są obiektami. Może to być czasochłonne, szczególnie jeśli weźmiemy pod uwagę kolekcje czy operacje sortowania — teoretycznie musimy porównać ‘każdy obiekt z każdym’ (w praktyce, można to zrobić optymalniej i wewnętrzne mechanizmy robią to w optymalniejszy sposób). Twórcy Javy doszli do wniosku, że porównywanie całych obiektów nie ma sensu w większości przypadków, ponieważ to, że dwa obiekty są różne, można w dużej części przypadków ‘przewidzieć’ w dużo szybszy sposób — np porównując jedno pole (id czy nazwę) albo kilka pól. Dodatkowo wiele algorytmów związanych z kolekcjami i sortowaniem potrzebuje ‘grupować’ obiekty wg jakiegoś kryterium aby efektywniej pracować. W ten sposób powstała koncepcja metody hashCode.
Metoda hashCode zwraca prymityw int — w założeniu jest to ‘skrót’ (stąd ‘hash’ w nazwie) obiektu, którego policzenie powinno być proste i szybkie. Wartość ta powinna być deterministyczna (tzn. dla danego obiektu zawsze taka sama) oraz ‑w idealnym przypadku — o rozkładzie jednostajnym w całym zakresie int. Ponieważ jednak typ ten ma ograniczony zakres, oczywiście nie jest to unikalna wartość dla każdego obiektu — dwa różne obiekty mogą zwracać tą samą wartość hashCode niezależnie od siebie. To właśnie tą właściwość wykorzystuje wiele API Javy i to ona jest podstawą kontraktu, o którym mowa.
Kontrakt hashCode i equals mówi o tym, że jeżeli wartość hashCode dla 2 obiektów jest taka sama, to obiekty te mogą być równoznaczne (innymi słowy equals może zwrócić true lub false). Jeśli jednak hashCode jest różny, to equals zawsze zwróci false. Na tym założeniu opiera się całe Collections API, w szczególności Set’y oraz wszelkie metody typu contains(), hasKey() etc.
Implementacja metod hashCode() i equals()
Skoro wiemy już jak ważne są te metody, to jak je napisać? Otóż odradzamy ich pisanie (patrz następna sekcja) — użyj swojego IDE aby wygenerował je za ciebie (w Eclipse klikamy prawym przyciskiem myszki wewnątrz klasy, po czym wybieramy opcję Source > generate hashCode() and equals()) lub skorzystaj z generatorów kodu (np. Lombok). Poza bardzo specyficznymi przypadkami implementacja tych metod samodzielnie prawie zawsze jest kiepskim pomysłem.
Problemy, problemy…
Kontrakt pomiędzy hashCode i equals jest często nie do końca rozumiany przez programistów, czasem zaniedbywany lub po prostu zapominany. Same kryteria, które funkcje te powinny spełniać także nie są trywialne, co niestety prowadzi co kilku częstych błędów.
Są one o tyle nieprzyjemne, że bardzo często trudno je wyśledzić. Objawem może być np. obiekt, który jest podwójnie dodany do seta lub obiekt, który nie został do niego dodany. Nie powoduje to wyjątku w miejscu dodawania do kolekcji, może spowodować wyjątek gdzieś w zupełnie innej części aplikacji a może wcale nie ‘ujawnić’ się poprzez wyjątek, a bardziej przez dziwne wartości i zachowania. Wyśledzenie takiego problemu jest bardzo czasochłonne i wymaga dobrej znajomości systemu, dlatego weź sobie do serca poniższe rady i pamiętaj o nich, a unikniesz wielu godzin bezsensownych poszukiwań ;)
Nowe pola, zmiana ich znaczenia
Jednym z najczęstszych problemów jest sytuacja, w której klasa ‘otrzymuje’ nowe pole, którego zapomnimy uwzględnić w obu metodach (lub co gorsza — tylko w jednej z nich, łamiąc kontrakt). Automatyczne generatory lub walidatory są tutaj dużą pomocą, jeśli jednak ręcznie generujesz kod tych metod, po modyfikacji pól obiektu najlepiej usunąć obie metody i wygenerować je od nowa.
Implementacja tylko metody equals(), bez hashCode()
To kolejny częsty przypadek, w którym implementowana jest tylko metoda equals — wystarcza ona, jeśli sami porównujemy obiekty w jakimś warunku, ale jest to złamanie kontraktu, o którym mówiliśmy wcześniej (domyślna implementacja hashCode() z typu Object nie uwzględnia żadnych pól obiektu).
Niepoprawny hashCode()
Inną praktyką jest ułatwianie sobie życia przez programistów i zwracanie jako hashCode() np. wartości pola id albo liczbowej reprezentacji daty utworzenia obiektu. O ile technicznie kontrakt może być zachowany w takiej sytuacji, taka implementacja będzie zwracała wartości w bardzo ograniczonym przedziale wartości, najczęściej nie będzie to też rozkład zbliżony do jednostajnego nawet w zawężonym przedziale. Wszystkie implementacje korzystające z hashCode (jak np. HashMap czy HashSet) będą nieoptymalne — zamiast oczekiwanej złożoności optymistycznej O(1) w praktyce będzie ona bliższa O(n) — jeśli operacje na kolekcji są częścią innego algorytmu na dużych zbiorach, nietrudno się domyślić, że spowoduje to duży problem z wydajnością.
Własna implementacja
Połączeniem wszystkich powyższych problemów jest samodzielne implementowanie wspomnianych metod — poza opisanymi już rzeczami, istnieje ryzyko przeoczenia ważnego warunku czy operacji, co bardzo trudno wykryć. Nigdy nie spotkałem się z sytuacją, w której wygenerowany kod nie byłby zgodny z rzeczywistością i konieczne byłaby własna implementacja (co nie znaczy, że tak nie jest — ale występuje to w naprawdę specyficznych przypadkach, z którymi raczej się nie spotkasz szybko w swojej karierze).
To co odróżnia dobrego programistę od przeciętnego to świadomość przewagi gotowych rozwiązań nad własnymi implementacjami — i tak jest też w tym wypadku.
Podsumowanie
Podsumowując — nie próbuj wymyślać koła od nowa ;) Generatory kodu czy makra IDE są po to, żeby ich używać! Nie bój się także testów — co mnie nieustannie zaskakuje, bardzo niewiele systemów testuje poprawność działania metod hashCode i equals, a jeszcze mniej testuje je razem. Nie wiem z czego to wynika, ale wiem, że oszczędziłyby co najmniej kilkukrotnie w systemach z którymi pracowałem wielu godzin pracy programistów szukających źródła dziwnego zachowania. Testy kontraktu hashCode i equals powinny być częścią testów jednostkowych każdego systemu, ponieważ jest to jedno z ważniejszych założeń czynionych przez standardowe API Javy i wielu bibliotek.
O hashCode i equals możesz poczytać także tutaj: