Chyby v metodách hashCode a hlavně equals jsou velmi zákeřné a mohou se v některých případech projevovat doslova až po nasazení programu u zákazníka; kolekce se více zaplní, konflikty v hash tabulce zapříčiní volání metody equals, ve které může být skrytá chyba. Proto je vhodné věnovat implementacím těchto metod náležitou pozornost.
Úkolem metody equals je zjistit, zda jsou si dvě instance daného typu rovny. Toho docílíme porovnáním klíčových prvků instance, například pro třídu Student by to bylo rodné číslo případně nějaký identifikátor (ID). V případě bodu v rovině to bude zřejmě porovnání obou souřadnic (x, y).
Proč vlastně máme implementovat metodu equals, vždyť implementace ze třídy Object umí přece zjistit, zda dva odkazy v paměti ukazují na stejnou instanci. To sice ano, ale představte si, že byste měli několik instancí se stejným obsahem – například načtete ze vstupu řetězec a porovnáte jej s nějakou konstantou (která je uložena v constant poolu JVM). Jsou to dvě různé instance, takže pokud by metoda String.equals nebyla korektně implementována a použila by se implementace třídy Object, rovnost by nenastala, i když by byly řetězce obsahově stejné.
Při implementaci metody equals je nutno dodržet několik pravidel (J. Bloch: Effective Java):
Příklad implementace pro bod v rovině:
public class Point { private int x, y; // ... public boolean equals(Object other) { if (other == this) return true; if (!(other instanceof Point)) return false; Point point = (Point)other; return x == point.x && y == point.y; } public int hashCode() { return x + y; } }
Všimněte si, že metoda equals přijímá jakýkoliv objekt (třída Object), výstupem je logická hodnota. Je důležité si uvědomit, že do metody equals může „vstoupit“ libovolný objekt (různého typu), včetně hodnoty null – ve všech těchto případech nesmí nastat rovnost. Metoda equals nesmí emitovat žádnou výjimku.
První řádek metody equals je optimalizační a nemusel by tam být – pakliže je odkaz other shodný s odkazem na instanci (this), jedná se o jeden a tentýž objekt a není třeba dalších testů – vracíme tedy true. Jelikož je testování rovnosti na reflexivitu častým jevem, může tento řádek zrychlit program.
Na druhém řádku se musíme ujistit, zda v objektu other je instance, kterou očekáváme. Pokud bychom to neučinili, na dalším řádku bychom mohli obdržet výjimku ClassCastException. Tento řádek budeme ještě diskutovat v dalším textu.
Na čtvrtém řádku si objekt přetypujeme na správnou třídu a na posledním zkontrolujeme všechny prvky, ze kterých vyplývá rovnost.
Obvyklým zvykem bývá řadit závěrečné podmínky tak, aby na prvním místě byla vždy podmínka, u které je nejpravděpodonější, že může selhat. Toto je obecný postup, který programátor aplikuje u všech podmínek v programu, jež jsou zřetězeny logickou spojkou a. Je to jednoduchá optimalizční technika.
V případě porovnávání referenčních typů delegujeme srovnání na metody equals daných tříd. V případě, že odkaz může obsahovat null hodnoty, je nutné toto dostatečně podchytit. Dobrým zvykem je nejprve otestovat odkazy samotné – pokud jsou stejné, je buď stejný i obsah (a tudíž není nutné volat metody equals), nebo jsou oba odkazy null (a opět nastává rovnost):
public class Person { private String name; private Date birth; // ... public boolean equals(Object other) { if (other == this) return true; if (other == null) return false; if (getClass() != other.getClass()) return false; Person person = (Person)other; return ( (name == person.name || (name != null && name.equals(person.name))) && (birth == person.birth || (birth != null && birth.equals(person.birth))) ); } }
Zajímavá otázka se týká použití get metod místo přímého přístupu na proměnné. Ve chvíli, kdy například implementujete metodu equals u objektu, který se ukládá do databáze přes JPA nebo Hibernate (a podobně), měli byste používat get metody ve chvíli, kdy je nastaven lazy load. Vzniklé potíže totiž mohou mít katastrofální následky.
Nyní se ale vrátím k druhému řádku prvního příkladu: if (!(other instanceof Point)) return false;, zde může nastat jeden problém. Když vytvoříte potomka třídy Point (například Point3D) a v metodě equals zavoláte tu z rodiče, můžete snadno porušit podmínku symetričnosti mezi instancemi point a point3d. Tedy neplatilo by, že point.equals(point3d) == point3d.equals(point):
public class Point3D extends Point { private int z; // ... public boolean equals(Object other) { if (!super.equals(other)) return false; if (!(other instanceof Point3D)) return false; Point3D point = (Point3D)other; return (z == point.z); } } Point p1 = new Point(1,1); Point p2 = new Point3D(1,1,1); // p2.equals(p1) == false; // platí // p1.equals(p2) == true; // neplatí
Toto chování přímo vyplývá ze sémantiky operátoru instanceof, kde v případě použití potomek-rodič nemůže nastat symetrie. Je tedy nutné zajistit, aby byla metoda equals implementována úplně znova (aby programátor nevolal super.equals), případně aby třída vůbec nemohla být přetížena (klíčové slovo final).
Reflexivita je velmi důležitá, pokud například vkládáme danou třídu do kolekcí. Chyby, které vznikají, jsou velmi zákeřné a mohou se projevovat až po čase. Pakliže tedy přidáváme nový datový prvek, je nutné znovu reimplementovat metodu equals, přičemž nesmíme volat rodičovské implementace u metody equals.
Jiným přístupem je nepoužívat instanceof a nahradit tuto podmínku zcela jinou, která zaručuje symetrii i v případě volání metody equals rodiče, nebo když v potomkovi nepřidáme žádnou proměnnou, na které by měla záviset rovnost. Podmínka getClass() != other.getClass() toto splňuje. V Javě je (za jistých podmínek) zajištěno, že objekty své odkazy na instanci třídy Class sdílí, takže pro porovnávání je možné použít operátor rovnosti (resp. nerovnosti). Ukázka tohoto přístupu na rodiči i potomkovi:
public class Point { private int x, y; // ... public boolean equals(Object other) { if (other == this) return true; if (other == null) return false; if (getClass() != other.getClass()) return false; Point point = (Point)other; return x == point.x && y == point.y; } public int hashCode() { return x ^ y; } } public class Point3D extends Point { private int z; // ... public boolean equals(Object other) { if (!super.equals(other)) return false; Point3D point = (Point3D)other; return (z == point.z); } public int hashCode() { return super.hashCode() ^ z; } }
Jakmile použijeme tento přístup, je nutné ve všech potomcích, kteří přidávají atributy, na kterých závisí rovnost, přetížit metodu equals (a tedy i hashCode) a dodat správnou implementaci. Pakliže přidáváme jen prvek, na kterým nezávisí rovnost, implementovat znovu metodu equals nemusíme.
Na téma, který přístup je lepší, se vedou sáhodlouhé diskuse, ale vypadá to, že druhý uvedený v java komunitě vyhrává. A to i přes to, že Joshua Bloch je zastáncem přístupu s instanceof. Pokud vytvoříme dvě instance bodu (souřadnice – 1,2) a bodu3D (souřadnice – 1,2,3), u prvního přístupu nastává v jednom směru rovnost, ve druhém ne. U druhého přístupu rovnost nenastává nikdy.
Druhý přístup (pomocí getClass) ovšem není samozřejmě dogma. Jakkoli to může být proti logice věci, v některých případech je dokonce nutné použít instanceof, abychom povolili rovnost s potomky. Například pokud používáte knihovnu Hibernate, která generuje z tříd reprezentujících entity tzv. proxy třídy jako potomky, je nutné se použití getClass vyvarovat.
Joshua přístup s instanceof obhajuje tím, že v podstatě dochází k porušení principu substituce Liskovové. A jaký je váš názor na věc? Podělte se v diskusi! Více o tomto tématu v rozhovoru s Joshua Blochem (odkaz je v referencích).
Tato metoda je zodpovědná za spočítání hashovacího kódu, což se využívá u některých kolekcí (Hashtable, HashMap) případně u jiných algoritmů (rychlé srovnávání instancí). Je to obyčejné celé číslo, které je vypočítáno na základě stavu objektu – jakmile se stav objektu (hodnota instančních proměnných) změní, změní se i toto číslo. Pokud se stav naopak nemění, hashcode se nesmí měnit. Tato ukázka nesplňuje uvedenou podmínku:
public class Point { private int x, y; // ... přístupové metody public int hashCode() { return x; } }
Pakliže změníme hodnotu y, hodnota hashe se nemění, správně bychom mohli vypočítat hash například obyčejným součtem: x + y. Je velmi důležíté počítat hodnotu hashcode z přesně těch hodnot, které porovnáváme v metodě equals.
To, jakým způsobem se má hodnota hashcode počítat, popisuje mnoho knih a článků. Zjednodušeně řečeno hodnota musí být co nejlépe distribuovaná napříč celým oborem hodnot. Proto se často používá místo obyčejného součtu XOR a čísla se násobí nějakým prvočíslem:
public int hashCode() { return x*37 ^ y*31; }
V případě, že máme počítat hashcode z instancí jiných objektů, prostě na nich zavoláme stejnou metodu. Například objekty z Java Core API mají metodu implementovanou, pokud bude třída obsahovat odkaz na nějaký námi vytvořený objekt, metodu si implementujeme sami:
public class Person { private String name; private Date birth; // ... přístupové metody public boolean hashCode() { return (name == null ? 17 : name.hashCode()) ^ (birth == null ? 31 : name.hashCode()); } }
Na tomto příkladu je vidět, že jsme nejprve otestovali hodnoty na null a poté bitovým operátorem xor spočetli výslednou hodnotu. Všimněte si také, že výstup volání metody name.hashCode() již není třeba násobit prvočíslem – předpokládáme, že číslo má již dobrou distribuci.
Pakliže je objekt neměnitelný (immutable) a na null testujeme už v konstruktoru, můžeme tyto podmínky vypustit. Neměnitelný objekt má ještě jednu výhodu při implementaci metody hashCode – spočtenou hodnotu si můžeme uložit do proměnné a při dalším zavolání vrátit jen tuto hodnotu, což bude jistě rychlejší.
Metody equals a hashCode nejsou jediné, u jejichž implementace musíte dbát zvýšené opatrnosti. Metoda comareTo rozhraní Comparable, musí být konzistentní vzhledem k equals v tom smyslu, že jakmile se dva objekty rovnají, pak nutně musí i tato metoda vracet nulu. Rozhraní Comparable potřebujete implementovat ve chvíli, kdy potřebujete instance porovnávat (třídit). Například při použití setříděných kolekcí.
S těmito třemi metodami souvisí ještě jedna důležitá věc – jakmile objekt vložíte do kolekce, která na těchto metodách závisí (množina, mapa, setříděná kolekce), objekt nesmíte změnit. Pokud tak učiníte, kolekce nemusí fungovat správně, protože nezachytí změnu – objekt je v jiném stavu (ne že by čekal miminko), a tudíž i výsledky metod jsou jiné. V setříděném stromu by měl být na jiné pozici, v hash mapě je v jiném bloku a podobně. Na změněný objekt se pak už nemusíte dostat. Proto je ideální do takových kolekcí vkládat neměnitelné objekty, případně tuto vlastnost ošetřit. Za poznámky děkuji Kamilu Páralovi.
Ačkoli je možné implementovat pouze metodu equals (a ne hashCode – ovšem ne naopak), obecně se to nedoporučuje, a to ani když nehodláte používat strukturu Map, protože metoda hashCode může zrychlit běh i jiných tříd (například seznamů). Proto je dobré implementovat vždy obě metody zároveň.
Jistým zvykem může být u všech datových objektů, které prozatím nemají implementaci těchto dvou metod, vyhazovat nějakou výjimku (NotImplementedException). Jakmile bychom zapomněli metody implementovat (a to se určitě stane), při použití s kolekcemi dostaneme výjimku. Je ale určitě lepší tyto metody implementovat ihned.
Pokud chcete, můžete využít pomocnou knihovnu Commons Lang, která obsahuje třídy EqualsBuilder a HashCodeBuilder, které mohou zpřehlednit kód těchto metod. Tyto pomocné metody používají instanceof přístup.
U projektů, na kterých pracuje více lidí, je dobré si předem říct, jaký přístup použít a tento dodržovat. A také dokumentovat, protože jinak vám začnou v programu vznikat rozbitá okna. A jak dopadne pěkný, opuštěný dům s jedním rozbitým oknem si dovedeme představit.