Zašto se proteže je zlo

extendsKljučna riječ je zlo; možda ne na razini Charlesa Mansona, ali dovoljno loše da se treba kloniti kad god je to moguće. Knjiga Gang of Four Design Patterns opširno govori o zamjeni nasljeđa implementacije ( extends) nasljeđivanjem sučelja ( implements).

Dobri dizajneri većinu svog koda pišu u smislu sučelja, a ne konkretnih osnovnih klasa. Ovaj članak opisuje zašto dizajneri imaju tako neobične navike, a također uvodi nekoliko osnova programiranja temeljenih na sučelju.

Sučelja naspram klasa

Jednom sam prisustvovao sastanku korisničke grupe Jave na kojem je James Gosling (izumitelj Java) bio istaknuti govornik. Tijekom nezaboravne sesije pitanja i odgovora, netko ga je pitao: "Kad biste mogli ponovo raditi Javu, što biste promijenili?" "Napustio bih nastavu", odgovorio je. Nakon što je smijeh utihnuo, objasnio je da stvarni problem nisu nastave same po sebi, već implementacijsko nasljeđivanje ( extendsodnos). Nasljeđivanje sučelja ( implementsodnos) je poželjnije. Trebali biste izbjegavati nasljeđivanje implementacije kad god je to moguće.

Gubljenje fleksibilnosti

Zašto biste trebali izbjegavati nasljeđivanje implementacije? Prvi je problem što vas eksplicitna upotreba konkretnih naziva klasa zaključava u određene implementacije, što nepotrebno otežava promjene u početnoj fazi.

U osnovi suvremenih agilnih razvojnih metodologija koncept je paralelnog dizajna i razvoja. Programiranje započinjete prije nego što u potpunosti odredite program. Ova se tehnika suprotstavlja tradicionalnoj mudrosti - da dizajn treba biti gotov prije započinjanja programiranja - no mnogi su uspješni projekti dokazali da na taj način možete brže (i isplativije) razviti visokokvalitetni kôd nego tradicionalnim cjelovitim pristupom. Međutim, u osnovi paralelnog razvoja je pojam fleksibilnosti. Morate napisati svoj kôd na takav način da možete što bezbolnije uvrstiti novootkrivene zahtjeve u postojeći kôd.

Umjesto da implementirate značajke koje bi vam mogle trebati, implementirate samo one značajke koje vam definitivno trebaju, ali na način koji uvažava promjene. Ako nemate tu fleksibilnost, paralelni razvoj jednostavno nije moguć.

Programiranje na sučelja jezgra je fleksibilne strukture. Da bismo vidjeli zašto, pogledajmo što se događa kada ih ne koristite. Razmotrite sljedeći kod:

f () {LinkedList list = novi LinkedList (); // ... g (popis); } g (LinkedList list) {list.add (...); g2 (popis)}

Sad pretpostavimo da se pojavio novi zahtjev za brzim pretraživanjem, pa to LinkedListne ide. Morate ga zamijeniti s HashSet. U postojećem kodu ta promjena nije lokalizirana jer morate izmijeniti ne samo f()već i g()(što uzima LinkedListargument), a sve g()prosljeđuje popis.

Prepisivanje koda ovako:

f () {Popis kolekcije = novi LinkedList (); // ... g (popis); } g (Popis zbirki) {list.add (...); g2 (popis)}

omogućuje promjenu povezanog popisa u hash tablicu jednostavnom zamjenom new LinkedList()a new HashSet(). To je to. Nisu potrebne nikakve druge promjene.

Kao drugi primjer, usporedite ovaj kod:

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

na ovo:

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

g2()Metoda sada mogu putovati Collectionderivata, kao i ključnih i vrijednost popisa možete dobiti iz Map. U stvari, možete pisati iteratore koji generiraju podatke umjesto da prelaze zbirku. U program možete upisati iteratore koji unose podatke s testne skele ili datoteke. Ovdje postoji ogromna fleksibilnost.

Spajanje

Ključniji problem s nasljeđivanjem implementacije je sprezanje - nepoželjno oslanjanje jednog dijela programa na drugi. Globalne varijable pružaju klasičan primjer zašto jako spajanje uzrokuje probleme. Ako promijenite tip globalne varijable, na primjer, to može utjecati na sve funkcije koje koriste varijablu (tj. Povezane su s varijablom), pa se sav ovaj kôd mora ispitati, izmijeniti i ponovo testirati. Štoviše, sve funkcije koje koriste varijablu međusobno su povezane putem varijable. To jest, jedna funkcija može pogrešno utjecati na ponašanje druge funkcije ako se vrijednost varijable promijeni u nezgodno vrijeme. Ovaj je problem posebno grozan u višenitnim programima.

Kao dizajner, trebali biste nastojati minimalizirati odnose spajanja. Ne možete u potpunosti eliminirati sprezanje jer je poziv metode iz objekta jedne klase u objekt druge vrste oblik labavog spajanja. Ne možete imati program bez neke sprege. Bez obzira na to, povezivanje možete znatno minimizirati ropski slijedeći OO (objektno orijentirane) zapovijedi (najvažnije je da implementacija objekta treba biti potpuno skrivena od objekata koji ga koriste). Na primjer, varijable instance objekta (polja člana koja nisu konstante), uvijek bi trebale biti private. Razdoblje. Nema izuzetaka. Ikad. Mislim to. (Povremeno možete protectedučinkovito koristiti metode, aliprotected varijable instance su odvratnost.) Nikada ne biste trebali koristiti funkcije get / set iz istog razloga - oni su samo prekomplicirani načini da se polje učini javnim (premda funkcije pristupa koje vraćaju cjelovite objekte umjesto vrijednosti osnovnog tipa jesu razumno u situacijama kada je klasa vraćenog objekta ključna apstrakcija u dizajnu).

Ovdje nisam pedantna. Pronašao sam izravnu korelaciju u svom radu između strogoće mog OO pristupa, brzog razvoja koda i jednostavnog održavanja koda. Kad god prekršim središnji OO princip kao što je skrivanje implementacije, na kraju prepisujem taj kod (obično zato što je kod nemoguće otkloniti). Nemam vremena za prepisivanje programa, pa se pridržavam pravila. Moja je briga posve praktična - ne zanima me čistoća zbog čistoće.

Krhki problem osnovne klase

Primijenimo sada koncept spajanja na nasljeđivanje. U sustavu implementacije-nasljeđivanja koji koristi extends, izvedene klase su vrlo usko povezane s osnovnim klasama, a ova uska veza je nepoželjna. Dizajneri su primijenili nadimak "krhki problem osnovne klase" kako bi opisali ovo ponašanje. Osnovne klase smatraju se krhkim jer možete izmijeniti osnovnu klasu na naizgled siguran način, ali ovo novo ponašanje, kada je naslijede izvedene klase, može dovesti do kvara izvedenih klasa. Jednostavnim ispitivanjem metoda osnovne klase ne možete utvrditi je li promjena osnovne klase sigurna; morate pogledati (i testirati) i sve izvedene klase. Štoviše, morate provjeriti sav kôd koji koristi i osnovnu klasu ii objekti izvedene klase, jer bi novo ponašanje moglo pokvariti i ovaj kôd. Jednostavna promjena osnovne klase ključa može onemogućiti čitav program.

Ispitajmo zajedno krhke probleme spajanja osnovne i osnovne klase. Sljedeća klasa proširuje Javinu ArrayListklasu kako bi se ponašala kao stog:

klasa Stack proširuje ArrayList {private int stack_pointer = 0; javni void push (objektni članak) {add (stack_pointer ++, article); } javni Object pop () {return remove (--stack_pointer); } public void push_many (Object [] članci) {for (int i = 0; i <articles.length; ++ i) push (articles [i]); }}

Čak i tako jednostavna klasa kao što je ova ima problema. Razmislite o tome što se događa kada korisnik iskorištava baštinu i koristi ArrayList„s clear()metodom za pop sve od dimnjaka:

Stog a_stack = novi stog (); a_stack.push ("1"); a_stack.push ("2"); a_stack.clear ();

Kôd se uspješno kompilira, ali budući da osnovna klasa ne zna ništa o pokazivaču steka, Stackobjekt je sada u nedefiniranom stanju. Sljedeći poziv za push()stavlja novu stavku u indeks 2 ( stack_pointertrenutna vrijednost), tako da stog na sebi ima tri elementa - donja dva su smeće. (Java Stackklasa ima upravo ovaj problem; nemojte ga koristiti.)

Jedno od rješenja neželjenog problema nasljeđivanja metoda je Stacknadjačavanje svih ArrayListmetoda koje mogu modificirati stanje niza, tako da nadjačavanja ili pravilno manipuliraju pokazivačem stoga ili izbacuju iznimku. ( removeRange()Metoda je dobar kandidat za izbacivanje iznimke.)

Ovaj pristup ima dva nedostatka. Prvo, ako nadjačate sve, osnovna bi klasa stvarno trebala biti sučelje, a ne klasa. Nema smisla u nasljeđivanju implementacije ako ne koristite niti jednu od naslijeđenih metoda. Drugo, i što je još važnije, ne želite da stog podržava sve ArrayListmetode. Ta dosadna removeRange()metoda, na primjer, nije korisna. Jedini razumni način za implementaciju beskorisne metode je da ona izbaci iznimku, jer je nikada ne bi trebalo pozivati. Ovaj pristup učinkovito premješta ono što bi bila pogreška tijekom kompajliranja u vrijeme izvođenja. Nije dobro. Ako metoda jednostavno nije deklarirana, kompajler izbacuje pogrešku koja nije pronađena. Ako metoda postoji, ali izbaci iznimku, nećete saznati za poziv dok se program stvarno ne pokrene.

Bolje rješenje problema s osnovnom klasom je inkapsulacija strukture podataka umjesto korištenja nasljeđivanja. Evo nove i poboljšane verzije Stack:

klasa Stack {private int stack_pointer = 0; privatni ArrayList the_data = novi ArrayList (); javni void push (članak o objektu) {the_data.add (stack_pointer ++, article); } public Object pop () {return the_data.remove (--stack_pointer); } public void push_many (Object [] članci) {for (int i = 0; i <o.length; ++ i) push (articles [i]); }}

Zasad je dobro, ali razmotrite krhko pitanje osnovne klase. Recimo da želite stvoriti varijantu Stackkoja prati maksimalnu veličinu sloga tijekom određenog vremenskog razdoblja. Jedna od mogućih implementacija mogla bi izgledati ovako:

klasa Monitorable_stack proteže se Stack {private int high_water_mark = 0; privatni int current_size; javni void push (članak o objektu) {if (++ current_size> high_water_mark) high_water_mark = current_size; super.push (članak); } javni objekt pop () {--current_size; vratiti super.pop (); } public int maximum_size_so_far () {return high_water_mark; }}

Ova nova klasa dobro djeluje, barem neko vrijeme. Nažalost, kod iskorištava činjenicu koja push_many()svoj posao obavlja pozivanjem push(). U početku se ovaj detalj ne čini lošim izborom. To pojednostavljuje kôd i dobivate izvedenu izvedbu klase push(), čak i kad Monitorable_stackse pristupi putem Stackreference, tako da se high_water_markispravno ažuriraju.

Jednog lijepog dana netko bi mogao pokrenuti profiler i primijetiti da Stacknije tako brz kao što bi mogao biti i da se jako koristi. Možete prepisati Stacktako da se ne koristi ArrayListi posljedično poboljšati Stackizvedbu. Evo nove verzije mršavih i srednjih riječi:

klasa Stack {private int stack_pointer = -1; privatni objekt [] stog = novi objekt [1000]; javni void push (članak o objektu) {assert stack_pointer = 0; povratni stog [stack_pointer--]; } public void push_many (Object [] članci) {assert (stack_pointer + articles.length) <stack.length; System.arraycopy (članci, 0, stack, stack_pointer + 1, articles.length); pokazivač_streaka + = articles.length; }}

Primijetite da push_many()više ne poziva push()više puta - vrši prijenos blokova. Nova verzija Stackdobro funkcionira; zapravo je bolja od prethodne verzije. Nažalost, Monitorable_stackizvedena klasa više ne radi, jer neće ispravno pratiti upotrebu steka ako push_many()je pozvana (verzija izvedene klase od push()se više ne poziva naslijeđenom push_many()metodom, pa push_many()više ne ažurira high_water_mark). Stackje krhka osnovna klasa. Ispostavilo se da je gotovo nemoguće ukloniti ove vrste problema jednostavnim oprezom.

Imajte na umu da nemate ovaj problem ako koristite nasljeđivanje sučelja jer nema naslijeđene funkcije koja bi vam se pokvarila. Ako Stackje sučelje, koje provode i a Simple_stacki a Monitorable_stack, tada je kod puno robusniji.