JE_14 - Dávejte přednost kompozici před dědičností

Dědičnost představuje mocný způsob, jak zajistit opakovanou využitelnost kódu, není k tomu všk vždy ten nejlepší nástroj. Je-li nevhodně používána, vzniká křehký software. Je bezpečné používat dědičnost v rámci nějakého balíčku, kde je implementace podtříd a nadtříd pod kontrolou stejných programátorů. Je rovněž bezpečné používat dědičnost při rozšiřování tříd, které jsou pro takové rozšíření speciálně navrženy a dokumentovány (rada 15). Dědění z běžných konkrétních tříd mezi hranicemi balíčků je však nebezpečné. Jenom pro připomenutí, tato kniha používá slovo "dědění" ve významu dědění implementací (když jedna třída rozšiřuje druhou). Problémy popisované v této radě neplatí pro dědění rozhraní (když třída implementuje nějaké rozhraní nebo když jedno rozhraní rozšiřuje druhé).

Narozdíl od volání metod narušuje dědičnost zapouzdření. Jiným slovy, podtřída závisí řádným fungováním na implementačních detailech své nadtřídy. Implementace této nadtříd se může v další verzi změnit a dojde-li k tomu, může dojít ke zhroucení podtřídy, třebaže se její kód vůbec nezměnil. Důsledkem je nutnost vyvíjet podtřídu v tandemu s její nadtřídou, pokud tedy autoři nadtřídu nenavrhli a nezdokumentovali přesně pro účely rozšiřování.

Abychom si vše ukázali konkrétně, představme si, ž máme nějaký program používající HashSet. Abychom dokázali zlepšit výkonnost našeho programu, musíme se třídy HashSet dotazovat, kolik prvků do ní bylo přidáno od jejího vytvoření (což nelze zamě�ovat s její aktuální velikostí, která se po odstranění nějakého prvků sníží). Pro zajištění této funkčnosti vytvoříme variantu HashSet, která sleduje počet pokusů o vložení prvků a exportuje přístupovou metodu k tomuto počtu. Třída HashSet obsahuje dvě metody schopné přidávat prvky, add a addAll, takže překryjeme obě tyto metody:

// Chyba - nevhodné použití dědičnosti!
public class InstrumentedHashSet extends HashSet {
	// Počet pokusů o vložení prvků
	private int addCount = 0;
	public InstrumentedHashSet() {
	}

	public InstrumentedHashSet(Collection c) {
		super(c);
	}

	public InstrumentedHashSet(int initCap, float loadFactor) {
		super(initCap, loadFactor);
	}

	public boolean add(Object o) {
		addCount++;
		return super.add(o);
	}

	public boolean addAll(Collection c) {
		addCount += c.size();
		return super.addAll(c);
	}

	public int getAddCount() {
		return addCount;
	}
}

Tato třída vypadá rozumně, ale nefunguje. Předpokládejme, že vytvoříme nějakou instanci a přidáme do ní tři prvky pomocí metody addAll:

InstrumentedHashSet s = new InstrumentedHashSet();
s.addAll(Arrays.asList(new String[] {"Ťuk", "Cvak", "Bzuk"}));

Očekávali bychom, že v tomto okamžiku vrátí metoda getAddCount hodnotu tři, vrátí však šestku. Kde je chyba? Interně je metoda addAll třídy HashSet implementována nad její metodou add, třebaže HashSet velmi rozumně tento implementační detail nedokumentuje. Metoda addAll ve třídě InstrumentedHashSet přidala trojku k addCount a pak zavolala implementaci addAll třídy HashSet pomocí super.addAll. Ta zase zavolala metodu add, jak byla překryta v InstrumentedHashSet, jednou pro každý prvek. Každé z těchto tří volání přidalo k addCount další jedničku a došlo tak k celkovému vzrůstu na šest: každý prvek přidaný metodou addAll se počítá dvakrát.

Tuto třídu bychom "opravili" odstraněním jejího překrytí metody addAll. Třebaže by výsledná třída fungovala, závisela by její správná funkce na skutečnosti, že metoda addAll třídy HashSet je implementována nad její metodou add. Toto "využívání sama sebe" je implementační detail, který není zaručen ve všech implementacích platformy Java a může se v jednotlivých verzích měnit. Proto bude výsledná třída InstrumentedHashSet křehká.

Bylo by trochu lepší překrýt metodu addAll tak, aby iterovala zadanou kolekcí a volala metodu add jednou pro každý z prvků. To by zaručilo správný výsledek, ať už bude metoda addAll třídy HashSet implementována nad její metodou add nebo ne, protože implementace addAll třídy HashSet se již nebude volat. Tato technika však neřeší všechny naše problémy. Znamená opakované implementování metod nadtřídy, které mohou a nemusejí skončit ve využívání sama sebe, což je obtížné, časově náročné a náchylné k chybám. navíc není tento přístup vždy možný, jelikož některé metody nelze implementovat bez přístupu k soukromým atributuům nepřístupným pro danou podtřídu.

Související příčinou křehkosti v podtřídách je to, že jejich nadtřída může v dalších verzích získat nové metody. Představme si, že zabezpečení programu závisí na tom, že všechny prvky vložené do určité kolekce napl�ují nějaké tvrzení. Toho lze dosáhnout vytvořením podtřídy takové kolekce a překrytím všech metod schopných přidat nějaký prvek, čímž bude před přidáním prvku zajištěno, že dané tvrzení platí. To funguje dobře až do okamžiku, než se v další verzi objeví v nadtřídě nová metoda schopná přidat prvek. Jakmile k tomu dojde, lze do instance podtřídy přidat "ilegální" prvek prostým voláním této nové metody, která není ve třídě překrytá. To není jen čistě teoretický problém. Bylo zaptřebí opravit několik bezpečnostních děr této podstaty, když byly třídy nazvané Hashtable a Vector upraveny tak, aby se mohly účastnit rámce kolekcí (Collections Framework).

Oba výše zmíněné problémy pramení z překrývání metod. Mohli byste si myslet, že je bezpečné rozšiřovat třídu, pokud jen přidáte nové metody a nebudete žádné stávající překrývat. Třebaže je tento druh rozšíření mnohe bezpečnější, není bez rizika. Pokud ve své další verzi získá nadtřída novo metodu a vy máte tu smůlu, že jste přiřadili své podtřídě metodu se stejným podpisem a jiným návratovým typem, vaše podtřída se již nezkompiluje. Pokud jste své podtřídě dali metodu s přesně stejným podpisem jako má nová metoda nadtřídy, tak ji nyní překrýváte a jste vystaveni oběma výše zmíněným problémům. Navíc metoda stěží naplní kontrakt nové metody nadtřídy, protože žádný takový kontrakt ještě neexistoval v době, kdy jste psali svou metodu podtřídy.

Naštěstí existuje způsob, jak se vyhnout všem výše popsaným problémům. Místo rozšiřování existující třídy dejte své nové třídě soukromý atribut, který se bude odkazovat na nějakou instanci dané existující třídy. Tento návrh se označuje za kompozici (skládání), protože existující třída se stává komponentou (složkou) té nové. Každá metoda instance v nové třídě volá odpovídající metodu v obsažené instanci existující třídy a vrací výsledek. To se označuje za přesměrování (předávání, forwarding) a metody v nové třídě jsou známé jako přesměrovávací metody. Výsledná třída bude naprosto stabilní a nebude vůbec záviset na implementačních detailech existující třídy. Ani přidání nových metod do existující třídy nijak neovlivní naši novou třídu. Konkrétní příklad ukazuje lepší třída InstrumentedSet, která používá přístup s kompozicí a přesměrováváním:

// Obalová třída - používá kompozici namísto dědičnosti
public class InstrumentedSet implements Set {
	private final Set s;
	private int addCount = 0;

	public InstrumentedSet(Set s) {
		this.s = s;
	}

	public boolean add(Object o) {
		addCount++;
		return s.add(o);
	}

	public boolean addAll(Collection c) {
		addCount += c.size();
		return s.addAll(c);
	}

	public int getAddCount() {
		return addCount;
	}

	// Přesměrovávací metody
	public void clear() {
		 s.clear(); 
	}
	
	public boolean contains(Object o) {
		return s.contains(o);
	}

	public boolean isEmpty() {
		return s.isempty();
	}

	public int size() {
		return s.size();
	}

	public Iterator iterator() {
		return s.iterator();
	}

	public boolean remove(Object o) {
		return s.remove(o);
	}

	public boolean containsAll(Collection c) {
		return s.containsAll(c);
	}

	public boolean removeAll(Collection c) {
		return s.removeAll(c);
	}

	public boolean retainAll(Collection c) {
		return s.retainAll(c);
	}

	public Object[] toArray() {
		return s.toArray();
	}

	public Object[] toArray(Object[] a) {
		return s.toArray(a);
	}

	public boolean equals(Object o) {
		return s.equals(o);
	}

	public int hashCode() {
		return s.hashCode();
	}

	public String toString() {
		return s.toString();
	}
}

Návrh třídy InstrumentedSet je umožněn existencí rozhraní Set, které zachytává funkčnost třídy HashSet. nejenže je robustní, ale tento návrh je také extrémně flexibilní. Třída InstrumentedSet implementuje rozhraní Set a má jediný konstruktor, jehož argument je rovněž typu Set. Třída v zásadě transformuje jeden Set na jiný a přidává funkčnost zpracování. Narozdíl od přístupu založeného na dědičnosti, který funguje pouze pro jednu konkrétní třídu a vyžaduje samostatný konstruktor pro každý podporvaný konstruktor v nadtřídě, lze používat tuto obalovou třídu k instrumentaci libovolné implementace Set a navíc bude fungovat ve spojení s jakýmkoli již existujícím konstruktorem:

Set s1 = new InstrumentedSet(new TreeSet(list));
Set s2 = new InstrumentedSet(new HashSet(capacity, loadFactor));

Třídu InstrumentedSet lze dokonce použít k dočasné instrumentaci instance množiny, která již byla použita bez instrumentace:

static void f(Set s) {
	InstrumentedSet sInst = new InstrumentedSet(s);
	. . . // V této metodě používejte sInst místo s

Třída InstrumentedSet se považuje za obalovou třídu, protože každá instance InstrumentedSet obaluje jinou instanci Set. To se také označuje za dekorační vzor, protože třída InstrumentedSet "dekoruje" nějakou množinu přidáním instrumentace (zpracování). Občas se kombinace kompozice a přesměrovávání chybně označuje za delegování. Technicky se ale nejedná o delegování, pokud obalový se objekt sám nepředává obalovému objektu.

Nevýhod obalových tříd je jen několik. Jednou z nich je skutečnost, že obalové třídy nejsou vhodné pro použití v rámcích zpětného volání, kde objekty předávají odkazy samy na sebe jiným objektům, aby mohly být později volány ("zpětné volání"). Protože obalený objekt neví nic o své obálce, předá odkaz sám na sebe (this) a zpětná volání se obálce vyhnou. To se označuje za problém SELF. Někteří lidé se obávají výkonnostního dopadu volání přesměrovávacích metod nebo zvýšených paměťových nároků obalových objektů. Ukazuje se však, že nic z toho nemá v praxi velký vliv. Je trochu únavné psát přesměrovávací metody, tao nuda je však částečně vyvážena skutečností, že musíte napsat jen jeden konstruktor.

Dědičnost je namístě jenom za těch okolností, kdy je daná podtřída skutečně podtypem své nadtřídy. Jinými slovy, třída B by měla rozšiřovat třídu A, pouze pokud mezi těmito dvěma třídami existuje vztah "je nějaký". Máte-li pocit, že by měla třída B rozšiřovt určitou třídu A, pak si položte otázku: Je každé B skutečně nějakým A? Nemůžete-li na tutuo otázku podle pravdy odpovědět kladně, pak by třída B neměla rozšiřovat A. Je-li odpověď záporná, pak by třída B měla často obsahovat nějakou soukromou instanci A a vystavovat menší a jednodušší API: A není základní součástí B, jenom detailem její implementace.

V knihovnách platformy Java je řada zřejmých narušení tohoto principu. Například zásobník není vektor, takže třída Stack by neměla rozšiřovat Vector. Podobně není seznam vlastností hešovací tabulkou, takže třída Properties by neměla rozšiřovat třídu Hashtable. V obou případech by byla lepší kompozice.

Použijete-li dědičnost na místě, kde by byla vhodnější kompozice, pak zbytečně odhalujete implementační detaily. Výsledné API vás svazuje s původní implementací a navždy omezuje výkonnost vaší třídy. Vážnější je však to, že vystavením vnitřních částí umož�ujete klientům přímo k nim přistupovat. To může vést přinejmenším ke zmatené sémantice. Když se například p odkazuje na instanci Properties, pak může p.getProperties(key) vrátit jiný výsledek než p.get(key). První uvedená metoda bere v úvahu výchozí zadání, zatímco druhá metoda, která se dědí z Hashtable, to nečiní. Nejvážnější však je, že klient může být schopen narušit invarianty dané podtřídy přímou úpravou nadtřídy. V případě třídy Properties návrháři chtěli, aby se jako klíče a hodnoty mohly vyskytovat pouze řetězce ale přímý přístup k bázové třídě Hashtable umož�uje narušení tohoto invariantu. Jakmile je tento invariant narušen, není již možné používat další součásti API Properties, load a store. V době, kdy byl tento problém odhalen, bylo již příliš pozdě na jeho nápravu, protože klienti záviseli na používání neřetězcových klíčů a hodnot.

Ještě než se rozhodnete používat a nikoli kompozici, měli byste si položit ještě jednu sadu otázek. Má třída, jejíž rozšíření zvažujete, ve svém API nějaké chyby nebo problémy? Pokud ano, nevadí vám postoupení těchto chyb do API vaší třídy? Dědičnost postupuje všechny chyby v API nadtřídy, zatímco kompozice vám umož�uje navrhnout nové API, které tyto chyby skryje.

Celkově lze říci, že dědičnost je mocná, ale také problematická, protože narušuje zapouzdření. Je vhodná pouze v situaci, kdy mezi podtřídou a příslušnou nadtřídou existuje opravdový vztah podtypů. I pak však může vést dědičnost ke křehkosti, pokud se podtřída nachází v jiném balíčku než nadtřída a nadtřída není přímo navržena k rozšíření. Chcete-li se vyhnout takové křehkosti, používejte místo dědičnosti kompozici a přesměrovávání, zejména existuje-li příslušné rozhraní k implementaci nějaké obalové třídy. Nejenže jsou obalové třídy robustnější než podtřídy, ale jsou také mocnější.