Niezbędnik Juniora: Kontrakt hashCode i equals

By 18 October 2015 April 25th, 2017 Niezbędnik Juniora

W tym wpisie nieco więcej o meto­dach hash­Code i equals, dlaczego mówimy o nich tylko razem, oraz o najczęst­szych błę­dach w kodzie z tym związanych.

Metody hashCode oraz equals

Obie metody są meto­da­mi klasy Object i najczęś­ciej nie prze­j­mu­je­my się ich imple­men­tacją. To może być akcep­towalne w przy­pad­ku obiek­tów, które żyją tylko chwilę i nie prze­chowu­je­my ich w kolekc­jach (np. obiek­ty trans­fer­owe), ale jeśli dochodzi seri­al­iza­c­ja obiek­tów lub ich prze­chowywanie np. w bazie danych czy nawet kolekc­jach, imple­men­tac­ja obu tych metod powin­na być jed­ną z pier­wszych rzeczy, którą zro­bimy. Z zasady jed­nak metody te powin­ny być imple­men­towane dla każdego obiek­tu, nieza­leżnie od jego przez­naczenia czy sposobu uży­cia i warto wyra­bi­ać sobie tego rodza­ju nawyki.

Tutaj czytel­nikom naszego kur­su należy się słowo wyjaśnienia — fak­ty­cznie w kur­sie przy obsłudze danych metody te nie są poruszane. Pominię­cie to zostało wyko­nane celowo, ponieważ w przy­pad­ku uży­cia opisanym w kur­sie nie spowodu­je to prob­lemów, a celem lekcji było pod­sta­wowe zapoz­nanie z mech­a­niz­ma­mi obsłu­gi bazy danych. Jed­nak każde rozsz­erze­nie funkcjon­al­noś­ci może spowodować nieoczeki­wane efek­ty i dobrą prak­tyką jest tworze­nie tych metod do każdego typu obiek­tów prze­chowu­ją­cych dane, nieza­leżnie od tego, czy obec­nie z nich korzys­tamy czy też nie.

Porównywanie obiektów w Javie

Java ofer­u­je stan­dar­d­owy oper­a­tor porów­na­nia ==, jed­nak nie może on być uży­wany do porów­na­nia czy obiek­ty są ‘takie same’. Oper­a­tor == porównu­je, czy dwie zmi­enne odnoszą się do dokład­nie tego samego obiek­tu (tzn. dokład­nie tego samego miejs­ca w pamię­ci kom­put­era). Dlaczego jest to prob­le­mem? Weźmy np poniższy kod:

Integer integerOne = new Integer(12345678);
Integer integerTwo = new Integer(12345678);
if (integerOne == integerTwo) {...}

Teo­re­ty­cznie oba obiek­ty reprezen­tu­ją to samo, więc oczeki­wal­ibyśmy, że porów­nanie w trze­ciej lin­i­jce będzie prawdzi­we. Otóż uży­cie kon­struk­to­ra powodu­je, że zawsze twor­zony jest nowy obiekt — mamy więc dwa różne obiek­ty, które reprezen­tu­ją to samo. Oper­a­tor == sprawdza tylko czy są to dokład­nie te same obiek­ty, a to nie jest praw­da. Stąd meto­da equals — jako mech­a­nizm w Javie do porów­na­nia, czy obiek­ty ‘znaczą to samo’ bardziej niż ‘są tym samym obiektem’.

Kontrakt hashCode() i equals()

Meto­da ta jed­nak częs­to wys­tępu­je w parze z metodą hash­Code, obie te metody częs­to opisu­je się w kon­tekś­cie kontraktu.

Na początku zas­tanówmy się, co to znaczy, że obiek­ty są równe — w prak­tyce wyma­ga to częs­to porów­na­nia wszys­t­kich pól, zagłębi­a­jąc się, jeśli pola te są obiek­ta­mi. Może to być czasochłonne, szczegól­nie jeśli weźmiemy pod uwagę kolekc­je czy oper­ac­je sor­towa­nia — teo­re­ty­cznie musimy porów­nać ‘każdy obiekt z każdym’ (w prak­tyce, moż­na to zro­bić opty­mal­niej i wewnętrzne mech­a­nizmy robią to w opty­mal­niejszy sposób). Twór­cy Javy dos­zli do wniosku, że porówny­wanie całych obiek­tów nie ma sen­su w więk­szoś­ci przy­pad­ków, ponieważ to, że dwa obiek­ty są różne, moż­na w dużej częś­ci przy­pad­ków ‘przewidzieć’ w dużo szyb­szy sposób — np porównu­jąc jed­no pole (id czy nazwę) albo kil­ka pól. Dodatkowo wiele algo­ryt­mów związanych z kolekc­ja­mi i sor­towaniem potrze­bu­je ‘grupować’ obiek­ty wg jakiegoś kry­teri­um aby efek­ty­wniej pra­cow­ać. W ten sposób pow­stała kon­cepc­ja metody hashCode.

Meto­da hash­Code zwraca prymi­tyw int — w założe­niu jest to ‘skrót’ (stąd ‘hash’ w nazwie) obiek­tu, którego policze­nie powin­no być proste i szy­bkie. Wartość ta powin­na być deter­min­isty­cz­na (tzn. dla danego obiek­tu zawsze taka sama) oraz ‑w ide­al­nym przy­pad­ku — o rozkładzie jed­nos­ta­jnym w całym zakre­sie int. Ponieważ jed­nak typ ten ma ogranic­zony zakres, oczy­wiś­cie nie jest to unikalna wartość dla każdego obiek­tu — dwa różne obiek­ty mogą zwracać tą samą wartość hash­Code nieza­leżnie od siebie. To właśnie tą właś­ci­wość wyko­rzys­tu­je wiele API Javy i to ona jest pod­stawą kon­trak­tu, o którym mowa.

Kon­trakt hash­Code i equals mówi o tym, że jeżeli wartość hash­Code dla 2 obiek­tów jest taka sama, to obiek­ty te mogą być równoz­naczne (inny­mi słowy equals może zwró­cić true lub false). Jeśli jed­nak hash­Code jest różny, to equals zawsze zwró­ci false. Na tym założe­niu opiera się całe Col­lec­tions API, w szczegól­noś­ci Set’y oraz wszelkie metody typu con­tains(), hasKey() etc.

Implementacja metod hashCode() i equals()

Sko­ro wiemy już jak ważne są te metody, to jak je napisać? Otóż odradza­my ich pisanie (patrz następ­na sekc­ja) — użyj swo­jego IDE aby wygen­erował je za ciebie (w Eclipse klikamy prawym przy­ciskiem mysz­ki wewnątrz klasy, po czym wybier­amy opcję Source > gen­er­ate hash­Code() and equals()) lub sko­rzys­taj z gen­er­a­torów kodu (np. Lom­bok). Poza bard­zo specy­ficzny­mi przy­pad­ka­mi imple­men­tac­ja tych metod samodziel­nie praw­ie zawsze jest kiep­skim pomysłem.

Problemy, problemy…

Kon­trakt pomiędzy hash­Code i equals jest częs­to nie do koń­ca rozu­mi­any przez pro­gramistów, cza­sem zanied­by­wany lub po pros­tu zapom­i­nany. Same kry­te­ria, które funkc­je te powin­ny speł­ni­ać także nie są try­wialne, co nieste­ty prowadzi co kilku częstych błędów.

Są one o tyle nieprzy­jemne, że bard­zo częs­to trud­no je wyśledz­ić. Objawem może być np. obiekt, który jest pod­wójnie dodany do seta lub obiekt, który nie został do niego dodany. Nie powodu­je to wyjątku w miejs­cu dodawa­nia 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 dzi­wne wartoś­ci i zachowa­nia. Wyśledze­nie takiego prob­le­mu jest bard­zo czasochłonne i wyma­ga dobrej zna­jo­moś­ci sys­te­mu, dlat­ego weź sobie do ser­ca poniższe rady i pamię­taj o nich, a unikniesz wielu godzin bezsen­sownych poszukiwań ;)

Nowe pola, zmiana ich znaczenia

Jed­nym z najczęst­szych prob­lemów jest sytu­ac­ja, w której klasa ‘otrzy­mu­je’ nowe pole, którego zapom­n­imy uwzględ­nić w obu meto­dach (lub co gorsza — tylko w jed­nej z nich, łamiąc kon­trakt). Automaty­czne gen­er­a­to­ry lub wal­ida­to­ry są tutaj dużą pomocą, jeśli jed­nak ręcznie generu­jesz kod tych metod, po mody­fikacji pól obiek­tu najlepiej usunąć obie metody i wygen­erować je od nowa.

Implementacja tylko metody equals(), bez hashCode()

To kole­jny częsty przy­padek, w którym imple­men­towana jest tylko meto­da equals — wystar­cza ona, jeśli sami porównu­je­my obiek­ty w jakimś warunku, ale jest to zła­manie kon­trak­tu, o którym mówiliśmy wcześniej (domyśl­na imple­men­tac­ja hash­Code() z typu Object nie uwzględ­nia żad­nych pól obiektu).

Niepoprawny hashCode()

Inną prak­tyką jest ułatwian­ie sobie życia przez pro­gramistów i zwracanie jako hash­Code() np. wartoś­ci pola id albo liczbowej reprezen­tacji daty utworzenia obiek­tu. O ile tech­nicznie kon­trakt może być zachowany w takiej sytu­acji, taka imple­men­tac­ja będzie zwracała wartoś­ci w bard­zo ogranic­zonym przedziale wartoś­ci, najczęś­ciej nie będzie to też rozkład zbliżony do jed­nos­ta­jnego nawet w zawężonym przedziale. Wszys­tkie imple­men­tac­je korzys­ta­jące z hash­Code (jak np. HashMap czy Hash­Set) będą nieop­ty­malne — zami­ast oczeki­wanej złożonoś­ci optymisty­cznej O(1) w prak­tyce będzie ona bliższa O(n) — jeśli oper­ac­je na kolekcji są częś­cią innego algo­ryt­mu na dużych zbio­rach, nietrud­no się domyślić, że spowodu­je to duży prob­lem z wydajnością.

Własna implementacja

Połącze­niem wszys­t­kich powyższych prob­lemów jest samodzielne imple­men­towanie wspom­ni­anych metod — poza opisany­mi już rzecza­mi, ist­nieje ryzyko przeoczenia ważnego warunku czy oper­acji, co bard­zo trud­no wykryć. Nigdy nie spotkałem się z sytu­acją, w której wygen­erowany kod nie był­by zgod­ny z rzeczy­wis­toś­cią i konieczne była­by włas­na imple­men­tac­ja (co nie znaczy, że tak nie jest — ale wys­tępu­je to w naprawdę specy­ficznych przy­pad­kach, z który­mi raczej się nie spotkasz szy­bko w swo­jej karierze).

To co odróż­nia dobrego pro­gramistę od prze­cięt­nego to świado­mość przewa­gi gotowych rozwiązań nad włas­ny­mi imple­men­tac­ja­mi — i tak jest też w tym wypadku.

Podsumowanie

Pod­sumowu­jąc — nie próbuj wymyślać koła od nowa ;) Gen­er­a­to­ry kodu czy makra IDE są po to, żeby ich uży­wać! Nie bój się także testów — co mnie nieustan­nie zaskaku­je, bard­zo niewiele sys­temów tes­tu­je poprawność dzi­ała­nia metod hash­Code i equals, a jeszcze mniej tes­tu­je je razem. Nie wiem z czego to wyni­ka, ale wiem, że oszczędz­iły­by co najm­niej kilkukrot­nie w sys­temach z który­mi pra­cow­ałem wielu godzin pra­cy pro­gramistów szuka­ją­cych źródła dzi­wnego zachowa­nia. Testy kon­trak­tu hash­Code i equals powin­ny być częś­cią testów jed­nos­tkowych każdego sys­te­mu, ponieważ jest to jed­no z ważniejszych założeń czynionych przez stan­dar­d­owe API Javy i wielu bibliotek.

 

O hash­Code i equals możesz poczy­tać także tutaj: