JE_07: Při překrývání equals se podrobte obecnému kontraktu

Překrývání metody equals se zdá jednoduché, existuje zde však mnoho nesprávných způsobů s nepříjemnými následky. nejsnáze se všem problémům vyhnete, když nebudete metodu equals překrývat - v takovém případě je každá instance rovná pouze sama sobě. Tak je to správné, pokud platí kterákoli z následujících podmínek:

	public boolean equals(Object o) {
		throw new UnsupportedOperationException();
	}

Kdy je tedy vhodné překrýt Object.equals? Když se třída zabývá logickou rovností, která se liší od prosté identity objektů, a nadtřída zatím nepřekryla equals a neimplementovala požadované chování. To je obvykle příklad hodnotových tříd, jako je Integer nebo Date. Programátor, který prorvnává odkazy na hodnotové objekty pomocí metody equals očekává, že zjistí, zda si logicky odpovídají a nikoli zda odkazují na tentýž objekt. Nejenže je tedy překrytí metody equals nezbytné pro naplnění očekávání programátora, ale navíc umož�uje instancím dané třídy sloužit jako klíče mapy nebo prvky množiny s předvídatelným, žádaným chováním.

Jedním z druhů hodnotových tříd, která nevyžaduje překrytí metody equals, je typově zabezpečený výčet (rada 21). Protože třídy zabezpečených výčtů zaručují, že s každou hodnotou existuje najvýše jeden objekt, metoda equals třídy Object odpovídá u takových tříd logické metodě equals.

Když přepisujete metodu equals, musíte se držet jejího obecného kontraktu. Dále je tento kontrakt uvedený, zkopírovaný ze specifikace java.lang.Object:

Metoda equals implementuje vztah ekvivalence:

Pokud právě nemáte rádi matematiku, může se vám to zdát trochu děsivé, nesmíte to však ignorovat! Když uvedená pravidla narušíte, pak můžete zjistit, že se váš program chová nepostižitelně nebo že se hroutí, a přitom může být velmi obtížné určit zdroj selhání. Abych parafrázoval Johna Donna, žádná třída není ostrov, Instance jedné třídy se často předávají jiné. Mnoho tříd včetně kolekcí, se spoléhá na to, že jim předávané respektují kontrakt equals.

Protože jste si nyní vědomi zla vznikajícího narušením kontraktu metody equals, projděme si celý kontrakt podrobněji. Dobrou zprávou je to, že bez ohledu na první dojem není opravdu daný kontrakt příliš komplikovaný. Jakmile jej pochopíte, nebudete mít potíže s jeho dodržením. Postupně se podívejme na jeho pět požadavků:

Reflexivnost   První požadavek prostě říká, že se objekt musí rovnat sám sobě. Jen stěží si lze představit, jak byste jej mohli neúmyslně porušit. Kdybyste jej porušili a pak přidali instanci své třídy do nějaké kolekce, pak by vám metoda contains kolekce téměř jistě oznámila, že kolekce neobsahuje instanci, kterou jste právě přidali.

Symetričnost   Druhý požadavek říká, že se každé dva objekty musejí shodnout v tom, zda jsou si rovné. Na rozdíl od prvního požadavku není v tomto případě obtížné představit si neúmyslné narušení tohoto požadavku. Zvažme například následující třídu:


/**
 * �etězec nerozlišující velikost znaků. Tvar původního řetězce
 * je zachován pomocí toString, ale ignorován v porovnáních.
 */
public final class CaseInsensitiveString {
	private String s;

	public CaseInsensitiveString(String s) {
		if (s == null)
			throw new NullPointerException();
		this.s = s;
	}

	// Chyba - narušení symetričnosti!
	public boolean equals(Object o) {
		if (o instanceof CaseInsensitiveString)
			return s.equalsIgnoreCase((CaseInsensitiveString)o).s);
		if (o instanceof String) // Jednosměrná interoperabilita!
			return s.equalsIgnoreCase((String)o);
		return false;
	}

	. . .

}

Dobře myšlená metoda equals v této třídě se naivně pokouší pracovat s běžnými řetězci. Předpokládejme, že máme jeden řetězec nerozlišující velikost písem a jeden normální řetězec:

	CaseInsensitiveString cis = new CaseInsensitiveString("polish");
	String s = "polish";

Podle očekávání vrátí cis.equals(s) hodnotu true. Problém spočívá v tom, že zatímco metoda equals v CaseInsensitiveString ví něco o normálních řetězcích, metoda equals ve třídě String vůbec řetězce nerozlišující velikosti písem nezná. Proto vrátí výraz s.equals(cis) hodnotu false, což představuje jasné narušení symetričnosti. Předpokládejme, že vložíte řetězec nerozlišující velikost písmen do nějaké kolekce:

	List list = new ArrayList();
	list.add(cis);

Co v tomto okamžiku vrátí list.contains(s)? Kdo ví! V aktuální implemntaci společnosti Sun se náhodou vrátí false, ale to je jen implementační artefakt. V jiné implementaci se může stejně dobře vrátit true nebo se může vyvolat výjimka za běhu. Jakmile narušíte kontrakt metody equals, pak prostě nevíte, jak se budou chovat jiné objekty, když jim předáte svůj objekt.

Chcete-li tento problém vyřešit, pak jednoduše odstra�te chybnou snahu o práci se třídou String z metody equals. Jakmile to učiníte, můžete metodu opravit:

	public boolean equals(Object o) {
		return o instanceof CaseInsensitiveString &&
			((CaseInsensitiveString)o).s.equalsIgnoreCase(s);
	}

Tranzitivita   Třetí požadavek kontraktu equals říká, že pokud je jeden objekt rovný druhému a druhý třetímu, apk musí být první objekt rovný třetímu. Znovu není obtížné představit si neúmyslné narušení tohoto požadavku. Zvažme příklad programátora, který vytvoří nějakou podtřídu, který přidává nový aspekt své nadtřídě. Jinými slovy, podtřída přidá nějakou informaci, která ovliv�uje porovnání equals. Začněme jednoduchou neměnitelnou třídou dvojrozměrného bodu:

public class Point {
	private final int x;
	private final int y;
	
	public Point(int x, int y) {
		this.x = x;
		this.y = y;
	}

	public boolean equals(object o) {
		if (!(o instanceof Point))
			return false;
		Point p = (Point)o;
		return p.x == x && p.y == y;
	}

	. . .

}

Předpokládejme, že chcete tuto třídu rozšířit a přidat jí pojem o barvě bodu:

public class ColorPoint extends Point {
	private Color color;

	public ColorPoint(int x, int y, Color color) {
		super(x, y);
		Point.color = color;
	}

	. . .

}

Jak bude vypadat metoda equals? Pokud ji úplně vynecháte, pak se její implementace zdědí z Point a při porovnáních equals se bude informace o barvě ignorovat. To sice nenarušuje kontrakt equals, je to však zárove� naprosto nepřijatelné. Předpokládejme, že napíšete metodu equals, která vrací true, pouze je-li jejím argumentem jiný barevný bod se stejnou polohou a barvou:

	// Chyba - narušuje symetričnost!
	public boolean equals(Onject o) {
		if (!(instanceof ColorPoint))
			return false;
		ColorPoint cp = (ColorPoint)o;
		return super.equals(o) && cp.color == color;
	}

Problém této metody spočívá v tom, že můžete obdržet různé výsledky, když budete porovnávat bod s barevným bodem a naopak. První porovnání barvu ignoruje, zatímco druhé vrací vždy false, protože typ argumentu je nesprávný. Abychom si vše ukázali konkrétně, vytvořme jeden bod a jeden barevný bod:

	Point p = new Point(1, 2);
	ColorPoint cp = new ColorPoint(1, 2, Color.RED);

Pak p.equals(cp) vrací true, zatímco cp.equals(p) vrací false. Problém se můžete pokusit vyřešit tak, že colorPoint.equals bude ignorovat barvu při vykonávání "smíšených porovnání":

	// Chyba - narušuje tanzitivnost!
	public boolean equals(Object o) {
		if (!(o instanceof Point))
			return false;
		
		// Jde-li o normální Point, vykonat provnání bez barvy
		if (!(o instanceof ColorPoint))
			return o.equals(this);
		// o je ColorPoint: vykonat plné porovnání
		ColorPoint cp = (ColorPoint)o;
		return super.equals(o) && cp.color == color;
	}

Tento přístup sice zajišťuje symetričnost, ale na úkor tranzitivnosti:

	ColorPoint p1 = new ColorPoint(1, 2, Color.RED);
	Point p2 = new Point(1, 2);
	ColorPoint p3 = new ColorPoint(1, 2, Color.RED);

V tomto okamžiku příkazy p1.equals(p2) a p2.equals(p3) vracejí true, zatímco příkaz p1.equals(p3) vrací false, což je jasné narušení tranzitivity. První dvě porovnání neberou v úvahu barvu, zatímco třetí ji zvažuje.

Jaké je tedy řešení? Ukazuje se, že se jedná o fundamentální problém vztahů ekvivalence v objektově orientovaných jazycích. Zkrátka nelze žádným způsobem rozšířit třídu, jejíž instance lze vytvářet, a přidat k ní nějaký aspekt a zárove� zachovat kontrakt metody equals. Existuje však hezká možnost, jak problém obejít. �iďte se doporučením v radě 14 nazvané "Dávejte přednost kompozici před dědičností". Místo toho, aby ColorPoint rozšiřovala Point, dejte ColorPoint soukromý atribut Point a nějakou veřejnou metodu pohledu (rada 4), která vrátí bod na stejné pozici, jako je daný barevný bod:

// Přidává aspekt bez narušení kontraktu equals
public class ColorPoint {
	private Point point;
	private Color color;

	public ColorPoint(int x, int y, Color color) {
		point = new Point(x, y);
		this.color = color;
	}

	
	/**
	 * Vrátí bodový pohled daného barevného bodu
	 */
	public Point asPoint() {
		return point;
	}

	public boolean equals(Object o) {
		if (!(o instanceof ColorPoint))
			return false;
		ColorPoint cp = (ColorPoint)o;
		return cp.point.equals(point) && cp.color.equals(color);
	}

	. . .

}

V knihovnách platformy Java jsou určité třídy, které tvoří podtřídu nějaké jiné třídy, jejíž instance lze vytvářet, a navíc přidávají nějaký aspekt. Například java.sql.Timestamp tvoří podtřídu java.util.Date, které přidává atribut nanoseconds. Impementace metody equals u Timestamp narušuje symetričnost a může způsobovat nestálé chování, pokud se objekty Timestamp a Date používají v téže kolekci nebo jsou jinak smíšené. Třída Timestamp obsahuje varování upozor�ující programátora na to, že nelze mísit data a časové značky. Pokud je nebudete míchat dohromady, sice se do žádných problémů nedostanete, ale na druhou stranu vám v tom ani nic nebrání a následné chyby se budou jen obtížně hledat. Třída Timestamp je anomálií a neměli byste ji opakovat.

Uvědomte si, že můžete přidat určitý aspekt podtřídě nějaké abstraktní třídy, aniž byste přitom narušili kontrakt equals. To je důležité v takových hierarchiích tříd, které získáte, budete-li se držet rady v radě 20 označené "Nahrazujte sjednocení hierarchiemi tříd". Můžete mít například abstraktní třídu Shape bez aspektů, podtřídu Circle, která přidává atribut radius, a podtřídu Rectangle, která přidává atribut length a width. Problémy výše uvedeného rázu se neprojeví, pokud není možné vytvořit instanci dané nadtřídy.

Konzistentnost   Čtvrtý požadavek kontraktu equals říká, že pokud jsou si dva objekty rovné, musejí zůstat rovné neustále, jestliže nedojde ke změně jednoho z nich (nebo obou). TO není vlastně ani opravdový požadavek, ale spíše připomínka toho, že měnitelné objekty se mohou rovnat jiným objektům v různých okamžicích, zatímco neměnitelné objekty ne. Při zápisu třídy si dobře promyslete, zda by neměla být neměnitelná (rada 13). Pokud dojdete ke kladné odpovědi, pak zajistěte, aby vaše metoda equals zajišťovala omezení spočívající v tom, že rovné objekty si zůstávají rovny a nerovné nerovny po celou dobu.

Nenullovost   Poslední požadavek, pro nějž jsem při neexistenci jasného názvu zvolil označení "nenullovost" (se dvěma "l"), říká, že všechny objekty musejí být nerovné hodnotě null. Třebaže je obtížné představit si náhodné vracení true v reakci na volání o.equals(null), není již tak složité představit si neúmyslné vyvolání NullPointerException. To však obecný kontrakt nedovoluje. Mnoho tříd má metody equals, které se tomu brání explicitním testem hodnoty null:

	public boolean equals(Object o) {
		if (o == null)
			return false;
		...
	}

Tento test je zbytečný. Při testování rovnosti argumentu musí metoda equals nejprve převést argument na příslušný typ, aby mohli být volání jeho předchůdci a aby byly přístupné jeho atributy. Ještě před převodem musí metoda použít operátor instanceof a prověřit, že má argument správný typ:

	public boolean equals(Object o) {
		if (!(o instanceof MyType))
			return false;
		...
	}

Kdyby tato kontrola typu chyběla a metodě equals by byl předán argument nesprávného typu, pak by metoda equals vyvolala výjimku ClassCastException, což narušuje kontrakt equals. Operátor instanceof však podle specifikace vrací false, je-li jeho první operand null, bez ohledu na to, jaký typ se objevuje jako druhý operand [JLS, 15.19.2]. Proto kontrola typu vrátí false, pokud předáte null, takže nemusíte vykonávat samostatnou kontrolu null. Když vše dáte dohromady, získáte následující recept na vysoce kvalitní metody equals:

  1. Pomocí operátoru == prověřte, zda je argument odkazem na tento objekt. Pokud ano, vraťte true. Jedná se jen o výkonnostní optimalizaci, která je však velmi užitečná, pokud je porovnání potencionálně náročné.
  2. Pomocí operátoru instanceof prověřte, zda je argument správného typu. Pokud ne, vraťte false. Správným typem je obvykle třída, v níž se metoda vyskytuje. Občas se jedná o nějaké rozhraní implementované touto třídou. Rozhraní použijte, pokud daní třída implementuje nějaké rozhraní, jež zpodrob�uje kontrakt equals a umož�uje porovnání mezi třídami impementujícími toto rozhraní. Tuto vlastnost mají rozhraní kolekcí Set, List, Map a Map.Entry.
  3. Převeďte argument na správný typ. Protože před tímto převodem se vyskytoval test instanceof, je jeho úspěch zaručen.
  4. U každého "významného" atributu dané třídy prověřte, zda daný atribut argumentu odpovídá příslušnému atributu tohoto objektu. Pokud všechny tyto testy uspějí, vraťte true; jinak vraťte false. Je-li typem v kroku 2 rozhraní, musíte přistupovat k významným atributům argumentu prostřednictvím metod rozhraní; je-li tímto typem třída, možná budete moci přistupovat k atributům přímo, podle jejich přístupnosti. U primitivních atributů, jejichž typem není float ani double, používejte při porovnáních operátor ==; u atributů odkazů na objekty volejte metodu equals rekurzivně; u atributů float překládejte hodnoty na int pomocí Float.floatToIntBits a porovnávejte tyto hodnoty int pomocí operátoru == (speciální zacházení s atributy float a double je zapotřebí kvůli existenci Float.NaN, -0.0f a podobných konstant double; podrobnosti najdete v dokumentaci Float.equals). U atributů typu púole aplikujte tyto zásady na každý z prvků. Některé atributy odkazů na objekty mohou legitimně obsahovat null. Chcete-li se vyhnout možnosti vzniku výjimky NullPointerException, používejte k porovnávání takových atributů tento přístup:
    	(field == null ? o.field == null : field.equals(o.field))
    
    Tato alternativa může být rychlejší, pokud jsou field a o.field často identické odkazy na objekty:
    	(field == o.field || (field != null && field.equals(o.field)))
    
    U některých tříd, jako třeba u výše uvedené třídy CaseInsensitiveString, jsou porovnávání atributů složitější než jen prsté testy rovnosti. Ze specifikace třídy by mělo být patrné, pokud tomu tak je. Jestliže ano, pak může být vhodné uložit kanonickou formu v každém objektu. aby mohla metoda equals vykonávat levná přesná provnání těchto kanonických forem a nikoli náročnější přesná porovnání. Tato technika je nejvhodnější u neměnitelných tříd (rada 13), protože jinak by bylo při změně objekt zapotřebí aktualizovat kanonickou formu.
    Výkonnost metody equals může být ovlivněna pořadím, v němž se atributy porovnávají. Chcete-li docílit nejlepšího výsledku, měli byste nejprve porovnávat atributy, které se budou pravděpodobně lišit, které se porovnají rychleji, nebo (v ideálním případě) pro které platí obojí zárove�. Nesmíte porovnávat atributym které nejsou součástí logického atavu daného objektu, jako jsou atributy Object používané k synchronizování operací. Nemusíte porovnávat redundantní atributy, které lze vypočítat z "významných atributů", jejich porovnání však může zlepšit výkonnost metody equals. Pokud nějaký redundantní atribut představuje celkový popis celého objektu, pak vám porovnání tohoto atributu ušetří provnávání vlastních dat, jestliže selže.
  5. Když dokončíte zápis metody equals, položte si tři otázky: je symetrická, je tranzitivní a je konzistentní?. Zbývající dvě otázky se obvykle naplní samy. Pokud ne, zjistěte, proč nejsou tyto vlastnosti naplněny a metodu příslušným způsobem upravte.

Konkrétní příklad metody equals zkonstruované podle výše uvedeného recpetu najdete jako PhoneNumber.equals v radě 8. Zde je několik posledních poznámek: