Programovanie

Prečo sa rozširuje, je zlo

The predlžuje kľúčové slovo je zlo; možno nie na úrovni Charlesa Mansona, ale dosť zlý na to, aby sa mu malo vyhýbať, kedykoľvek je to možné. Gang štyroch Dizajnové vzory kniha dlho diskutuje o nahradení dedičnosti implementácie (predlžuje) s dedičnosťou rozhrania (náradie).

Dobrí dizajnéri píšu väčšinu svojho kódu z hľadiska rozhraní, nie konkrétnych základných tried. Tento článok popisuje prečo návrhári majú také zvláštne návyky a taktiež zavádza niekoľko základných programovacích postupov založených na rozhraní.

Rozhrania verzus triedy

Raz som sa zúčastnil stretnutia skupín používateľov Java, kde bol hlavným rečníkom James Gosling (vynálezca Java). Počas nezabudnuteľnej relácie Otázky a odpovede sa ho niekto spýtal: „Ak by ste mohli robiť Javu znova, čo by ste zmenili?“ „Vynechal by som vyučovanie,“ odpovedal. Po tom, čo smiech utíchol, vysvetlil, že skutočným problémom neboli triedy samy osebe, ale skôr implementačné dedičstvo ( predlžuje vzťah). Dedičnosť rozhrania ( náradie vzťah). Mali by ste sa vyhnúť dedičstvu implementácie, kedykoľvek je to možné.

Strata flexibility

Prečo by ste sa mali vyhnúť dedičstvu po implementácii? Prvým problémom je, že výslovné použitie konkrétnych názvov tried vás uzamkne v konkrétnych implementáciách, čo zbytočne sťažuje vykonávanie následných zmien.

Jadrom súčasnej metodológie vývoja Agile je koncept paralelného návrhu a vývoja. Programovanie začnete skôr, ako program úplne špecifikujete. Táto technika je v rozpore s tradičnou múdrosťou - že návrh by mal byť hotový pred začiatkom programovania - ale veľa úspešných projektov dokázalo, že týmto spôsobom môžete vyvinúť kvalitný kód rýchlejšie ako pri tradičnom zreťazenom prístupe. Jadrom paralelného vývoja je však predstava flexibility. Váš kód musíte napísať tak, aby ste čo najbezbolestnejšie mohli do existujúceho kódu začleniť novoobjavené požiadavky.

Namiesto implementácie funkcií, ktoré ste vy možno potrebujete implementujete iba tie funkcie, ktoré sami určite potreby, ale spôsobom, ktorý umožňuje zmeny. Ak nemáte túto flexibilitu, paralelný vývoj jednoducho nie je možný.

Programovanie rozhraní je jadrom flexibilnej štruktúry. Aby sme zistili, prečo, pozrime sa, čo sa stane, keď ich nepoužívate. Zvážte nasledujúci kód:

f () {LinkedList list = nový LinkedList (); //... g (zoznam); } g (zoznam LinkedList) {list.add (...); g2 (zoznam)} 

Teraz predpokladajme, že sa objavila nová požiadavka na rýchle vyhľadanie, takže LinkedList nefunguje. Musíte ho vymeniť za a HashSet. V existujúcom kóde nie je táto zmena lokalizovaná, pretože je potrebné upraviť nielen ňu f () ale tiež g () (čo trvá a LinkedList argument) a všetko g () odovzdá zoznam.

Prepis kódu takto:

f () {Zoznam zbierok = nový LinkedList (); //... g (zoznam); } g (Zoznam zbierok) {list.add (...); g2 (zoznam)} 

umožňuje zmeniť prepojený zoznam na hašovaciu tabuľku jednoduchým nahradením nový LinkedList () s nový HashSet (). To je všetko. Nie sú potrebné žiadne ďalšie zmeny.

Ako ďalší príklad porovnajte tento kód:

f () {Zbierka c = nová HashSet (); //... g (c); } g (Kolekcia c) {pre (Iterator i = c.iterator (); i.hasNext ();) do_something_with (i.next ()); } 

do tohto:

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

The g2 () metóda môže teraz prechádzať Zbierka deriváty, ako aj zoznamy kľúčov a hodnôt, ktoré môžete získať z a Mapa. V skutočnosti môžete písať iterátory, ktoré generujú údaje namiesto toho, aby prechádzali zbierkou. Môžete napísať iterátory, ktoré do programu napájajú informácie z testovacieho lešenia alebo súboru. Je tu obrovská flexibilita.

Spojka

Zásadnejším problémom pri dedení implementácie je spojka—Nežiaduce spoliehanie sa jednej časti programu na inú časť. Globálne premenné poskytujú klasický príklad toho, prečo silné spojenie spôsobuje problémy. Ak napríklad zmeníte typ globálnej premennej, všetky funkcie, ktoré ju používajú, sú spojený môže byť ovplyvnený, takže celý tento kód musí byť preskúmaný, upravený a znovu otestovaný. Okrem toho sú všetky funkcie, ktoré používajú premennú, navzájom spojené prostredníctvom premennej. To znamená, že jedna funkcia môže nesprávne ovplyvniť správanie inej funkcie, ak dôjde k zmene hodnoty premennej v nepríjemnom čase. Tento problém je obzvlášť ohavný pri viacvláknových programoch.

Ako dizajnér by ste sa mali snažiť minimalizovať väzobné vzťahy. Spojenie nemôžete úplne vylúčiť, pretože volanie metódy z objektu jednej triedy na objekt druhej je formou voľného spojenia. Bez spojky nemôžete mať program. Spojenie však môžete značne minimalizovať otrockým dodržiavaním OO (objektovo orientovaných) predpisov (najdôležitejšie je, že implementácia objektu by mala byť úplne skrytá pred objektmi, ktoré ho používajú). Napríklad inštančné premenné objektu (členské polia, ktoré nie sú konštantné) by mali byť vždy súkromné. Obdobie. Bez výnimky. Vždy. Myslím to vážne. (Príležitostne môžete použiť chránené metódy efektívne, ale chránené premenné inštancie sú ohavnosťou.) Funkcie get / set by ste nikdy nemali používať z rovnakého dôvodu - sú to príliš zložité spôsoby, ako pole zverejniť (aj keď prístupové funkcie, ktoré vracajú plnohodnotné objekty, a nie hodnota základného typu, sú rozumné v situáciách, keď je trieda vráteného objektu kľúčovou abstrakciou v dizajne).

Nebudem tu pedantný. Vo svojej vlastnej práci som našiel priamu koreláciu medzi prísnosťou môjho prístupu OO, rýchlym vývojom kódu a ľahkou údržbou kódu. Kedykoľvek poruším centrálny princíp OO, ako je skrývanie implementácie, nakoniec tento kód prepíšem (zvyčajne preto, že kód nie je možné odladiť). Nemám čas prepisovať programy, takže sa riadim pravidlami. Moja obava je úplne praktická - nemám záujem o čistotu kvôli čistote.

Krehký problém základnej triedy

Teraz aplikujme koncept spojenia na dedičstvo. V systéme implementácie a dedenia, ktorý používa predlžuje, odvodené triedy sú veľmi tesne spojené so základnými triedami a toto úzke spojenie je nežiaduce. Dizajnéri použili na označenie tohto správania prezývku „krehký problém základnej triedy“. Základné triedy sa považujú za krehké, pretože základnú triedu môžete upraviť zdanlivo bezpečným spôsobom, ale toto nové správanie, ak je zdedené odvodenými triedami, môže spôsobiť poruchu odvodených tried. Či je zmena základnej triedy bezpečná, nemôžete zistiť jednoduchým skúmaním metód základnej triedy izolovane; musíte sa tiež pozrieť na (a otestovať) všetky odvodené triedy. Okrem toho musíte skontrolovať všetok kód používa obaja základná trieda a objekty odvodenej triedy, pretože tento kód môže byť tiež porušený novým správaním. Jednoduchá zmena kľúčovej základnej triedy môže spôsobiť nefunkčnosť celého programu.

Poďme sa spoločne pozrieť na krehké problémy spojenia základnej a základnej triedy. Nasledujúca trieda rozširuje Java ArrayList triedy, aby sa správalo ako zásobník:

trieda Stack rozširuje ArrayList {private int stack_pointer = 0; public void push (článok o objekte) {add (stack_pointer ++, článok); } public Object pop () {return remove (--stack_pointer); } public void push_many (Object [] articles) {for (int i = 0; i <articles.length; ++ i) push (articles [i]); }} 

Aj taká jednoduchá trieda, ako je táto, má problémy. Zvážte, čo sa stane, keď používateľ využije dedičstvo a použije kľúč ArrayListje jasný() spôsob, ako vyskočiť všetko zo stohu:

Zásobník a_stack = nový zásobník (); a_stack.push ("1"); a_stack.push ("2"); a_stack.clear (); 

Kód sa úspešne kompiluje, ale keďže základná trieda nevie nič o smerníku zásobníka, znak Stoh objekt je teraz v nedefinovanom stave. Nasledujúci hovor na číslo tam() umiestni novú položku do indexu 2 ( stack_pointerAktuálna hodnota), takže zásobník má v skutočnosti tri prvky - spodné dva sú odpadky. (Java Stoh trieda má presne tento problém; nepoužívaj to.)

Jedným z riešení nežiaduceho problému dedičnosti metód je Stoh prepísať všetky ArrayList metódy, ktoré môžu upraviť stav poľa, takže prepísania buď správne manipulujú s ukazovateľom zásobníka, alebo vyvolajú výnimku. (The removeRange () metóda je dobrým kandidátom na udelenie výnimky.)

Tento prístup má dve nevýhody. Po prvé, ak prepíšete všetko, základná trieda by mala byť skutočne rozhraním, nie triedou. Dedičnosť implementácie nemá zmysel, ak nepoužívate niektorú zo zdedených metód. Po druhé, a čo je dôležitejšie, nechcete, aby zásobník podporoval všetky ArrayList metódy. To otravné removeRange () metóda napríklad nie je užitočná. Jediným rozumným spôsobom, ako implementovať zbytočnú metódu, je nechať ju vyvolať výnimku, pretože by sa nikdy nemala volať. Tento prístup efektívne presúva to, čo by bola chyba v kompilácii, do behu. Nie dobré. Ak metóda jednoducho nie je deklarovaná, kompilátor vykopne chybu nenájdenú metódu. Ak metóda existuje, ale vyvolá výnimku, o hovore sa dozviete, až keď bude program skutočne spustený.

Lepším riešením problému základnej triedy je zapuzdrenie dátovej štruktúry namiesto použitia dedičnosti. Tu je nová a vylepšená verzia aplikácie Stoh:

trieda Stack {private int stack_pointer = 0; private ArrayList the_data = new ArrayList (); public void push (článok o objekte) {the_data.add (stack_pointer ++, článok); } 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]); }} 

Zatiaľ je to dobré, ale zvážte krehký problém základnej triedy. Povedzme, že chcete vytvoriť variant na Stoh ktorý sleduje maximálnu veľkosť zásobníka za určité časové obdobie. Jedna možná implementácia môže vyzerať takto:

trieda Monitorable_stack rozširuje Stack {private int high_water_mark = 0; private int current_size; public void push (článok o objekte) {if (++ current_size> high_water_mark) high_water_mark = current_size; super.push (článok); } public object pop () {--current_size; návrat super.pop (); } public int maximum_size_so_far () {return high_water_mark; }} 

Táto nová trieda funguje aspoň na chvíľu dobre. Tento kód, bohužiaľ, využíva skutočnosť, že push_many () robí svoju prácu volaním tam(). Spočiatku sa tento detail nejaví ako zlá voľba. Zjednodušuje to kód a získate verziu odvodenej triedy tam(), aj keď Monitorable_stack je prístupný cez a Stoh odkaz, takže high_water_mark sa aktualizuje správne.

Jedného pekného dňa môže niekto spustiť profiler a všimnúť si Stoh nie je tak rýchly, ako by mohol byť, a je veľmi používaný. Môžete prepísať Stoh takže nepoužíva ArrayList a následne vylepšiť Stohvýkon. Tu je nová verzia lean-and-mean:

trieda Stack {private int stack_pointer = -1; zásobník súkromných objektov [] = nový objekt [1 000]; public void push (článok o objekte) {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 (články, 0, zásobník, zásobník_pointer + 1, články.dĺžka); stack_pointer + = articles.length; }} 

Všimni si push_many () už nehovorí tam() viackrát - vykoná sa blokový prenos. Nová verzia Stoh funguje dobre; v skutočnosti je lepšie ako predchádzajúca verzia. Bohužiaľ, Monitorable_stack odvodená trieda nie už nebude fungovať, pretože nebude správne sledovať využitie zásobníka, ak push_many () sa volá (verzia odvodenej triedy tam() zdedený už nevolá push_many () metóda, tak push_many () už neaktualizuje high_water_mark). Stoh je krehká základná trieda. Ako sa ukazuje, je prakticky nemožné vylúčiť tieto typy problémov iba opatrnosťou.

Upozorňujeme, že tento problém nemáte, ak používate dedičnosť rozhrania, pretože neexistuje žiadna zdedená funkcia, ktorá by sa vám mohla pokaziť. Ak Stoh je rozhranie, implementované ako a Simple_stack a a Monitorable_stack, potom je kód omnoho robustnejší.

$config[zx-auto] not found$config[zx-overlay] not found