Liskov-substitusjonsprinsipp i Java

1. Oversikt

SOLID-designprinsippene ble introdusert av Robert C. Martin i papiret fra 2000, Designprinsipper og designmønstre. SOLIDE designprinsipper hjelper oss lage mer vedlikeholdsbar, forståelig og fleksibel programvare.

I denne artikkelen vil vi diskutere Liskov-substitusjonsprinsippet, som er "L" i akronymet.

2. Det åpne / lukkede prinsippet

For å forstå Liskov-substitusjonsprinsippet, må vi først forstå det åpne / lukkede prinsippet (“O” fra SOLID).

Målet med Open / Closed-prinsippet oppfordrer oss til å designe programvaren vår slik at vi bare legge til nye funksjoner ved å legge til ny kode. Når dette er mulig, har vi løst koblet og dermed lett vedlikeholdbare applikasjoner.

3. Et eksempel på en brukssak

La oss se på et eksempel på et banksystem for å forstå det åpne / lukkede prinsippet litt mer.

3.1. Uten det åpne / lukkede prinsippet

Bankapplikasjonen vår støtter to kontotyper - “nåværende” og “sparing”. Disse er representert av klassene Gjeldende konto og Sparekonto henholdsvis.

De BankingAppWithdrawalService serverer uttaksfunksjonaliteten til sine brukere:

Dessverre er det et problem med å utvide dette designet. De BankingAppWithdrawalService er klar over de to konkrete regnskapsimplementeringene. derfor BankingAppWithdrawalService må endres hver gang en ny kontotype innføres.

3.2. Bruke det åpne / lukkede prinsippet for å gjøre koden utvidbar

La oss redesigne løsningen slik at den er i samsvar med Open / Closed-prinsippet. Vi stenger BankingAppWithdrawalService fra endring når nye kontotyper er nødvendig, ved å bruke en Regnskap baseklasse i stedet:

Her introduserte vi et nytt abstrakt Regnskap klasse det Gjeldende konto og Sparekonto forlenge.

De BankingAppWithdrawalService avhenger ikke lenger av konkrete kontoklasser. Fordi det nå bare avhenger av den abstrakte klassen, trenger den ikke å endres når en ny kontotype innføres.

Følgelig ble den BankingAppWithdrawalService er åpne for utvidelsen med nye kontotyper, men stengt for modifikasjon, ved at de nye typene ikke krever at den endres for å integreres.

3.3. Java-kode

La oss se på dette eksemplet i Java. Til å begynne med, la oss definere Regnskap klasse:

offentlig abstrakt klasse Konto {beskyttet abstrakt ugyldig innskudd (BigDecimal beløp); / ** * Reduserer saldoen på kontoen med det angitte beløpet * gitt gitt beløp> 0 og kontoen oppfyller minimum tilgjengelige * saldokriterier. * * @param beløp * / beskyttet abstrakt ugyldig uttak (BigDecimal beløp); } 

Og la oss definere BankingAppWithdrawalService:

offentlig klasse BankingAppWithdrawalService {privat konto; public BankingAppWithdrawalService (Kontokonto) {this.account = konto; } offentlig annullering (BigDecimal beløp) {account.withdraw (beløp); }}

La oss nå se på hvordan en ny kontotyp i dette designet kan bryte med Liskov-erstatningsprinsippet.

3.4. En ny kontotype

Banken vil nå tilby en høyrentetjenende innskuddskonto til sine kunder.

For å støtte dette, la oss introdusere en ny FixedTermDepositAccount klasse. En innskuddskonto med fast løpetid i den virkelige verden er en type konto. Dette innebærer arv i vårt objektorienterte design.

Så la oss lage FixedTermDepositAccount en underklasse av Regnskap:

offentlig klasse FixedTermDepositAccount utvider konto {// Overstyrte metoder ...}

Så langt så bra. Banken vil imidlertid ikke tillate uttak for innskuddskontoer med fast sikt.

Dette betyr at den nye FixedTermDepositAccount klassen kan ikke meningsfylt gi ta ut metode som Regnskap definerer. En vanlig løsning for dette er å lage FixedTermDepositAccount kaste en Ikke-støttetOperationException i metoden kan den ikke oppfylle:

offentlig klasse FixedTermDepositAccount utvider konto {@Override beskyttet ugyldig innskudd (BigDecimal beløp) {// Innskudd til denne kontoen} @Override beskyttet ugyldig uttak (BigDecimal beløp) {kast nytt UupportedOperationException ("Uttak støttes ikke av FixedTermDepositAccount !!"); }}

3.5. Testing ved hjelp av den nye kontotypen

Mens den nye klassen fungerer bra, la oss prøve å bruke den med BankingAppWithdrawalService:

Konto myFixedTermDepositAccount = nytt FixedTermDepositAccount (); myFixedTermDepositAccount.deposit (ny BigDecimal (1000,00)); BankingAppWithdrawalService winningService = ny BankingAppWithdrawalService (myFixedTermDepositAccount); tilbaketrekningstjeneste.withdraw (ny BigDecimal (100,00));

Ikke overraskende krasjer banksøknaden med feilen:

Uttak støttes ikke av FixedTermDepositAccount !!

Det er tydeligvis noe galt med dette designet hvis en gyldig kombinasjon av objekter resulterer i en feil.

3.6. Hva gikk galt?

De BankingAppWithdrawalService er en klient av Regnskap klasse. Det forventer at begge deler Regnskap og dens undertyper garanterer atferd som Regnskap klasse har spesifisert for sin ta ut metode:

/ ** * Reduserer kontosaldoen med det angitte beløpet * gitt gitt beløp> 0 og kontoen oppfyller minimum tilgjengelige * saldokriterier. * * @param beløp * / beskyttet abstrakt ugyldig uttak (BigDecimal beløp);

Imidlertid ved ikke å støtte ta ut metoden, den FixedTermDepositAccount bryter denne metodespesifikasjonen. Derfor kan vi ikke erstatte pålitelig FixedTermDepositAccount til Regnskap.

Med andre ord, FixedTermDepositAccount har brutt Liskov-erstatningsprinsippet.

3.7. Kan vi ikke håndtere feilen i BankingAppWithdrawalService?

Vi kunne endre designet slik at klienten til Regnskap‘S ta ut metoden må være klar over en mulig feil i å kalle den. Dette vil imidlertid bety at klienter må ha spesiell kunnskap om uventet undertype. Dette begynner å bryte Open / Closed-prinsippet.

Med andre ord, for at det åpne / lukkede prinsippet skal fungere bra, alt sammen undertyper må erstattes av deres supertype uten å måtte endre klientkoden. Overholdelse av Liskov-substitusjonsprinsippet sikrer denne substituerbarheten.

La oss nå se på Liskov-substitusjonsprinsippet i detalj.

4. Liskov-erstatningsprinsippet

4.1. Definisjon

Robert C. Martin oppsummerer det:

Undertyper må være substituerbare med basistypene.

Barbara Liskov, som definerte den i 1988, ga en mer matematisk definisjon:

Hvis det for hvert objekt o1 av type S er et objekt o2 av typen T slik at for alle programmer P definert i termer av T, er oppførselen til P uendret når o1 erstattes av o2, så er S en undertype av T.

La oss forstå disse definisjonene litt mer.

4.2. Når kan en undertype erstattes av sin supertype?

En undertype kan ikke automatisk erstattes av supertypen. For å være substituerbar må undertypen oppføre seg som sin supertype.

Et objekts oppførsel er kontrakten som kundene kan stole på. Oppførselen er spesifisert av de offentlige metodene, eventuelle begrensninger som er plassert på deres innganger, eventuelle tilstandsendringer som objektet går gjennom, og bivirkningene fra utførelsen av metoder.

Subtyping i Java krever baseklassens egenskaper og metoder er tilgjengelige i underklassen.

Atferdsmessig undertyping betyr imidlertid at ikke bare en undertype gir alle metodene i supertypen, men det må overholde atferdsspesifikasjonen til supertypen. Dette sikrer at alle antagelser fra klientene om supertypeatferd blir oppfylt av undertypen.

Dette er den ekstra begrensningen som Liskov Substitution Principle bringer til objektorientert design.

La oss nå omformulere banksøknaden vår for å løse problemene vi har opplevd tidligere.

5. Refactoring

For å fikse problemene vi fant i bankeksemplet, la oss starte med å forstå grunnårsaken.

5.1. Rotårsaken

I eksemplet, vår FixedTermDepositAccount var ikke en atferdstype av Regnskap.

Utformingen av Regnskap antok feilaktig at alle Regnskap typer tillater uttak. Følgelig alle undertyper av Regnskap, gjelder også FixedTermDepositAccount som ikke støtter uttak, arvet ta ut metode.

Selv om vi kunne løse dette ved å forlenge kontrakten med Regnskap, det er alternative løsninger.

5.2. Revidert klassediagram

La oss utforme kontohierarkiet vårt annerledes:

Fordi alle kontoer ikke støtter uttak, flyttet vi ta ut metoden fra Regnskap klasse til en ny abstrakt underklasse Uttakbar konto. Både Gjeldende konto og Sparekonto tillate uttak. Så de har nå blitt underklasser av den nye Uttakbar konto.

Dette betyr BankingAppWithdrawalService kan stole på riktig type konto for å gi ta ut funksjon.

5.3. Refactored BankingAppWithdrawalService

BankingAppWithdrawalService trenger nå å bruke Uttakbar konto:

offentlig klasse BankingAppWithdrawalService {private WithdrawableAccount pullableAccount; public BankingAppWithdrawalService (WithdrawableAccount pullableAccount) {this.withdrawableAccount = pullableAccount; } offentlig ugyldig uttak (BigDecimal beløp) {uttrekkbartAccount.withdraw (beløp); }}

Når det gjelder FixedTermDepositAccount, beholder vi Regnskap som foreldreklasse. Følgelig arver den bare innskudd oppførsel som den pålitelig kan oppfylle og ikke lenger arver ta ut metode som den ikke vil ha. Denne nye designen unngår problemene vi så tidligere.

6. Regler

La oss nå se på noen regler / teknikker angående metodesignaturer, invarianter, forutsetninger og postforhold som vi kan følge og bruke for å sikre at vi lager veloppførte undertyper.

I boken deres Programutvikling i Java: Abstraksjon, spesifikasjon og objektorientert design, Barbara Liskov og John Guttag grupperte disse reglene i tre kategorier - signaturregelen, eiendomsregelen og metodene.

Noen av disse praksisene er allerede håndhevet av Java's overordnede regler.

Vi bør merke oss noen terminologi her. En bred type er mer generell - Gjenstand for eksempel kan bety ALT Java-objekt og er bredere enn, si, CharSequence, hvor String er veldig spesifikk og derfor smalere.

6.1. Signaturregel - Metodeargumenttyper

Denne regelen sier at de overstyrte argumenttypene for undertypemetoden kan være identiske eller bredere enn argumenttypene for supertypemetoden.

Java's regler for overordnede metoder støtter denne regelen ved å håndheve at argumentene for overstyrte metoden samsvarer nøyaktig med supertypemetoden.

6.2. Signaturregel - Returtyper

Returtypen for den overstyrte undertypemetoden kan være smalere enn returtypen for supertypemetoden. Dette kalles kovarians av returtypene. Kovarians indikerer når en undertype aksepteres i stedet for en supertype. Java støtter samvarianten av returtyper. La oss se på et eksempel:

offentlig abstrakt klasse Foo {offentlig abstrakt Number generereNummer (); // Andre metoder} 

De createNumber metode i Foo har returtype som Nummer. La oss nå overstyre denne metoden ved å returnere en smalere type Heltall:

public class Bar utvider Foo {@Override public Integer generateNumber () {return new Integer (10); } // Andre metoder}

Fordi Heltall ER EN Nummer, en klientkode som forventes Nummer kan erstatte Foo med Bar uten problemer.

På den annen side, hvis den overstyrte metoden i Bar skulle returnere en bredere type enn Nummer, f.eks. Gjenstand, som kan omfatte hvilken som helst undertype av Gjenstand f.eks. en Lastebil. Enhver klientkode som er avhengig av returtypen av Nummer kunne ikke takle en Lastebil!

Heldigvis hindrer Javas metodeoverordnede regler at en overstyringsmetode returnerer en bredere type.

6.3. Underskriftsregel - unntak

Undertypemetoden kan kaste færre eller smalere (men ikke noen ekstra eller bredere) unntak enn supertypemetoden.

Dette er forståelig fordi når klientkoden erstatter en undertype, kan den håndtere metoden som kaster færre unntak enn supertypemetoden. Imidlertid, hvis undertypens metode gir nye eller bredere sjekket unntak, vil den bryte klientkoden.

Javas metodeoverordnede regler håndhever allerede denne regelen for avmerkede unntak. Derimot, overordnede metoder i Java KAN Kaste alle RuntimeException uavhengig av om den overstyrte metoden erklærer unntaket.

6.4. Egenskapsregel - Klassevarianter

En klassevariant er en påstand om objektegenskaper som må være sanne for alle gyldige tilstander av objektet.

La oss se på et eksempel:

offentlig abstrakt klasse bil {beskyttet int grense; // invariant: hastighet <limit; beskyttet int hastighet; // postcondition: hastighet <begrenset beskyttet abstrakt tomrom akselerere (); // Andre metoder ...}

De Bil klasse spesifiserer en klasse invariant som hastighet må alltid være under grense. Invarianternes regel sier at alle subtypemetoder (arvet og nytt) må opprettholde eller styrke supertypens klasseinvariere.

La oss definere en underklasse av Bil som bevarer klassevarianten:

offentlig klasse HybridCar utvider bil {// invariant: charge> = 0; privat int kostnad; @Override // postcondition: speed <limit protected void accelerate () {// Accelerate HybridCar sikrer speed <limit} // Andre metoder ...}

I dette eksemplet er invarianten i Bil er bevart av det overstyrte akselerere metode i Hybridbil. De Hybridbil definerer i tillegg sin egen klassevariant ladning> = 0, og dette er helt greit.

Omvendt, hvis klasse-invarianten ikke er bevart av undertypen, bryter den enhver klientkode som er avhengig av supertypen.

6.5. Egenskapsregel - Historiebegrensning

Historiebegrensningen sier at underklassemetoder (arvet eller ny) skal ikke tillate tilstandsendringer som baseklassen ikke tillot.

La oss se på et eksempel:

offentlig abstrakt klasse bil {// tillatt å bli satt en gang på tidspunktet for opprettelsen. // Verdien kan bare økes deretter. // Verdien kan ikke tilbakestilles. beskyttet int kjørelengde; offentlig bil (int kjørelengde) {this.mileage = kjørelengde; } // Andre egenskaper og metoder ...}

De Bil klasse angir en begrensning på kjørelengde eiendom. De kjørelengde egenskapen kan bare settes en gang på tidspunktet for opprettelsen, og kan ikke tilbakestilles deretter.

La oss nå definere en Leke bil som strekker seg Bil:

offentlig klasse ToyCar utvider Car {public void reset () {mileage = 0; } // Andre egenskaper og metoder}

De Leke bil har en ekstra metode nullstille som tilbakestiller kjørelengde eiendom. Ved å gjøre det, Leke bil ignorert den begrensningen foreldrene pålegger kjørelengde eiendom. Dette bryter enhver klientkode som er avhengig av begrensningen. Så, Leke bil kan ikke erstattes av Bil.

Tilsvarende, hvis basisklassen har en uforanderlig eiendom, bør ikke underklassen tillate at denne egenskapen endres. Dette er grunnen til at uforanderlige klasser skal være endelig.

6.6. Metoderegel - Forutsetninger

En forutsetning skal være oppfylt før en metode kan utføres. La oss se på et eksempel på en forutsetning for parameterverdier:

public class Foo {// forutsetning: 0 <num <= 5 public void doStuff (int num) {if (num 5) {throw new IllegalArgumentException ("Input out of range 1-5"); } // litt logikk her ...}}

Her er forutsetningen for gjøre ting metoden sier at num parameterverdien må være mellom 1 og 5. Vi har håndhevet denne forutsetningen med en områdekontroll inne i metoden. En undertype kan svekke (men ikke styrke) forutsetningen for en metode den overstyrer. Når en undertype svekker forutsetningen, slapper det av begrensningene som supertypemetoden pålegger.

La oss nå overstyre gjøre ting metode med svekket forutsetning:

public class Bar utvider Foo {@Override // forutsetning: 0 <num <= 10 public void doStuff (int num) {if (num 10) {throw new IllegalArgumentException ("Input out of range 1-10"); } // litt logikk her ...}}

Her er forutsetningen svekket i det overstyrte gjøre ting metode til 0 <num <= 10, som tillater et bredere spekter av verdier for num. Alle verdier av num som er gyldige for Foo.doStuff er gyldige for Bar. Gjør ting også. Følgelig en klient av Foo.doStuff merker ikke forskjell når den erstatter Foo med Bar.

Motsatt når en undertype styrker forutsetningen (f.eks. 0 <num <= 3 i vårt eksempel), gjelder det strengere begrensninger enn supertypen. For eksempel verdiene 4 og 5 for num er gyldige for Foo.doStuff, men er ikke lenger gyldig for Bar. Gjør ting.

Dette vil bryte klientkoden som ikke forventer denne nye strammere begrensningen.

6.7. Metoderegel - Postconditions

En posttilstand er en betingelse som skal oppfylles etter at en metode er utført.

La oss se på et eksempel:

offentlig abstrakt klasse Bil {beskyttet int hastighet; // postcondition: hastighet må redusere beskyttet abstrakt ugyldig brems (); // Andre metoder ...} 

Her, den brems Metode av Bil angir en posttilstand som Bil‘S hastighet må reduseres på slutten av metodeutførelsen. Undertypen kan styrke (men ikke svekke) postconditionen for en metode den overstyrer. Når en undertype styrker posttilstanden, gir den mer enn supertypemetoden.

La oss nå definere en avledet klasse av Bil som styrker denne forutsetningen:

offentlig klasse HybridCar utvider bilen {// Noen egenskaper og andre metoder ...@ Override // postcondition: hastighet må reduseres // postcondition: charge må øke beskyttet tom brems () {// Apply HybridCar brake}}

Det overstyrte brems metode i Hybridbil styrker posttilstanden ved i tillegg å sørge for at lade økes også. Følgelig vil enhver klientkode som stole på posttilstanden til brems metoden i Bil klasse merker ingen forskjell når den erstatter Hybridbil til Bil.

Omvendt, hvis Hybridbil skulle svekke posttilstanden til de overstyrte brems metoden, vil det ikke lenger garantere at hastighet ville bli redusert. Dette kan bryte klientkoden gitt en Hybridbil som erstatning for Bil.

7. Kodelukt

Hvordan kan vi få øye på en undertype som ikke kan erstattes av supertypen i den virkelige verden?

La oss se på noen vanlige kodelukter som er tegn på brudd på Liskov-substitusjonsprinsippet.

7.1. En undertype kaster et unntak for en oppførsel den ikke kan oppfylle

Vi har sett et eksempel på dette i vårt eksempel på banksøknad tidligere.

Før refactoring, den Regnskap klasse hadde en ekstra metode ta ut at dens underklasse FixedTermDepositAccount ikke ønsket. De FixedTermDepositAccount klassen jobbet rundt dette ved å kaste Ikke-støttetOperationException for ta ut metode. Dette var imidlertid bare et hack for å dekke over en svakhet i modelleringen av arvshierarkiet.

7.2. En undertype gir ingen gjennomføring for en oppførsel den ikke kan oppfylle

Dette er en variant av kodelukten ovenfor. Undertypen kan ikke oppfylle en oppførsel, og det gjør derfor ingenting i den overstyrte metoden.

Her er et eksempel. La oss definere en Filsystem grensesnitt:

offentlig grensesnitt FileSystem {File [] listFiles (strengbane); ugyldig deleteFile (strengbane) kaster IOException; } 

La oss definere en ReadOnlyFileSystem som implementerer Filsystem:

offentlig klasse ReadOnlyFileSystem implementerer FileSystem {public File [] listFiles (strengbane) {// kode for å liste filer returnerer ny File [0]; } offentlig tomrom deleteFile (strengbane) kaster IOException {// Gjør ingenting. // deleteFile-operasjon støttes ikke på et skrivebeskyttet filsystem}}

Her, den ReadOnlyFileSystem støtter ikke slett fil drift og gir ikke en implementering.

7.3. Kunden vet om undertyper

Hvis klientkoden må brukes tilfelle av eller downcasting, så er sjansen stor for at både det åpne / lukkede prinsippet og Liskov-substitusjonsprinsippet er brutt.

La oss illustrere dette ved hjelp av a FilePurgingJob:

offentlig klasse FilePurgingJob {private FileSystem fileSystem; offentlig FilePurgingJob (FileSystem fileSystem) {this.fileSystem = fileSystem; } public void purgeOldestFile (strengbane) {if (! (fileSystem instanceof ReadOnlyFileSystem)) {// kode for å oppdage eldste fil fileSystem.deleteFile (bane); }}}

Fordi det Filsystem modellen er grunnleggende inkompatibel med skrivebeskyttede filsystemer ReadOnlyFileSystem arver en slett fil metoden den ikke kan støtte. Denne eksempelkoden bruker en tilfelle av sjekk for å gjøre spesialarbeid basert på implementering av undertype.

7.4. En undertypemetode gir alltid samme verdi

Dette er et langt mer subtilt brudd enn de andre og er vanskeligere å få øye på. I dette eksemplet, Leke bil returnerer alltid en fast verdi for gjenværende drivstoff eiendom:

offentlig klasse ToyCar utvider bil {@Override-beskyttet int getRemainingFuel () {retur 0; }} 

Det avhenger av grensesnittet, og hva verdien betyr, men generelt er hardkoding hva som skal være en foranderlig tilstandsverdi for et objekt, et tegn på at underklassen ikke oppfyller hele sin supertype og ikke virkelig kan erstattes av den.

8. Konklusjon

I denne artikkelen så vi på Liskov Substitution SOLID design-prinsippet.

Liskov-substitusjonsprinsippet hjelper oss med å modellere gode arvshierarkier. Det hjelper oss med å forhindre modellhierarkier som ikke samsvarer med Open / Closed-prinsippet.

Enhver arvemodell som overholder Liskov-substitusjonsprinsippet, vil implisitt følge Open / Closed-prinsippet.

Til å begynne med så vi på en brukssak som forsøker å følge Åpen / Lukket-prinsippet, men bryter med Liskov-substitusjonsprinsippet. Deretter så vi på definisjonen av Liskov-substitusjonsprinsippet, forestillingen om atferdsmessig undertyping og reglene som undertyper må følge.

Til slutt så vi på noen vanlige kodelukter som kan hjelpe oss med å oppdage brudd på vår eksisterende kode.

Som alltid er eksempelkoden fra denne artikkelen tilgjengelig på GitHub.


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