Pozor na Equals

Tento článek jsem chtěl napsat už dlouho. Dnes jsem si na to o čem chci psát sám naběhl, takže jsem se konečně rozhoupal. O co jde? Všichni Java programátoři vědí, že každá třída má metodu equals. Často se ovšem setkáme s tím, že není správně naimplementována.

Uveďme si jednoduchý příklad. Máme třídu PhoneNumber, která obsahuje telefonní číslo ve tvaru předčíslí a samotné číslo.



public class PhoneNumber {
  private String areaCode;
  private String extension;
  
  public PhoneNumber(String areaCode, String extension) {
    this.areaCode = areaCode;
    this.extension = extension;
  }
  
  
  public String getAreaCode() {
    return areaCode;
  }
  public String getExtension() {
    return extension;
  }
}

Třída pěkně funguje, ovšem jenom do té doby, než se ji pokusím vyhledat v nějaké kolekci.



    PhoneNumber phoneNumber = new PhoneNumber("555","123-456");
    Set<PhoneNumber> aSet = new HashSet<PhoneNumber>();
    aSet.add(phoneNumber);
    
    assertTrue(aSet.contains(phoneNumber));
    assertTrue(aSet.contains(new PhoneNumber("555","123-456")));

Poslední aserce selže. Není divu, Java neví jak poznat, že jsou si obě instance třídy rovny. To ji musí říci programátor. Když ji to neřekne, jsou instance porovnány pomocí operátoru ==, což v tomto případě není to co bychom chtěli. Náprava je snadná, můžeme použít například následující implementaci



  @Override
  public boolean equals(Object obj) {
    if (obj==thisreturn true;
    if (!(obj instanceof PhoneNumber)) return false;
    PhoneNumber second = (PhoneNumber)obj;
      return new EqualsBuilder()
                     .append(this.areaCode, second.areaCode)
                     .append(this.extension, second.extension)
                     .isEquals();
  }

Z lenosti jsem použil EqualsBuilder, který je součástí commons-lang knihovny. Nicméně test mi stále neprojde. Zkušenější už vědí, porušil jsem základní pravidlo

Když překrýváte equals, překryjte také hashCode

To napravím jednoduše, například takto:



  @Override
  public int hashCode() {
    return new HashCodeBuilder(1737).
         append(areaCode).
         append(extension).
         toHashCode();
  }

(Alternativou k předchozím implementacím, je nechat si obě metody vygenerovat vývojovým prostředím. Výsledek se bude lišit jenom v detailech. )

Důležitá ovšem není implementace, ale logika celého porovnání. Musíme určit, jak poznám rovnost instancí. To často závisí na logice aplikace a často se může u stejné třídy lišit pro různé případy užití. Mějme například třídu Client, která obsahuje jméno, příjmení a kolekci telefonních čísel. Musíme určit, kdy jsou si dvě instance této třídy rovny. Ideální je případ, kdy máme tzv. business klíč. V případě klienta by to mohlo být například rodné číslo. Poté stačí porovnat jenom rodná čísla, a víme, jestli obě instance představují stejného klienta.

Když ovšem business klíč nemáme, musíme si poradit jinak. Můžeme tvrdit, že instance jsou si rovny, pokud mají stejné všechny položky. To je ovšem často velmi pracné, chybové a člověk hledá cestu jak si práci ulehčit. Navíc pokud toto equals chci používat v kolekcích, musím zajistit, aby se mi equals v čase neměnilo. Tzn. nesmí se mi například změnit, když klientovi přidám telefonní číslo. V tomto případě selže poslední řádek následujícího testu.



    Client client = new Client("John","Doe");
    
    Set<Client> aSet = new HashSet<Client>();
    aSet.add(client);
    
    assertTrue(aSet.contains(client));
    client.addPhoneNumber(new PhoneNumber("555","123-456"));
    assertTrue(aSet.contains(client));//fails

Důvod je jasný. Změnila se hodnota hashCode a prvek je hledán na špatném místě Setu (ve špatném kýblu). To může vést k veselým „záhadným“ chybám. Řešením by bylo udělat třídu Client neměnitelnou. To si ovšem často nemůžeme dovolit.

Pokud používáme objektově relační mapování, nabízí se použít databázové ID jako položku přes kterou porovnávám. Na první pohled to může vypadat jako dobrý nápad. ID je jednoznačné a nemění se. Je tu ovšem jeden zásadní háček. Po vytvoření instance je toto ID obvykle null. Takže následující test selže.



    Set<Client> aSet = new HashSet<Client>();
    aSet.add(new Client("John","Doe"));
    aSet.add(new Client("Bill","Smith"));
    assertEquals(2, aSet.size());

Důvod je jednoduchý, v Setu může být každý prvek jenom jednou. Při vkládání Billa Smithe, Set zjistí, že už vkládaný prvek obsahuje (equals vrací true, protože mají stejné ID). Takže prvního klienta nahradí druhým! To asi není to co bychom chtěli (hledáním této chyby jsem strávil jenom jednu hodinu, to jde ne?). Jednoho klienta to prostě v tichosti sežere. Pokud nebudeme vkládat neperzistované objekty do Setu, neměli bychom mít žádný problém. Pro jistotu můžeme upravit equals vyhozením výjimky, takže kód neselže potichu, ale pěkně nahlas (kód vygenerovaný pomocí Eclipse, výjimka přidána ručně).



  @Override
  public boolean equals(Object obj) {
    if (this == obj)
      return true;
    if (obj == null)
      return false;
    if (getClass() != obj.getClass())
      return false;
    final Client other = (Clientobj;
    
    if (id==null && other.id==null)
    {
      throw new UnsupportedOperationException("Can not compare two transient objects.");
    }
    if (id == null) {
      if (other.id != null)
        return false;
    else if (!id.equals(other.id))
      return false;
    return true;
  }

Pro shrnutí uvedu tabulku možností implementace, abyste si mohli vybrat to nejmenší z možných zel:

Implementace equals Nevýhody
Porovnání business klíčů (rodné číslo)
Porovnání neměnitelných polí
Porovnání měnitelných polí Po změně hodnoty, může selhat vyhledání v kolekci
Implicitní implementace (a==b) Nelze porovnávat různé instance. Při použití Hibernate, nelze porovnat instance, které nejsou ze stejné Session
Porovnání id (a.getId().equals(b.getId()) Neperzistované objekty nelze vkládat do Setu (a podobných kolekcí)

Kde se dočíst více:

Povinná kniha pro každého kdo se chce věnovat Javě:
Bloch Joshua, Java Efektivně (57 zásad softwarového experta) Grada 2002, http://www.grada.cz/katalog/kniha/java-efektivne/

Kapitola z originálu o Equals:
http://developer.java.sun.com/developer/Books/effectivejava/Chapter3.pdf

Povídání o equals a Hibernate:
http://www.hibernate.org/109.html

3 thoughts on “Pozor na Equals

  1. Satai

    Jeden z vysledku tohoto povidani by mohlo byt, ze se programator pasti do cela a rekne si, ze imutable objekty jsou fain. Ale rvat je trebas do prace s hibernatem je opruz, takze nejsou vseresici :/

  2. benzin

    Dobre ze o tom pisete. Jen snad dodat. Jak zabranit tomu, aby clovek nezapomel na implementaci equals a hashCode?

    1) Prvni moznost je nastavit spelchecker (minimalne tak, aby kdyz implementujete equals musite mit implementovanou hashCode, resp. opacne)
    2) Generator kodu tridy automaticky generuje hashCode metodu a equals, ale jenom kostru (bez returnu). Tudiz se to neda skompilovat a musite implementaci dopsat.

    Btw. casem sem prisel na to, ze je vhodne udrzovat v databazi informaci o poslednim updatu zaznamu. Nekdy je dobre udrzovat i informaci o vytvoreni zaznamu. Kazdopadne tuto informaci nastavuji jiz pri vytvarni daneho objektu. Takze porovnani muze prochazet prave pres tento datum, ktery je na 99,999999999999% jedinecne pro dany zaznam (jinak samozrejme neni-li id nulove, porovnavam podle neho).

    A snad jeste nakonec
    if (getClass() != obj.getClass()) return false; Mi prijde dost nedokonale. Napriklad, kdyz se bude jednat o potomka tridy muzete mit problem. Takze ja preferuji operator if (!(o instanceof getClass())) { retrn false; } resp. if (!(o instanceof TatoTrida.class)) { return false; }

  3. Lukáš Křečan Post author

    Já jsem o tom if (getClass() != obj.getClass()) return false; přemýšlel už dříve (původně jsem to chtěl i napsat do článku). Tato varianta má právě tu výhodu (nebo nevýhodu), že bude fungovat i na porovnání dvou instancí potomka. Takže pokud budeme porovnávat například podle business klíče a pro potomka bude toto porovnání také stačit, není pro něj potřeba implementovat metodu equals.

Comments are closed.