Náhrada dědičnosti v objektovém programování Podtřídy, delegáti, vkládání - jak se to rýmuje? V tomto článku se seznámíme s technikami objektového designu, jež v opodstatněných případech umožňují vyhnout se nešikovnému dědění a vytváření nových tříd: půjde o využívání delegátů (a jim příbuzné techniky akce/cíl), o využití kategorií pro doplnění nového API již existující třídě a o vkládání objektů. V řadě objektových prostředí je zvykem využívat dědičnost takřka na vše možné i nemožné. Chceme omezit velikost okna nejvýše na 640 [215] 480 obrazových bodů? Dobrá, vytvoříme si vlastní podtřídu standardní systémové třídy Window, v níž patřičným způsobem reimplementujeme metodu resize. Chceme tlačítko, jež ukončí aplikaci? Vytvoříme vlastní podtřídu standardní systémové třídy Button, reimplementujeme v ní metodu clicked tak, že aplikaci ukončí, a pro dané tlačítko ji použijeme. Potřebujeme prioritní frontu objektů? Vytvoříme podtřídu standardní třídy Array a přidáme patřičné služby... Ve skutečnosti však takové nadužívání dědičnosti není ideálním objektovým designem a v praxi přináší řadu problémů: nezřídka vede k tomu, že bychom potřebovali vícenásobnou dědičnost (jež je v C++ problematická a jinde není vůbec), často také způsobuje kombinaci služeb z logicky různých bloků aplikace v jediném zdrojovém souboru. V důsledku pak omezuje reusabilitu a komplikuje další úpravy kódu. PODTŘÍDY Začít lze tím, že podtřídy zcela běžně vytváříme. Součástí standardních knihoven Cocoa, kterou budeme pro ilustraci popisovaných technik používat, je dokonce řada tříd, jež jsou přímo určeny k tomu, abychom v konkrétních aplikacích používali jejich konkrétní podtřídy, a samy o sobě téměř nemají smysl (NSView). Důležité však je, že pro dosažení téhož cíle můžeme využít i jiné prostředky, které jsou často mnohem šikovnější a znamenají menší námahu pro programátora (a menší pravděpodobnost chyb). Obecně proto při objektovém designu platí následující pravidlo: Pokud nepracujeme s třídou, jež je přímo navržena pro dědění, měli bychom nejprve zvážit, zda pro vyřešení daného problému nalezneme vhodnou cestu bez vytváření nových tříd. Teprve pokud tomu tak není, měli bychom začít uvažovat o podtřídách - ne dříve. Podívejme se znovu na příklady z úvodu: * K omezení velikosti okna na 640 x 480 bodů je nejjednodušší použít standardní okno a přidělit mu tzv. delegáta - objekt, jehož se okno "zeptá", má-li změnit velikost daným způsobem. * Pro tlačítko ukončující aplikaci je nejjednodušší použít standardní tlačítko, definovat jeho akci jako zprávu terminate: a přidělit mu jako cíl objekt aplikace. * Na prioritní frontu objektů lze použít zcela obyčejnou instanci třídy, reprezentující pole, a odpovídající služby doplnit prostřednictvím tzv. kategorie. (Případně můžeme vytvořit nový objekt jako dědice základní třídy a pole do něj vložit. To je výhodnější v případě, že požadujeme i další atributy - dejme tomu, že by naše prioritní fronta měla mít ještě jméno.) DELEGACE Nejjednodušší náhradou pro řadu případů nešikovného dědění je delegace. Princip je prostý. Místo toho, aby se třída sama o všechno starala prostřednictvím svých vlastních metod, obsahuje instance třídy odkaz na spolupracující objekt, tzv. delegáta, s nímž nejrůznější akce prostřednictvím odpovídajících zpráv "konzultuje". Zásadní výhodou tohoto přístupu proti dědění je to, že můžeme funkčně odlišné bloky kódu skutečně rozdělit do různých tříd. Objektovým designem podle vzorce MVC (Model, View, Controller) se v rámci tohoto článku podrobně zabývat nemůžeme; i bez větší teorie je však zřejmé, že jde (nebo by aspoň mělo jít) o poměrně nezávislé moduly. Zatímco kód, který určuje, jak vypadá a jak funguje ovladač pro změnu velikosti, logicky patří do objektu "okno", kód, který určuje, jak se okno může zmenšovat či zvětšovat, patří do ovladače, který okno řídí, ale rozhodně ne do okna samotného (obr. 1). Využití delegace také obvykle i zjednoduší kód aplikace: jen výjimečně totiž delegáta připravujeme jako nový, samostatný objekt. Častěji jde o objekt, který slouží zároveň pro více logicky souvisejících věcí - například může jako delegát řídit okno pro zobrazení seznamu položek a zároveň jako zdroj dat předávat tyto položky tabulce, jež je uvnitř okna zobrazuje. Kromě toho se ještě může starat o aktivaci/deaktivaci tlačítek, jež nad položkami pracují. Základní mechanismus delegace je prostý a můžeme jej ve vlastních třídách s výhodou využívat. Instance má prostě k dispozici proměnnou obsahující odkaz na delegáta, metody pro přístup k této proměnné, jež se standardně jmenují delegate a setDelegate - a před důležitými operacemi a/nebo po jejich provedení se delegáta zeptá (či jej informuje). Konkrétní implementace závisí na použitém jazyce. V C++ je nutné pro delegaci definovat abstraktní třídu, jejímiž dědici budou všichni delegáti; to je samozřejmě nešikovné a nutí nás to využívat vícenásobné dědičnosti, ale to je již dáno omezeními C++. V Javě lze pro delegaci využít interface, případně (pro větší flexibilitu za cenu trochu složitějšího kódu) lze použít služeb java.lang.reflect pro dosažení týchž možností, jež jsou v Objective C. Standardní technikou v Objective C je využití tzv. neformálního protokolu pro deklaraci zpráv, jež jsou delegátovi posílány. Nejde o nic jiného než o rozhraní kategorie pro třídu NSObject. Díky němu překladač zprávy zná a vyhneme se "warningům" při jejich posílání: // deklarace zpráv, posílaných delegátovi: @class OCJumper; @interface NSObject (OCJumperDelegate) -(BOOL)jumperShouldJump:(OCJumper*)jumper; // dotaz, zda má být akce provedena @end // deklarace vlastní třídy: @interface OCJumper:... { id delegate; ... } -delegate; -(void)setDelegate:del; ... -(void)jump; ... @end Za zvláštní zmínku stojí snad jen využití standardní metody respondsToSelector: (zděděné od třídy NSObject) pro ověření, zda delegát zprávě skutečně rozumí (na jejím místě bychom v Javě použili reflexi, v C++ nic podobného není). To je důležité proto, abychom při implementaci delegáta mohli připravit pouze metody, jež potřebujeme, a nemuseli se zdržovat implementací ostatních: @implementation OCJumper -delegate { return delegate; } -(void)setDelegate:del { delegate=del; } ... -(void)performJump { // vlastní akce: není v rozhraní, jde o privátní metodu ... } -(void)jump { // pokud delegát rozumí zprávě jumperShouldJump: a odpoví NO... if ([delegate respondsToSelector:@selector(jumperShouldJump:)] && ![delegate jumperShouldJump:self]) return; // ... neděláme nic [self performJump]; // jinak provedeme vlastní akci } ... @end MECHANISMUS AKCE/CÍL Mechanismus akce/cíl (action/target) je určitou alternativou delegace. Podobně jako objekt může udržovat odkaz na delegáta, může také udržovat odkaz na cíl (target), objekt, který bude informován o provedení nějaké zásadní operace. Jednoznačné to bývá u objektů GUI - takto zásadní operací pro tlačítko je jeho stisknutí, pro nabídku výběr některé z jejích položek, pro textové pole ukončení jeho editace apod. Mechanismus akce/cíl je proti delegaci flexibilnější v tom, že i zpráva zasílaná objektem je proměnná, není tedy pevně určena, jako zpráva delegáta. Místo toho instance obsahuje vhodnou proměnnou - v Javě String obsahující jméno metody (odeslané pomocí reflexe), v Objective C využijeme typu SEL a standardní metody zděděné od třídy NSObject pro odeslání zprávy - velmi přibližně takto: // Akce/cíl pro tlačítko [8212] princip @interface Button:... { SEL action; id target; ... } ... @end ... @implementation Button ... -(void)performClick { // tlačítko bylo stisknuto [target performSelector:action withObject:target]; } ... @end Výhody proti využití dědičnosti jsou obrovské. Nejenže nemusíme "míchat" kód pro řízení aplikace s kódem samotného tlačítka, nemusíme ani psát nic "navíc". Pokud je požadovaná služba již někde k dispozici, prostě vezmeme tlačítko a jen vhodně nastavíme jeho target a action. KATEGORIE Pokud chceme sestavit jen specifické API, jež nepožaduje zásadní rozšíření služeb, lze vzít již hotovou třídu a služby doplnit prostřednictvím tzv. kategorie. To se "klasickému" vytváření podtřídy hodně podobá; z hlediska programátora je vlastně jediný rozdíl v tom, že není třeba vytvářet novou třídu. Nové metody však implementuje přesně stejným způsobem, jako by tomu bylo při využití dědičnosti. Podívejme se na obr. 2 a 3; ty dobře ilustrují jak podobnost, tak i rozdíly obou přístupů. Ukažme si nejjednodušší příklad implementace zásobníku. Jednou ze standardních tříd, jež knihovny Cocoa nabízejí, je dynamické pole objektů, NSMutableArray. Je celkem zřejmé, že nejjednodušší bude implementovat zásobník právě doplněním služeb k této třídě - v rozhraní prostě jen deklarujeme nové zprávy: @interface NSMutableArray (StackAccess) -(void)push:object; -pop; // pro prázdný zásobník vyvolá výjimku @end Implementace je víceméně zřejmá - jen přímo převedeme zásobníková primitiva na primitiva dynamického pole: @implementation NSMutableArray (StackAccess) -(void)push:object { [self addObject:object]; } -pop { id o=[[[self lastObject] retain] autorelease]; [self removeLastObject]; return o; } @end Metody addObject:, lastObject a removeLastObject jsou standardními metodami třídy NSMutableArray a jejich funkce je zřejmá z jejich jmen. Metody retain, autorelease a jejich využití jsou dané způsobem, jímž Cocoa řídí správu paměti, a jejich vysvětlení by přesáhlo rozsah článku. Jaké jsou výhody a nevýhody tohoto využití kategorie? + Kategorie funguje stejně dobře s obyčejnou třídou i se sdruženými třídami (class clusters) - s nimi by se obyčejná dědičnost nedala přímo využít. + Objekty typu zásobník jsou oboustranně stoprocentně kompatibilní s instancemi NSMutableArray (jde prakticky o objekty téže třídy). Nějaký modul tedy může vytvořit pole NSMutableArray a jiný může s týmž objektem pracovat jako se zásobníkem. To by nebylo možné u objektů různých tříd (platila by kompatibilita jen jedním směrem). + Příjemným důsledkem minulého bodu je neomezené využití všech knihovních služeb. Máme-li například služby pro archivaci objektů do formátu XML, jež podporují pole, můžeme je beze změny využít i pro náš zásobník. Pokud bychom pro zásobník měli samostatnou třídu, bylo by nutné použitý formát XML rozšířit tak, aby tuto třídu explicitně podporoval. Podobně je tomu i při sdílení objektů mezi různými procesy a podobně. - Obecnou nevýhodou kategorie je, že není možné přímo přidávat instanční proměnné. Pokud bychom to potřebovali, existují sice triky, jak to zaonačit, avšak obvykle už je lepší využít spíše vkládání objektů (nebo "klasickou" dědičnost, pokud nenarazíme na sdružené třídy či na jiný problém). - Při využití kategorie tímto způsobem také nelze změnit standardní chování základní třídy (to musí zůstat k dispozici pro moduly, jež třídu využívají běžným způsobem). Pokud tedy požadované chování nového objektu není pouhým rozšířením služeb objektu původního, nelze kategorii použít. Tam, kde je některá z těchto nevýhod kritická, máme k dispozici vkládání objektů. VKLÁDÁNÍ OBJEKTŮ A PŘESMĚROVÁNÍ ZPRÁV Pro lepší pochopení toho, jak vkládání objektů funguje, je vhodné se podívat na obr. 2 a uvědomit si, co se děje, když objektu Zásobník pošleme nějakou zprávu: * Pokud je odpovídající metoda součástí třídy Zásobník (je ve skupině nové služby), prostě se provede. * Pokud tomu tak není, hledá se odpovídající metoda ve třídě Pole. Vkládání objektů dosahuje téhož efektu trochu jinak: třída Zásobník není dědicem třídy Pole, ale obsahuje vložený objekt třídy Pole. Třída je implementována tak, aby se zprávy, jimž sama "nerozumí", přesměrovaly na tento vnořený objekt (obr. 4). Implicitní dědičnost tedy nahrazuje explicitní předávání zpráv. Vkládání objektů je složitější než prosté využití dědičnosti. Kromě nových služeb musíme navíc explicitně implementovat předávání zpráv. Výměnou za to ovšem získáme značnou flexibilitu: * Vložený objekt nemusí být vytvořen hned při tvorbě hlavního objektu, lze jej vytvořit, až když je poprvé zapotřebí. V některých případech to může znamenat skutečně zásadní rozdíl - pokud se vytváří mnoho instancí základních objektů, vložené objekty jsou velké a používají se zřídka, může tato technika mnohonásobně snížit spotřebu paměti a zrychlit aplikaci. * Vložený objekt nemusí být po celou dobu existence základního objektu tentýž. Podle potřeby může základní objekt kdykoli nahradit objekt vložený jinou instancí, dokonce i instancí jiné třídy. Vložených objektů může být i více a základní objekt se může dynamicky rozhodnout, kterému z nich zprávu předá. Tak můžeme efektivně implementovat de facto vícenásobnou dědičnost, aniž bychom narazili na nevýhody, jež jsou s ní spjaty v jazycích typu C++, jež ji nabízejí přímo. * Protože předávání zpráv mezi základním a vloženým objektem je "jejich interní věc", skrytá v rámci zapouzdření uvnitř API základního objektu a neovlivňující zbytek aplikace, můžeme pro něj použít různé nestandardní metody. Typickým využitím této možnosti jsou tzv. distribuované objekty - odkaz na objekt obsahuje identifikaci počítače a procesu a předávání zpráv je zajištěno prostřednictvím meziprocesové komunikace a/nebo počítačové sítě. Tak můžeme transparentně "přímo" pracovat s objekty, jež jsou fakticky součástí jiného procesu na jiném počítači. Další výhodou vkládání objektů je i to, že bez problémů funguje i se sdruženými třídami - "klasická" dědičnost je u těchto speciálních tříd problematická, resp. slouží u nich pro zcela jiný účel. ROZHRANÍ Pro ilustraci si ukážeme implementaci jednoduchého zásobníku pomocí vkládání objektů, i když zde by se kategorie hodila lépe. Rozhraní je poměrně jednoduché - kromě deklarace API potřebujeme jen jednu instanční proměnnou, jež bude obsahovat odkaz na vložený objekt: @interface Stack:NSObject { NSMutableArray *array; } -(void)push:object; -pop; // pro prázdný zásobník vyvolá výjimku @end V praxi bychom asi vkládání objektů využili ve složitějších případech, kdy by instančních proměnných bylo více. IMPLEMENTACE Implementace je tentokrát trochu složitější, a proto si ji ukážeme v několika krocích. Nejprve to nejjednodušší: zásobníková primitiva převedeme na primitiva dynamického pole prakticky stejně, jako tomu bylo u kategorie, jen místo odkazu self sám na sebe použijeme odkaz na vložený objekt: @implementation Stack -(void)push:object { [array addObject:object]; } -pop { id o=[[[array lastObject] retain] autorelease]; [array removeLastObject]; return o; } Celkem jednoduchá je i standardní implementace "konstruktoru" init, v němž vytvoříme vložený objekt: -init { [super init]; array=[[NSMutableArray alloc] init]; return self; } Pokud požadujeme jen nové API (reprezentované zprávami push: a pop), jsme hotovi. Sice jsme museli napsat nepatrně více kódu než při použití kategorie podtřídy, zato se nám však neplete dohromady staré a nové API a použitá technika funguje stejně dobře se sdruženými i s normálními třídami. Navíc máme výhodu možnosti inicializovat vložený objekt dynamicky až v případě potřeby - prostě bychom zrušili implementaci metody init a na začátek metod push: a pop bychom přidali řádek if (!array) array=[[NSMutableArray alloc] init]; V obecném případě však to, že se nám neplete staré a nové API, přináší vlastní problémy, neboť to znamená, že nad objekty třídy Stack nemůžeme užívat služeb třídy NSMutableArray. To proto, že jsme dosud neimplementovali vlastní přesměrování zpráv - až dosud jsme využívali jen vkládání objektů; hned to napravíme. PŘESMĚROVÁNÍ ZPRÁV Pro přesměrování zpráv musíme mít k dispozici odpovídající službu runtime: jde o algoritmus předávání zprávy objektu, který musí obsahovat plně dynamická "zadní vrátka" pro případ, že se nenajde odpovídající metoda. V Objective C s knihovnami Cocoa je možnost přesměrování standardně. Lze ji zajistit i v Javě s nestandardními knihovnami (standardní Java ji ani s využitím reflexe nenabízí). Pokud se v žádné nadtřídě nenajde odpovídající metoda, runtime systém se pokusí odeslat zprávu dynamicky, nezávisle na metodách, jež jsou součástí tříd. Takové dynamické odeslání zprávy pak probíhá ve dvou krocích: * Nejprve se runtime systém objektu "zeptá", jaké jsou typy argumentů a návratová hodnota odpovídající dané zprávě. * Pak runtime systém vytvoří "balíček" reprezentující zprávu i její argumenty (aby to bylo korektně možné s libovolnými typy, musel nejprve proběhnout první krok) a ten objektu předá. Objekt s ním může udělat cokoliv, v našem případě jej předá vloženému objektu, aby jej zpracoval za něj. Oba kroky jsou realizovány pomocí standardizovaných zpráv. Pro první z nich slouží zpráva methodSignatureForSelector:, jejímž argumentem je tzv. selektor (v podstatě zakódované jméno) dané zprávy. Vrácená hodnota je speciální objekt třídy NSMethodSignature, který podrobně určuje všechny atributy metody, tj. počet a typy jejích argumentů i typ její návratové hodnoty. Pro druhý krok je určena zpráva forwardInvocation:, jejímž argumentem je speciální objekt třídy NSInvocation. Ten slouží jako výše zmíněný "balíček" - jeho součástí jsou konkrétní hodnoty argumentů i selektor zprávy a jeho prostřednictvím se předává i návratová hodnota. Zní to možná složitě, ale konkrétní implementace přesměrování všech neznámých zpráv na vložený objekt je velmi jednoduchá: -(NSMethodSignature*)methodSignatureForSelector:(SEL)sel { return [array methodSignatureForSelector:sel]; } -(void)forwardInvocation:(NSInvocation*)inv { [inv invokeWithTarget:array]; } @end V první metodě si vyžádáme signaturu od vloženého objektu. V implementaci druhé metody forwardInvocation: jen předáme objektu inv požadavek, aby zpráva, již reprezentuje, byla zaslána objektu array - to zajistí standardní metoda invokeWithTarget:, jež je součástí knihovní implementace třídy NSInvocation. VÝHODY A NEVÝHODY O některých výhodách vkládání objektů a přesměrování zpráv jsme už hovořili, je jich však více: + Vkládání objektů a přesměrování zpráv korektně podporuje sdružené i normální třídy. + Vložený objekt nemusí být vytvořen hned při tvorbě hlavního objektu, lze jej vytvořit "on-demand" až ve chvíli, kdy je poprvé zapotřebí. + Vložený objekt nemusí být po celou dobu existence základního objektu týž. Podle potřeby může základní objekt kdykoli nahradit vložený objekt jinou instancí, v komplexnějších případech i instancí jiné třídy. Podobně může být vložených objektů více a základní objekt se může podle podmínek a potřeby dynamicky rozhodnout, kterému zprávu předá. + Protože předávání zpráv mezi základním a vloženým objektem je "jejich interní věcí", skrytou v rámci zapouzdření uvnitř API základního objektu a neovlivňující zbytek aplikace, můžeme pro něj použít různé nestandardní metody. + Při vkládání objektů a přesměrování zpráv lze snadno skrýt API, jež nechceme publikovat (jako by zůstalo skryto kompletní API třídy NSMutableArray, kdybychom přesměrování zpráv neimplementovali). Stejně dobře můžeme při přesměrovávání dynamicky volit, které zprávy předáme, a které ne. - Proti využití kategorie ztrácíme výhodu oboustranné kompatibility (dostaneme-li instanci třídy NSMutableArray, nemůžeme ji přímo využívat jako zásobník). Proti dědičnosti nemá přesměrování takřka žádnou nevýhodu, nepočítáme-li těch cca osm řádků zdrojového kódu, jež musíme napsat navíc pro implementaci vlastního přesměrování. SHRNUTÍ Přestože dědičnost je a zůstane základní a nesmírně cennou vlastností objektových systémů, je třeba dát si pozor na její nadužívání, jež někdy vede ke zbytečně komplikovanému kódu a jindy k nevhodnému designu (jmenovitě k "prorůstání" modulů, jež by měly v rámci modelu MVC zůstat samostatnými). Přitom existuje řada technik, které dokáží dědičnost efektivně a s některými výhodami nahradit - od jednoduché delegace, přístupné prakticky v libovolném prostředí, až po nejflexibilnější vkládání objektů a přesměrování, jež ovšem vyžaduje dynamický objektový jazyk s vhodným runtime. Můžeme tedy odpovědět na otázku z nadpisu: Podtřídy, delegáti, vkládání - jak se to rýmuje? Inu, třeba takto: Na podtřídy bacha, delegát, ten fachá, vkládání je řacha, tak se to rýmuje. Ondřej Čada