Liskov-substitutionsprincip i Java

1. Oversigt

SOLID-designprincipperne blev introduceret af Robert C. Martin i sit papir fra 2000, Designprincipper og designmønstre. SOLIDE designprincipper hjælper os skabe mere vedligeholdelig, forståelig og fleksibel software.

I denne artikel vil vi diskutere Liskov-substitutionsprincippet, som er "L" i akronymet.

2. Det åbne / lukkede princip

For at forstå Liskov-substitutionsprincippet skal vi først forstå det åbne / lukkede princip (“O” fra SOLID).

Målet med Open / Closed-princippet tilskynder os til at designe vores software, så vi tilføj kun nye funktioner ved at tilføje ny kode. Når dette er muligt, har vi løst koblede og dermed let vedligeholdelige applikationer.

3. Et eksempel på brugssag

Lad os se på et eksempel på et bankansøgningsprogram for at forstå det åbne / lukkede princip mere.

3.1. Uden det åbne / lukkede princip

Vores bankapplikation understøtter to kontotyper - "nuværende" og "opsparing". Disse er repræsenteret af klasserne Nuværende konto og Opsparingskonto henholdsvis.

Det BankingAppWithdrawalService tjener tilbagetrækningsfunktionen til sine brugere:

Desværre er der et problem med at udvide dette design. Det BankingAppWithdrawalService er opmærksom på de to konkrete implementeringer af kontoen. Derfor er den BankingAppWithdrawalService skulle ændres hver gang en ny kontotype introduceres.

3.2. Brug af det åbne / lukkede princip til at gøre koden udvidelig

Lad os redesigne løsningen, så den overholder Open / Closed-princippet. Vi lukker BankingAppWithdrawalService fra ændring, når nye kontotyper er nødvendige ved hjælp af en Konto base klasse i stedet:

Her introducerede vi et nyt abstrakt Konto klasse det Nuværende konto og Opsparingskonto forlænge.

Det BankingAppWithdrawalService afhænger ikke længere af konkrete kontoklasser. Fordi det nu kun afhænger af den abstrakte klasse, behøver det ikke at blive ændret, når en ny kontotype introduceres.

Derfor er den BankingAppWithdrawalService er åben for udvidelsen med nye kontotyper, men lukket for ændring, idet de nye typer ikke kræver, at den ændres for at blive integreret.

3.3. Java-kode

Lad os se på dette eksempel i Java. Lad os til at begynde med definere Konto klasse:

offentlig abstrakt klasse Konto {beskyttet abstrakt ugyldigt depositum (BigDecimal beløb); / ** * Reducerer saldoen på kontoen med det angivne beløb * givet det givne beløb> 0, og kontoen opfylder det mindst mulige * saldokriterium. * * @parambeløb * / beskyttet abstrakt ugyldigt tilbagetrækning (BigDecimal beløb); } 

Og lad os definere BankingAppWithdrawalService:

offentlig klasse BankingAppWithdrawalService {privat konto konto; public BankingAppWithdrawalService (Kontokonto) {this.account = konto; } offentlig ugyldig tilbagetrækning (BigDecimal beløb) {account.withdraw (beløb); }}

Lad os nu se på, hvordan en ny kontotype i dette design kan krænke Liskov-substitutionsprincippet.

3.4. En ny kontotype

Banken ønsker nu at tilbyde en højrentetilpasningskonto til sine faste kunder.

For at støtte dette, lad os introducere et nyt FixedTermDepositAccount klasse. En indskudskonto med fast løbetid i den virkelige verden “er en” type konto. Dette indebærer arv i vores objektorienterede design.

Så lad os lave FixedTermDepositAccount en underklasse af Konto:

offentlig klasse FixedTermDepositAccount udvider konto {// Tilsidesatte metoder ...}

Så langt så godt. Banken ønsker imidlertid ikke at tillade udbetalinger til de faste indskudskonti.

Dette betyder, at den nye FixedTermDepositAccount klasse kan ikke meningsfuldt give den trække sig tilbage metode, der Konto definerer. En almindelig løsning på dette er at foretage FixedTermDepositAccount smide en Ikke-understøttetOperationException i metoden kan den ikke opfylde:

offentlig klasse FixedTermDepositAccount udvider konto {@Override beskyttet ugyldigt depositum (BigDecimal beløb) {// Indsæt på denne konto} @Override beskyttet ugyldigt tilbagetrækning (BigDecimal beløb) {kast nyt UupportedOperationException ("Tilbagetrækning understøttes ikke af FixedTermDepositAccount !!"); }}

3.5. Test ved hjælp af den nye kontotype

Mens den nye klasse fungerer fint, lad os prøve at bruge den med BankingAppWithdrawalService:

Konto myFixedTermDepositAccount = ny FixedTermDepositAccount (); myFixedTermDepositAccount.deposit (nyt BigDecimal (1000,00)); BankingAppWithdrawalService winningService = ny BankingAppWithdrawalService (myFixedTermDepositAccount); tilbagetrækningstjeneste.withdraw (ny BigDecimal (100,00));

Ikke overraskende går bankapplikationen ned med fejlen:

Tilbagetrækninger understøttes ikke af FixedTermDepositAccount !!

Der er tydeligt noget galt med dette design, hvis en gyldig kombination af objekter resulterer i en fejl.

3.6. Hvad gik galt?

Det BankingAppWithdrawalService er klient af Konto klasse. Det forventer, at begge dele Konto og dens undertyper garanterer den adfærd, som Konto klasse har specificeret for sin trække sig tilbage metode:

/ ** * Reducerer kontosaldoen med det specificerede beløb * givet det givne beløb> 0, og kontoen opfylder minimums tilgængelige * saldokriterier. * * @parambeløb * / beskyttet abstrakt ugyldigt tilbagetrækning (BigDecimal beløb);

Dog ved ikke at støtte trække sig tilbage metode, den FixedTermDepositAccount overtræder denne metodespecifikation. Derfor kan vi ikke erstatte pålideligt FixedTermDepositAccount til Konto.

Med andre ord, FixedTermDepositAccount har overtrådt Liskov-substitutionsprincippet.

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

Vi kunne ændre designet, så kunden af Konto'S trække sig tilbage metoden skal være opmærksom på en mulig fejl i at kalde den. Dette vil dog betyde, at klienter skal have særlig viden om uventet undertypeadfærd. Dette begynder at bryde Open / Closed-princippet.

Med andre ord, for at det åbne / lukkede princip fungerer godt, alt sammen undertyper skal kunne erstattes af deres supertype uden nogensinde at skulle ændre klientkoden. Overholdelse af Liskov-substitutionsprincippet sikrer denne substituerbarhed.

Lad os nu se på Liskov-substitutionsprincippet i detaljer.

4. Liskov-substitutionsprincippet

4.1. Definition

Robert C. Martin opsummerer det:

Undertyper skal kunne erstattes af deres basetyper.

Barbara Liskov, der definerede det i 1988, leverede en mere matematisk definition:

Hvis der for hvert objekt o1 af type S er et objekt o2 af typen T, således at for alle programmer P defineret i termer af T er P's adfærd uændret, når o1 erstattes af o2, så er S en undertype af T.

Lad os forstå disse definitioner lidt mere.

4.2. Hvornår kan en undertype udskiftes med sin supertype?

En undertype kan ikke automatisk erstattes af sin supertype. For at være substituerbar skal undertypen opføre sig som sin supertype.

Et objekts adfærd er den kontrakt, som klienterne kan stole på. Opførslen er specificeret af de offentlige metoder, eventuelle begrænsninger for deres input, enhver tilstandsændring, som objektet gennemgår, og bivirkningerne fra udførelsen af ​​metoder.

Undertypning i Java kræver baseklassens egenskaber, og metoder er tilgængelige i underklassen.

Imidlertid betyder adfærdsmæssig undertypning, at ikke kun en undertype giver alle metoderne i supertypen, men det skal overholde adfærdsspecifikationen for supertypen. Dette sikrer, at eventuelle antagelser fra klienterne om supertypeadfærd overholdes af undertypen.

Dette er den yderligere begrænsning, som Liskov Substitution Princip bringer til objektorienteret design.

Lad os nu omformulere vores bankapplikation for at løse de problemer, vi stødte på tidligere.

5. Refactoring

For at løse de problemer, vi fandt i bankeksemplet, skal vi starte med at forstå grundårsagen.

5.1. Roden Årsag

I eksemplet er vores FixedTermDepositAccount var ikke en adfærdsmæssig undertype af Konto.

Designet af Konto antog forkert, at alle Konto typer tillader tilbagetrækning. Derfor er alle undertyper af Konto, inklusive FixedTermDepositAccount som ikke understøtter udbetalinger, arvede trække sig tilbage metode.

Selvom vi kunne omgå dette ved at udvide kontrakten med Konto, der er alternative løsninger.

5.2. Revideret klassediagram

Lad os designe vores kontohierarki forskelligt:

Da alle konti ikke understøtter udbetalinger, flyttede vi trække sig tilbage metode fra Konto klasse til en ny abstrakt underklasse Uttageligt konto. Begge Nuværende konto og Opsparingskonto tillade udbetalinger. Så de er nu blevet underklasser af den nye Uttageligt konto.

Det betyder BankingAppWithdrawalService kan stole på den rigtige type konto for at levere trække sig tilbage fungere.

5.3. Ombygget BankingAppWithdrawalService

BankingAppWithdrawalService skal nu bruge Uttageligt konto:

offentlig klasse BankingAppWithdrawalService {privat WithdrawableAccount tilbagebetaltAccount; public BankingAppWithdrawalService (WithdrawableAccount pullableAccount) {this.withdrawableAccount = hæveligtAccount; } offentlig ugyldig tilbagetrækning (BigDecimal beløb) {hævnbarAccount.withdraw (beløb); }}

Som for FixedTermDepositAccount, vi bevarer Konto som sin overordnede klasse. Derfor arver den kun den depositum adfærd, som den pålideligt kan opfylde og ikke længere arver trække sig tilbage metode, som den ikke ønsker. Dette nye design undgår de problemer, vi så tidligere.

6. Regler

Lad os nu se på nogle regler / teknikker vedrørende metodesignaturer, invarianter, forudsætninger og postbetingelser, som vi kan følge og bruge for at sikre, at vi skaber velopførte undertyper.

I deres bog Programudvikling i Java: Abstraktion, Specifikation og Objektorienteret Design, Barbara Liskov og John Guttag grupperede disse regler i tre kategorier - signaturreglen, egenskabsreglen og metoderne.

Nogle af disse fremgangsmåder håndhæves allerede af Java's overordnede regler.

Vi skal bemærke nogle terminologier her. En bred type er mere generel - Objekt for eksempel kunne betyde ALLE Java-objekter og er bredere end f.eks. CharSequence, hvor Snor er meget specifik og derfor smallere.

6.1. Signaturregel - Metode Argumenttyper

Denne regel siger, at de tilsidesatte undertypemetoder kan være identiske eller bredere end supertypemetodens argumenttyper.

Java's regler for tilsidesættelse af metoden understøtter denne regel ved at håndhæve, at de overstyrede metodeargumenttyper matcher nøjagtigt med supertypemetoden.

6.2. Signaturregel - Returtyper

Returtypen for den tilsidesatte undertypemetode kan være smallere end supertypemetodens returtype. Dette kaldes kovarians af returtyperne. Kovarians indikerer, hvornår en undertype accepteres i stedet for en supertype. Java understøtter kovariansen af ​​returtyper. Lad os se på et eksempel:

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

Det createNumber metode i Foo har returtype som Nummer. Lad os nu tilsidesætte denne metode ved at returnere en smallere type Heltal:

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

Fordi Heltal ER EN Nummer, en klientkode, der forventes Nummer kan erstatte Foo med Bar uden problemer.

På den anden side, hvis den tilsidesatte metode i Bar skulle returnere en bredere type end Nummer, f.eks. Objekt, der kan omfatte enhver undertype af Objekt f.eks. -en Lastbil. Enhver klientkode, der var afhængig af returtypen af Nummer kunne ikke håndtere en Lastbil!

Heldigvis forhindrer Javas metodeoverordnede regler, at en tilsidesættelsesmetode returnerer en bredere type.

6.3. Underskriftsregel - undtagelser

Undertypemetoden kan kaste færre eller smallere (men ikke nogen yderligere eller bredere) undtagelser end supertypemetoden.

Dette er forståeligt, fordi når klientkoden erstatter en undertype, kan den håndtere metoden, der kaster færre undtagelser end supertypemetoden. Men hvis undertypens metode kaster nye eller bredere kontrollerede undtagelser, vil den bryde klientkoden.

Java's metodeoverordnede regler håndhæver allerede denne regel for kontrollerede undtagelser. Imidlertid, overordnede metoder i Java KAN Kaste alle RuntimeException uanset om den tilsidesatte metode erklærer undtagelsen.

6.4. Egenskabsregel - klassevarianter

En klassevariant er en påstand om objektegenskaber, der skal være sand for alle objektets gyldige tilstande.

Lad os se på et eksempel:

offentlig abstrakt klasse bil {beskyttet int grænse; // invariant: hastighed <grænse; beskyttet int hastighed; // postcondition: hastighed <grænse beskyttet abstrakt tomrum accelerere (); // Andre metoder ...}

Det Bil klasse angiver en klasse invariant, at fart skal altid være under begrænse. Invarianternes regel siger, at alle undertypemetoder (nedarvede og nye) skal vedligeholde eller styrke supertypens klasseinvariere.

Lad os definere en underklasse af Bil der bevarer klassens invariant:

offentlig klasse HybridCar udvider bil {// invariant: charge> = 0; privat int gebyr; @Override // postcondition: hastighed <grænse beskyttet tomrum accelerere () {// Accelerate HybridCar sikre hastighed <grænse} // Andre metoder ...}

I dette eksempel er invarianten i Bil bevares af den tilsidesatte fremskynde metode i Hybrid bil. Det Hybrid bil definerer desuden sin egen klassevariant opladning> = 0, og det er helt fint.

Omvendt, hvis klasse-invarianten ikke bevares af undertypen, bryder den enhver klientkode, der er afhængig af supertypen.

6.5. Egenskabsregel - Begrænsning af historie

Historiens begrænsning siger, at underklassemetoder (nedarvet eller ny) bør ikke tillade tilstandsændringer, som basisklassen ikke tillod.

Lad os se på et eksempel:

offentlig abstrakt klasse bil {// tilladt at blive indstillet en gang på tidspunktet for oprettelsen. // Værdien kan kun øges derefter. // Værdien kan ikke nulstilles. beskyttet int kilometertal; offentlig bil (int kilometertal) {this.mileage = kilometertal; } // Andre egenskaber og metoder ...}

Det Bil klasse angiver en begrænsning på kilometertal ejendom. Det kilometertal ejendom kan kun indstilles én gang på tidspunktet for oprettelsen og kan ikke nulstilles derefter.

Lad os nu definere en Legetøjsbil der strækker sig Bil:

offentlig klasse ToyCar udvider bil {offentlig ugyldig nulstilling () {kilometertal = 0; } // Andre egenskaber og metoder}

Det Legetøjsbil har en ekstra metode Nulstil der nulstiller kilometertal ejendom. Ved at gøre dette, Legetøjsbil ignorerede den begrænsning, som dets forælder pålagde kilometertal ejendom. Dette bryder enhver klientkode, der er afhængig af begrænsningen. Så, Legetøjsbil kan ikke erstattes af Bil.

Tilsvarende, hvis basisklassen har en uforanderlig egenskab, bør underklassen ikke tillade, at denne ejendom ændres. Dette er grunden til uforanderlige klasser endelig.

6.6. Metoderegel - Forudsætninger

En forudsætning skal være opfyldt, før en metode kan udføres. Lad os se på et eksempel på en forudsætning for parameterværdier:

public class Foo {// forudsætning: 0 <num <= 5 public void doStuff (int num) {if (num 5) {throw new IllegalArgumentException ("Input out of range 1-5"); } // noget logik her ...}}

Her er forudsætningen for doStuff metode angiver, at antal parameterværdien skal være mellem 1 og 5. Vi har håndhævet denne forudsætning med et områdekontrol inde i metoden. En undertype kan svække (men ikke styrke) forudsætningen for en metode, den tilsidesætter. Når en undertype svækker forudsætningen, lindrer den de begrænsninger, som supertypemetoden pålægger.

Lad os nu tilsidesætte doStuff metode med en svækket forudsætning:

public class Bar udvider Foo {@Override // forudsætning: 0 <num <= 10 public void doStuff (int num) {if (num 10) {throw new IllegalArgumentException ("Input out of range 1-10"); } // noget logik her ...}}

Her svækkes forudsætningen i det tilsidesatte doStuff metode til 0 <num <= 10, der tillader en bredere vifte af værdier for antal. Alle værdier af antal der er gyldige for Foo.doStuff er gyldige for Bar.doStuff såvel. Derfor er en kunde hos Foo.doStuff bemærker ikke forskel, når den erstatter Foo med Bar.

Omvendt, når en undertype styrker forudsætningen (f.eks. 0 <num <= 3 i vores eksempel) anvender den strengere begrænsninger end supertypen. F.eks. Værdier 4 & 5 for antal er gyldige for Foo.doStuff, men er ikke længere gyldige for Bar.doStuff.

Dette ville bryde klientkoden, der ikke forventer denne nye strammere begrænsning.

6.7. Metoderegel - Efterbetingelser

En posttilstand er en betingelse, der skal være opfyldt, efter at en metode er udført.

Lad os se på et eksempel:

offentlig abstrakt klasse Bil {beskyttet int hastighed; // postcondition: hastighed skal reducere beskyttet abstrakt tomrumsbremse (); // Andre metoder ...} 

Her, den bremse metode til Bil angiver en postbetingelse, som Bil'S fart skal reduceres ved afslutningen af ​​metodeudførelsen. Undertypen kan styrke (men ikke svække) postconditionen for en metode, den tilsidesætter. Når en undertype styrker posttilstanden, giver den mere end supertypemetoden.

Lad os nu definere en afledt klasse af Bil der styrker denne forudsætning:

offentlig klasse HybridCar udvider bil {// Nogle egenskaber og andre metoder [email protected] Override // postcondition: hastighed skal reduceres // postcondition: opladning skal øge beskyttet ugyldig bremse () {// Påfør HybridCar-bremse}}

Den tilsidesatte bremse metode i Hybrid bil styrker posttilstanden ved yderligere at sikre, at oplade øges også. Derfor er enhver klientkode, der er afhængig af postconditionen for bremse metode i Bil klasse bemærker ingen forskel, når den erstatter Hybrid bil til Bil.

Omvendt, hvis Hybrid bil skulle svække de tilsidesatte postcondition bremse metode, ville det ikke længere garantere, at fart ville blive reduceret. Dette kan bryde klientkoden givet en Hybrid bil som erstatning for Bil.

7. Kodelugt

Hvordan kan vi få øje på en undertype, der ikke kan erstattes af dens supertype i den virkelige verden?

Lad os se på nogle almindelige kodelugt, der er tegn på en overtrædelse af Liskov-substitutionsprincippet.

7.1. En undertype kaster en undtagelse for en adfærd, den ikke kan opfylde

Vi har set et eksempel på dette i vores eksempel på bankapplikationer tidligere.

Forud for refactoring, den Konto klasse havde en ekstra metode trække sig tilbage at dens underklasse FixedTermDepositAccount ikke ønskede. Det FixedTermDepositAccount klasse arbejdede omkring dette ved at kaste Ikke-understøttetOperationException til trække sig tilbage metode. Dette var dog bare et hack for at dække over en svaghed i modelleringen af ​​arvshierarkiet.

7.2. En undertype giver ingen implementering af en adfærd, den ikke kan opfylde

Dette er en variation af ovenstående kodelugt. Undertypen kan ikke opfylde en adfærd, og det gør derfor intet i den tilsidesatte metode.

Her er et eksempel. Lad os definere en Filsystem grænseflade:

offentlig grænseflade FileSystem {File [] listFiles (strengsti); ugyldig deleteFile (streng sti) kaster IOException; } 

Lad os definere en ReadOnlyFileSystem der implementerer Filsystem:

offentlig klasse ReadOnlyFileSystem implementerer FileSystem {public File [] listFiles (streng sti) {// kode for at liste filer returnere ny File [0]; } offentlig tomrum deleteFile (streng sti) kaster IOException {// Gør intet. // deleteFile-handling understøttes ikke på et skrivebeskyttet filsystem}}

Her, den ReadOnlyFileSystem understøtter ikke slet fil operation og giver derfor ikke en implementering.

7.3. Kunden ved om undertyper

Hvis klientkoden skal bruges forekomst af eller downcasting, så er chancerne for, at både det åbne / lukkede princip og Liskov-substitutionsprincippet er blevet overtrådt.

Lad os illustrere dette ved hjælp af en FilePurgingJob:

offentlig klasse FilePurgingJob {private FileSystem fileSystem; offentlig FilePurgingJob (FileSystem fileSystem) {this.fileSystem = fileSystem; } public void purgeOldestFile (streng sti) {if (! (fileSystem instans af ReadOnlyFileSystem)) {// kode til at opdage ældste fil fileSystem.deleteFile (sti); }}}

Fordi Filsystem modellen er grundlæggende uforenelig med skrivebeskyttede filsystemer ReadOnlyFileSystem arver en slet fil metode, den understøtter ikke. Denne eksempelkode bruger en forekomst af tjek for at udføre specielt arbejde baseret på en subtypeimplementering.

7.4. En undertypemetode returnerer altid den samme værdi

Dette er en langt mere subtil overtrædelse end de andre og er sværere at få øje på. I dette eksempel Legetøjsbil returnerer altid en fast værdi for resterende brændstof ejendom:

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

Det afhænger af grænsefladen, og hvad værdien betyder, men generelt er hardcoding, hvad der skal være en ændret tilstandsværdi for et objekt, et tegn på, at underklassen ikke opfylder hele sin supertype og ikke virkelig kan erstattes af den.

8. Konklusion

I denne artikel kiggede vi på Liskov Substitution SOLID-designprincippet.

Liskov-substitutionsprincippet hjælper os med at modellere gode arvshierarkier. Det hjælper os med at forhindre modelhierarkier, der ikke overholder Open / Closed-princippet.

Enhver arvemodel, der overholder Liskov-substitutionsprincippet, vil implicit følge Open / Closed-princippet.

Til at begynde med så vi på en brugssag, der forsøgte at følge Åben / Lukket-princippet, men krænker Liskov-substitutionsprincippet. Dernæst så vi på definitionen af ​​Liskov-substitutionsprincippet, begrebet adfærdsmæssig undertypning og de regler, som undertyper skal følge.

Endelig kiggede vi på nogle almindelige kodelugt, der kan hjælpe os med at opdage overtrædelser i vores eksisterende kode.

Som altid er eksempelkoden fra denne artikel tilgængelig på GitHub.