Programmering

Hvorfor strækker sig er ondt

Det strækker sig nøgleord er ondt; måske ikke på Charles Manson-niveau, men dårligt nok til, at det skal undgås, når det er muligt. Banden af ​​fire Designmønstre bogen diskuterer udførligt, der erstatter implementeringsarv (strækker sig) med interface-arv (redskaber).

Gode ​​designere skriver det meste af deres kode i form af grænseflader, ikke konkrete basisklasser. Denne artikel beskriver hvorfor designere har så ulige vaner og introducerer også et par interface-baserede programmeringsbasics.

Grænseflader versus klasser

Jeg deltog engang i et Java-brugergruppemøde, hvor James Gosling (Java's opfinder) var den fremhævede højttaler. Under den mindeværdige Q & A-session spurgte nogen ham: "Hvis du kunne lave Java igen, hvad ville du ændre?" ”Jeg ville udelade klasser,” svarede han. Efter at latteren døde, forklarede han, at det virkelige problem ikke var klasser i sig selv, men snarere implementeringsarv (den strækker sig forhold). Interface arv ( redskaber forhold) er at foretrække. Du bør undgå implementeringsarv, når det er muligt.

Mister fleksibilitet

Hvorfor skal du undgå arv ved implementering? Det første problem er, at eksplicit brug af konkrete klassenavne låser dig fast i specifikke implementeringer, hvilket gør ændringer unødvendige vanskelige.

Kernen i de moderne Agile udviklingsmetoder er begrebet parallel design og udvikling. Du starter programmeringen, inden du angiver programmet fuldt ud. Denne teknik flyver over for traditionel visdom - at et design skal være komplet, inden programmeringen starter - men mange vellykkede projekter har bevist, at du kan udvikle kode af høj kvalitet hurtigere (og omkostningseffektivt) på denne måde end med den traditionelle pipelined tilgang. Kernen i parallel udvikling er imidlertid begrebet fleksibilitet. Du skal skrive din kode på en sådan måde, at du kan integrere nyopdagede krav i den eksisterende kode så smertefrit som muligt.

I stedet for at implementere har du det magt har brug for, implementerer du kun de funktioner, du har helt bestemt behov, men på en måde, der rummer forandring. Hvis du ikke har denne fleksibilitet, er paralleludvikling simpelthen ikke mulig.

Programmering til grænseflader er kernen i fleksibel struktur. For at se hvorfor, lad os se på, hvad der sker, når du ikke bruger dem. Overvej følgende kode:

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

Antag nu, at der er opstået et nyt krav til hurtig opslag, så LinkedList fungerer ikke. Du skal udskifte den med en HashSet. I den eksisterende kode er ændringen ikke lokaliseret, da du ikke kun skal ændre f () men også g () (som tager en LinkedList argument) og hvad som helst g () overfører listen til.

Omskrivning af koden sådan:

f () {Samlingsliste = ny LinkedList (); //... g (liste); } g (Samlingsliste) {list.add (...); g2 (liste)} 

gør det muligt at ændre den linkede liste til en hash-tabel ved blot at erstatte ny LinkedList () med en nyt HashSet (). Det er det. Ingen andre ændringer er nødvendige.

Som et andet eksempel, sammenlign denne kode:

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

Til dette:

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

Det g2 () metoden kan nu krydse Kollektion derivater samt nøgle- og værdilisterne, du kan få fra en Kort. Faktisk kan du skrive iteratorer, der genererer data i stedet for at krydse en samling. Du kan skrive iteratorer, der føjer information fra et teststillads eller en fil til programmet. Der er enorm fleksibilitet her.

Kobling

Et mere afgørende problem med implementeringsarv er kobling- den uønskede afhængighed af en del af et program på en anden del. Globale variabler er det klassiske eksempel på, hvorfor stærk kobling giver problemer. Hvis du f.eks. Ændrer typen af ​​den globale variabel, er alle funktioner, der bruger variablen (dvs. koblet til variablen) kan blive påvirket, så al denne kode skal undersøges, ændres og testes igen. Desuden er alle funktioner, der bruger variablen, koblet til hinanden gennem variablen. Det vil sige, at en funktion kan påvirke en anden funktions opførsel forkert, hvis en variabels værdi ændres på et akavet tidspunkt. Dette problem er især afskyeligt i programmer med flere tråde.

Som designer skal du stræbe efter at minimere koblingsforhold. Du kan ikke fjerne kobling helt, fordi et metodekald fra et objekt i en klasse til et objekt i en anden er en form for løs kobling. Du kan ikke have et program uden nogen kobling. Ikke desto mindre kan du minimere kobling betydeligt ved at følge OO (objektorienterede) forskrifter slavisk (det vigtigste er, at implementeringen af ​​et objekt skal være helt skjult for de objekter, der bruger det). For eksempel skal et objekts instansvariabler (medlemsfelter, der ikke er konstanter) altid være privat. Periode. Ingen undtagelser. Nogensinde. Jeg mener det. (Du kan lejlighedsvis bruge beskyttet metoder effektivt, men beskyttet eksempelvariabler er en vederstyggelighed.) Du bør aldrig bruge get / set-funktioner af samme grund - de er bare alt for komplicerede måder at gøre et felt offentligt på (selvom adgangsfunktioner, der returnerer fuldblæste objekter snarere end en grundlæggende type værdi, rimelig i situationer, hvor det returnerede objekts klasse er en nøgleabstraktion i designet).

Jeg er ikke pedant her. Jeg har fundet en direkte sammenhæng i mit eget arbejde mellem strengheden i min OO-tilgang, hurtig kodeudvikling og let kodevedligeholdelse. Når jeg overtræder et centralt OO-princip som at skjule implementering, ender jeg med at omskrive den kode (normalt fordi koden er umulig at debugge). Jeg har ikke tid til at omskrive programmer, så jeg følger reglerne. Min bekymring er helt praktisk - jeg har ingen interesse i renhed for renhedens skyld.

Det skrøbelige basisklasseproblem

Lad os nu anvende begrebet kobling til arv. I et implementerings-arvssystem, der bruger strækker sig, er de afledte klasser meget tæt koblet til basisklasserne, og denne tætte forbindelse er uønsket. Designere har anvendt monikeren "det skrøbelige problem i baseklassen" for at beskrive denne adfærd. Baseklasser betragtes som skrøbelige, fordi du kan ændre en basisklasse på en tilsyneladende sikker måde, men denne nye adfærd, når den arves af de afledte klasser, kan medføre, at de afledte klasser ikke fungerer korrekt. Du kan ikke fortælle, om en ændring i baseklassen er sikker ved blot at undersøge baseklassens metoder isoleret; du skal også se på (og teste) alle afledte klasser. Desuden skal du kontrollere al kode, der anvendelser begge base-klasse og afledte objekter også, da denne kode muligvis også brydes af den nye adfærd. En simpel ændring af en nøglebaseklasse kan gøre et helt program ubrugeligt.

Lad os undersøge de skrøbelige base-class og base-class koblingsproblemer sammen. Følgende klasse udvider Java ArrayList klasse for at få det til at opføre sig som en stak:

klasse Stack udvider ArrayList {private int stack_pointer = 0; public void push (Objektartikel) {tilføj (stack_pointer ++, artikel); } public Object pop () {return remove (--stack_pointer); } offentlig ugyldig push_many (Objekt [] artikler) {for (int i = 0; i <artikler.længde; ++ i) push (artikler [i]); }} 

Selv en klasse så enkel som denne har problemer. Overvej hvad der sker, når en bruger udnytter arv og bruger ArrayList's klar() metode til at poppe alt fra stakken:

Stak a_stack = ny stak (); a_stack.push ("1"); a_stack.push ("2"); a_stack.clear (); 

Koden kompileres med succes, men da basisklassen ikke ved noget om stakmarkøren, er Stak objektet er nu i en udefineret tilstand. Det næste opkald til skubbe() sætter det nye element i indeks 2 ( stack_pointer's aktuelle værdi), så stakken har tre elementer effektivt - de nederste to er skrald. (Java's Stak klasse har netop dette problem; brug det ikke.)

En løsning på det uønskede metode-arvsproblem er til Stak at tilsidesætte alle ArrayList metoder, der kan ændre matrixens tilstand, så tilsidesættelserne enten manipulerer stakmarkøren korrekt eller kaster en undtagelse. (Det removeRange () metoden er en god kandidat til at kaste en undtagelse.)

Denne tilgang har to ulemper. For det første, hvis du tilsidesætter alt, skal baseklassen virkelig være en grænseflade, ikke en klasse. Der er ingen mening i implementeringsarv, hvis du ikke bruger nogen af ​​de arvede metoder. For det andet og vigtigere er, at du ikke vil have en stak, der understøtter alle ArrayList metoder. Det irriterende removeRange () metoden er ikke nyttig, for eksempel. Den eneste rimelige måde at implementere en ubrugelig metode på er at lade den kaste en undtagelse, da den aldrig skal kaldes. Denne tilgang flytter effektivt, hvad der ville være en kompileringstidsfejl, til runtime. Ikke godt. Hvis metoden simpelthen ikke er erklæret, sparker compileren en metode, der ikke blev fundet, ud. Hvis metoden er der, men kaster en undtagelse, finder du ikke ud af opkaldet, før programmet rent faktisk kører.

En bedre løsning på basisklasseproblemet er at indkapsle datastrukturen i stedet for at bruge arv. Her er en ny og forbedret version af Stak:

klasse Stak {privat int stack_pointer = 0; privat ArrayList the_data = ny ArrayList (); public void push (Objektartikel) {the_data.add (stack_pointer ++, artikel); } pop op med offentlig objekt () {returner_data.remove (--stack_pointer); } offentlig tomrum push_many (Objekt [] artikler) {for (int i = 0; i <o.længde; ++ i) push (artikler [i]); }} 

Indtil videre så godt, men overvej det skrøbelige problem i baseklassen. Lad os sige, at du vil oprette en variant til Stak der sporer den maksimale stakstørrelse over en bestemt tidsperiode. En mulig implementering kan se sådan ud:

klasse Monitorable_stack udvider Stack {private int high_water_mark = 0; privat int nuværende_størrelse; public void push (Objektartikel) {if (++ current_size> high_water_mark) high_water_mark = current_size; super.push (artikel); } offentlig Objekt pop () {--strøm_størrelse; returner super.pop (); } public int maximum_size_so_far () {return high_water_mark; }} 

Denne nye klasse fungerer godt, i det mindste et stykke tid. Desværre udnytter koden det faktum, at push_many () gør sit arbejde ved at ringe skubbe(). I første omgang virker denne detalje ikke som et dårligt valg. Det forenkler koden, og du får den afledte klasseversion af skubbe(), selv når Overvågbar stabel er tilgængelig via en Stak reference, så den høj_vand_mærke opdateres korrekt.

En smuk dag kan nogen køre en profil og lægge mærke til Stak er ikke så hurtig som det kunne være og er meget brugt. Du kan omskrive Stak så det ikke bruger en ArrayList og dermed forbedre Stak's ydeevne. Her er den nye lean-and-mean version:

klasse Stak {privat int stack_pointer = -1; privat objekt [] stak = nyt objekt [1000]; public void push (Objektartikel) {assert stack_pointer = 0; returstabel [stack_pointer--]; } public void push_many (Object [] articles) {assert (stack_pointer + articles.length) <stack.length; System.arraycopy (artikler, 0, stack, stack_pointer + 1, articles.length); stack_pointer + = articles.length; }} 

Læg mærke til det push_many () ringer ikke længere skubbe() flere gange — det foretager en blokoverførsel. Den nye version af Stak fungerer fint; faktisk er det bedre end den tidligere version. Desværre er Overvågbar stabel afledt klasse ikke arbejde mere, da det ikke sporer stakbrug korrekt, hvis push_many () kaldes (den afledte klasseversion af skubbe() kaldes ikke længere af den arvede push_many () metode, så push_many () opdaterer ikke længere høj_vand_mærke). Stak er en skrøbelig basisklasse. Som det viser sig, er det næsten umuligt at eliminere disse typer problemer ved blot at være forsigtig.

Bemærk, at du ikke har dette problem, hvis du bruger interface-arv, da der ikke er nogen arvelig funktionalitet, der går dårligt på dig. Hvis Stak er en grænseflade, implementeret af både a Simple_stack og en Overvågbar stabel, så er koden meget mere robust.