Programování

Proč se rozšiřuje, je zlo

The rozšiřuje klíčové slovo je zlo; možná ne na úrovni Charlese Mansona, ale dost špatné, že by se tomu mělo vyhýbat, kdykoli je to možné. Gang čtyř Designové vzory kniha podrobně pojednává o nahrazení dědičnosti implementace (rozšiřuje) s dědičností rozhraní (nářadí).

Dobří designéři píší většinu svého kódu z hlediska rozhraní, nikoli konkrétních základních tříd. Tento článek popisuje proč designéři mají takové zvláštní zvyky a také zavádějí několik základů programování založených na rozhraní.

Rozhraní versus třídy

Jednou jsem se zúčastnil schůzky skupiny uživatelů Java, kde byl hlavním řečníkem James Gosling (vynálezce Java). Během nezapomenutelné relace otázek a odpovědí se ho někdo zeptal: „Pokud byste mohli dělat Javu znovu, co byste změnili?“ „Vynechal bych hodiny,“ odpověděl. Poté, co smích utichl, vysvětlil, že skutečným problémem nebyly třídy samy o sobě, ale spíše implementační dědičnost ( rozšiřuje vztah). Dědičnost rozhraní ( nářadí vztah) je vhodnější. Měli byste se vyhnout dědičnosti implementace, kdykoli je to možné.

Ztráta flexibility

Proč byste se měli vyhnout dědičnosti implementace? Prvním problémem je, že explicitní použití konkrétních názvů tříd vás uzamkne do konkrétních implementací, což zbytečně zkomplikuje přímé změny.

Jádrem současné metodiky vývoje Agile je koncept paralelního designu a vývoje. Program začnete před úplným určením programu. Tato technika je tváří v tvář tradiční moudrosti - že návrh by měl být dokončen před zahájením programování - ale mnoho úspěšných projektů prokázalo, že můžete vysoce kvalitním kódem vyvíjet rychleji (a nákladově efektivně) tímto způsobem než tradičním pipeline přístupem. Jádrem paralelního vývoje je však představa flexibility. Musíte napsat svůj kód takovým způsobem, že můžete nově objevené požadavky začlenit do stávajícího kódu co nejbolestněji.

Spíše než implementovat funkce mohl potřebujete implementovat pouze ty funkce, které máte rozhodně potřeba, ale způsobem, který umožňuje změnu. Pokud nemáte tuto flexibilitu, paralelní vývoj prostě není možný.

Programování do rozhraní je jádrem flexibilní struktury. Abychom zjistili proč, podívejme se, co se stane, když je nepoužíváte. Zvažte následující kód:

f () {LinkedList list = new LinkedList (); //... g (seznam); } g (seznam LinkedList) {list.add (...); g2 (seznam)} 

Nyní předpokládejme, že se objevil nový požadavek na rychlé vyhledávání, takže Spojový seznam nefunguje. Musíte jej nahradit a HashSet. V existujícím kódu není tato změna lokalizována, protože je nutné upravit nejen F() ale také G() (což trvá a Spojový seznam argument) a cokoli jiného G() předá seznam.

Přepisování kódu takto:

f () {Seznam sbírek = nový LinkedList (); //... g (seznam); } g (Seznam sbírek) {list.add (...); g2 (seznam)} 

umožňuje změnit propojený seznam na hashovací tabulku jednoduše nahrazením nový LinkedList () s nový HashSet (). A je to. Žádné další změny nejsou nutné.

Jako další příklad porovnejte tento kód:

f () {Collection c = new HashSet (); //... g (c); } g (Collection c) {for (Iterator i = c.iterator (); i.hasNext ();) do_something_with (i.next ()); } 

k tomuto:

f2 () {Collection c = new HashSet (); //... g2 (c.iterator ()); } g2 (Iterator i) {while (i.hasNext ();) do_something_with (i.next ()); } 

The g2 () metoda může nyní procházet Sbírka deriváty a seznamy klíčů a hodnot, které můžete získat z a Mapa. Ve skutečnosti můžete místo procházení kolekce psát iterátory, které generují data. Můžete psát iterátory, které do programu přivádějí informace z testovacího lešení nebo souboru. Je zde obrovská flexibilita.

Spojka

Zásadnějším problémem s dědičností implementace je spojka—Nežádoucí závislost jedné části programu na jiné části. Globální proměnné jsou klasickým příkladem toho, proč silná vazba způsobuje potíže. Pokud například změníte typ globální proměnné, jsou všechny funkce, které proměnnou používají (tj. Jsou spojený může být ovlivněn), takže celý tento kód musí být prozkoumán, upraven a znovu otestován. Kromě toho jsou všechny funkce, které používají proměnnou, navzájem propojeny prostřednictvím proměnné. To znamená, že jedna funkce může nesprávně ovlivnit chování jiné funkce, pokud se hodnota proměnné změní v nepříjemném čase. Tento problém je obzvláště ohavný u vícevláknových programů.

Jako návrhář byste se měli snažit minimalizovat vazebné vztahy. Nelze zcela vyloučit propojení, protože volání metody z objektu jedné třídy na objekt jiné je formou volné vazby. Bez nějaké vazby nemůžete mít program. Spojení však můžete značně minimalizovat otrockým dodržováním OO (objektově orientovaných) pravidel (nejdůležitější je, že implementace objektu by měla být zcela skryta před objekty, které jej používají). Například proměnné instance objektu (členské pole, které nejsou konstantami), by vždy měly být soukromé. Doba. Žádné vyjímky. Vůbec. Myslím to vážně. (Příležitostně můžete použít chráněný metody efektivně, ale chráněný proměnné instance jsou ohavností.) Funkce get / set byste nikdy neměli používat ze stejného důvodu - jsou to jen příliš komplikované způsoby, jak pole zveřejnit (ačkoli přístupové funkce, které vracejí plnohodnotné objekty, spíše než hodnota základního typu, jsou přiměřené v situacích, kdy je třída vráceného objektu klíčovou abstrakcí v návrhu).

Nejsem tu pedantský. Ve své vlastní práci jsem našel přímou korelaci mezi přísností mého přístupu OO, rychlým vývojem kódu a snadnou údržbou kódu. Kdykoli poruším centrální princip OO, jako je skrývání implementace, nakonec přepíšu tento kód (obvykle proto, že kód nelze ladit). Nemám čas přepsat programy, proto se řídím pravidly. Moje starost je zcela praktická - kvůli čistotě nemám zájem o čistotu.

Křehký problém základní třídy

Nyní pojďme aplikovat koncept vazby na dědictví. V systému implementace a dědictví, který používá rozšiřuje, odvozené třídy jsou velmi těsně spojeny se základními třídami a toto úzké spojení je nežádoucí. Návrháři použili k popisu tohoto chování přezdívku „křehký problém základní třídy“. Základní třídy jsou považovány za křehké, protože můžete základní třídu upravit zdánlivě bezpečným způsobem, ale toto nové chování, pokud je zděděno odvozenými třídami, může způsobit poruchu odvozených tříd. Nezjistíte, zda je změna základní třídy bezpečná, prostým prostudováním metod základní třídy; musíte se také podívat (a otestovat) všechny odvozené třídy. Kromě toho musíte zkontrolovat celý kód používá oba základní třídy a objekty odvozené třídy, protože tento kód může být také porušen novým chováním. Jednoduchá změna klíčové základní třídy může způsobit nefunkčnost celého programu.

Pojďme společně prozkoumat křehké problémy se spojením základní třídy a základní třídy. Následující třída rozšiřuje prostředí Java ArrayList třída, aby se chovala jako zásobník:

class Stack extends ArrayList {private int stack_pointer = 0; public void push (článek o předmětu) {add (stack_pointer ++, článek); } public Object pop () {návrat odebrat (--stack_pointer); } public void push_many (Object [] articles) {for (int i = 0; i <articles.length; ++ i) push (articles [i]); }} 

I tak jednoduchá třída jako tato má problémy. Zvažte, co se stane, když uživatel využije dědičnost a použije ArrayListje Průhledná() způsob, jak vyskočit vše ze zásobníku:

Stack a_stack = new Stack (); a_stack.push ("1"); a_stack.push ("2"); a_stack.clear (); 

Kód se úspěšně kompiluje, ale protože základní třída neví nic o ukazateli zásobníku, kód Zásobník objekt je nyní v nedefinovaném stavu. Další volání na tam() umístí novou položku na index 2 ( stack_pointeraktuální hodnota), takže zásobník má ve skutečnosti tři prvky - spodní dva jsou odpadky. (Java Zásobník třída má přesně tento problém; nepoužívejte to.)

Jedno řešení nežádoucího problému dědičnosti metody je pro Zásobník přepsat vše ArrayList metody, které mohou upravit stav pole, takže přepsání buď správně manipuluje s ukazatelem zásobníku, nebo vyvolá výjimku. (The removeRange () Metoda je dobrým kandidátem na zrušení výjimky.)

Tento přístup má dvě nevýhody. Za prvé, pokud přepíšete všechno, základní třída by měla být ve skutečnosti rozhraní, nikoli třída. Pokud nepoužíváte žádnou zděděných metod, nemá smysl v dědičnosti implementace. Zadruhé, a co je důležitější, nechcete, aby zásobník podporoval všechny ArrayList metody. To otravné removeRange () metoda není například užitečná. Jediným rozumným způsobem, jak implementovat zbytečnou metodu, je nechat ji vyvolat výjimku, protože by nikdy neměla být volána. Tento přístup efektivně přesouvá to, co by byla chyba při kompilaci, do běhu. Špatný. Pokud metoda jednoduše není deklarována, kompilátor vykopne chybu nenalezenou metodou. Pokud metoda existuje, ale vyvolá výjimku, o hovoru se nedozvíte, dokud se program skutečně nespustí.

Lepším řešením problému základní třídy je zapouzdření datové struktury namísto použití dědičnosti. Tady je nová a vylepšená verze aplikace Zásobník:

třída Stack {private int stack_pointer = 0; private ArrayList the_data = new ArrayList (); public void push (článek o objektu) {the_data.add (stack_pointer ++, článek); } public Object pop () {return the_data.remove (--stack_pointer); } public void push_many (Object [] articles) {for (int i = 0; i <o.length; ++ i) push (articles [i]); }} 

Zatím dobré, ale zvažte křehký problém základní třídy. Řekněme, že chcete vytvořit variantu na Zásobník který sleduje maximální velikost zásobníku za určité časové období. Jedna možná implementace může vypadat takto:

třída Monitorable_stack rozšiřuje Stack {private int high_water_mark = 0; private int current_size; public void push (článek o objektu) {if (++ current_size> high_water_mark) high_water_mark = current_size; super.push (článek); } public Object pop () {--current_size; návrat super.pop (); } public int maximum_size_so_far () {návrat high_water_mark; }} 

Tato nová třída funguje alespoň na chvíli dobře. Bohužel kód využívá skutečnost, že push_many () dělá svou práci voláním tam(). Zpočátku se tento detail nezdá jako špatná volba. Zjednodušuje to kód a získáte odvozenou verzi třídy tam(), i když Monitorable_stack je přístupný přes a Zásobník odkaz, takže high_water_mark se aktualizuje správně.

Jednoho krásného dne může někdo spustit profiler a všimnout si Zásobník není tak rychlý, jak by mohl být, a je hodně používán. Můžete přepsat Zásobník takže nepoužívá ArrayList a následně vylepšit Zásobníkvýkon. Tady je nová verze lean-and-mean:

třída Stack {private int stack_pointer = -1; soukromý objekt [] zásobník = nový objekt [1 000]; public void push (článek o předmětu) {assert stack_pointer = 0; návratový zásobník [stack_pointer--]; } public void push_many (Object [] articles) {assert (stack_pointer + articles.length) <stack.length; System.arraycopy (articles, 0, stack, stack_pointer + 1, articles.length); stack_pointer + = articles.length; }} 

Všimněte si toho push_many () již nevolá tam() několikrát - provede přenos bloku. Nová verze Zásobník funguje dobře; ve skutečnosti je lepší než předchozí verze. Bohužel Monitorable_stack odvozená třída ne už nebude fungovat, protože nebude správně sledovat využití zásobníku, pokud push_many () se nazývá (verze odvozené třídy tam() již není volán zděděným push_many () metoda, tak push_many () již neaktualizuje high_water_mark). Zásobník je křehká základní třída. Jak se ukázalo, je prakticky nemožné tyto typy problémů jednoduše odstranit opatrností.

Všimněte si, že nemáte tento problém, pokud používáte dědičnost rozhraní, protože neexistuje žádná zděděná funkce, která by se vám zhoršila. Li Zásobník je rozhraní implementované oběma a Simple_stack a Monitorable_stack, pak je kód mnohem robustnější.