Liskov-substitutieprincipe in Java

1. Overzicht

De SOLID-ontwerpprincipes werden geïntroduceerd door Robert C.Martin in zijn paper uit 2000, Ontwerpprincipes en ontwerppatronen. SOLID-ontwerpprincipes helpen ons maak meer onderhoudbare, begrijpelijke en flexibele software.

In dit artikel bespreken we het Liskov-vervangingsprincipe, de 'L' in het acroniem.

2. Het open / gesloten principe

Om het Liskov-vervangingsprincipe te begrijpen, moeten we eerst het Open / Gesloten-principe (de "O" van SOLID) begrijpen.

Het doel van het Open / Gesloten principe moedigt ons aan om onze software zo te ontwerpen alleen nieuwe functies toevoegen door nieuwe code toe te voegen. Wanneer dit mogelijk is, hebben we losjes gekoppelde, en dus gemakkelijk te onderhouden applicaties.

3. Een voorbeeld van een use-case

Laten we eens kijken naar een voorbeeld van een bankapplicatie om het open / gesloten principe wat meer te begrijpen.

3.1. Zonder het open / gesloten principe

Onze bankapplicatie ondersteunt twee rekeningtypes - “huidig” en “spaargeld”. Deze worden vertegenwoordigd door de klassen Huidig ​​account en Spaarrekening respectievelijk.

De BankingAppWithdrawalService dient de opnamefunctie aan zijn gebruikers:

Helaas is er een probleem bij het uitbreiden van dit ontwerp. De BankingAppWithdrawalService is zich bewust van de twee concrete implementaties van account. Daarom, de BankingAppWithdrawalService elke keer dat een nieuw accounttype wordt geïntroduceerd, moet worden gewijzigd.

3.2. Het open / gesloten principe gebruiken om de code uitbreidbaar te maken

Laten we de oplossing opnieuw ontwerpen om te voldoen aan het Open / Gesloten-principe. We sluiten af BankingAppWithdrawalService van wijziging wanneer nieuwe accounttypes nodig zijn, door een Account basisklasse in plaats daarvan:

Hier hebben we een nieuw abstract geïntroduceerd Account klasse dat Huidig ​​account en Spaarrekening uitbreiden.

De BankingAppWithdrawalService is niet langer afhankelijk van concrete accountklassen. Omdat het nu alleen afhangt van de abstracte klasse, hoeft het niet te worden gewijzigd wanneer een nieuw accounttype wordt geïntroduceerd.

Bijgevolg is het BankingAppWithdrawalService is open voor de extensie met nieuwe accounttypes, maar gesloten voor wijziging, in die zin dat de nieuwe typen het niet nodig hebben om te veranderen om te integreren.

3.3. Java-code

Laten we dit voorbeeld in Java bekijken. Laten we om te beginnen de Account klasse:

openbare abstracte klasse-rekening {beschermde abstracte nietige storting (BigDecimal-bedrag); / ** * Verlaagt het saldo van de rekening met het opgegeven bedrag * mits gegeven bedrag> 0 en de rekening voldoet aan de minimum beschikbare * saldocriteria. * * @param bedrag * / beschermde abstract ongeldige opname (BigDecimal-bedrag); } 

En laten we de BankingAppWithdrawalService:

openbare klasse BankingAppWithdrawalService {privérekeningaccount; openbare BankingAppWithdrawalService (Accountaccount) {this.account = account; } openbare ongeldige opname (BigDecimal-bedrag) {account.withdraw (bedrag); }}

Laten we nu eens kijken hoe, in dit ontwerp, een nieuw accounttype het Liskov-vervangingsprincipe zou kunnen schenden.

3.4. Een nieuw accounttype

De bank wil haar klanten nu een hoogrentende termijndeposito aanbieden.

Om dit te ondersteunen, introduceren we een nieuw FixedTermDepositAccount klasse. Een deposito-rekening met een vaste looptijd in de echte wereld "is een" type rekening. Dit impliceert overerving in ons objectgeoriënteerde ontwerp.

Dus laten we maken FixedTermDepositAccount een subklasse van Account:

openbare klasse FixedTermDepositAccount breidt Account {// Overschreven methoden ...}

Tot nu toe zo goed. De bank wil echter geen opnames toestaan ​​voor de termijnrekeningen.

Dit betekent dat het nieuwe FixedTermDepositAccount klasse kan de terugtrekken methode dat Account definieert. Een veel voorkomende oplossing hiervoor is om FixedTermDepositAccount gooi een UnsupportedOperationException in de methode die het niet kan vervullen:

openbare klasse FixedTermDepositAccount breidt account uit {@Override beschermde storting (BigDecimal-bedrag) {// Stort op deze rekening} @Override beschermde ongeldige opname (BigDecimal-bedrag) {gooi nieuwe UnsupportedOperationException ("Opnames worden niet ondersteund door FixedTermDepositAccount !!"); }}

3.5. Testen met het nieuwe accounttype

Hoewel de nieuwe klasse prima werkt, kunnen we proberen deze te gebruiken met de BankingAppWithdrawalService:

Account myFixedTermDepositAccount = nieuwe FixedTermDepositAccount (); myFixedTermDepositAccount.deposit (nieuwe BigDecimal (1000.00)); BankingAppWithdrawalService recordingService = nieuwe BankingAppWithdrawalService (myFixedTermDepositAccount); terugtrekkingService.withdraw (nieuwe BigDecimal (100.00));

Het is niet verwonderlijk dat de bankapplicatie crasht met de fout:

Opnames worden niet ondersteund door FixedTermDepositAccount !!

Er is duidelijk iets mis met dit ontwerp als een geldige combinatie van objecten resulteert in een fout.

3.6. Wat ging er mis?

De BankingAppWithdrawalService is een klant van de Account klasse. Het verwacht dat beide Account en zijn subtypen garanderen het gedrag dat de Account klasse heeft opgegeven voor zijn terugtrekken methode:

/ ** * Verlaagt het rekeningsaldo met het opgegeven bedrag * mits gegeven bedrag> 0 en het account voldoet aan de minimum beschikbare * saldocriteria. * * @param bedrag * / beschermde abstract ongeldige opname (BigDecimal-bedrag);

Door het terugtrekken methode, de FixedTermDepositAccount schendt deze methodespecificatie. Daarom kunnen we deze niet op betrouwbare wijze vervangen FixedTermDepositAccount voor Account.

Met andere woorden, de FixedTermDepositAccount heeft het Liskov-vervangingsprincipe geschonden.

3.7. Kunnen we de fout niet in BankingAppWithdrawalService?

We konden het ontwerp aanpassen zodat de opdrachtgever van Account‘S terugtrekken methode moet zich bewust zijn van een mogelijke fout bij het aanroepen ervan. Dit zou echter betekenen dat cliënten speciale kennis moeten hebben van onverwacht subtype gedrag. Dit begint het Open / Gesloten principe te doorbreken.

Met andere woorden, om het open / gesloten principe goed te laten werken, allemaal subtypen moeten hun supertype kunnen vervangen zonder ooit de clientcode te hoeven wijzigen. Het naleven van het Liskov-vervangingsprincipe zorgt voor deze substitueerbaarheid.

Laten we nu in detail kijken naar het Liskov-vervangingsprincipe.

4. Het Liskov-substitutieprincipe

4.1. Definitie

Robert C. Martin vat het samen:

Subtypen moeten substitueerbaar zijn voor hun basistypen.

Barbara Liskov, die het in 1988 definieerde, gaf een meer wiskundige definitie:

Als voor elk object o1 van type S er een object o2 van type T is zodat voor alle programma's P gedefinieerd in termen van T, het gedrag van P ongewijzigd blijft wanneer o1 wordt vervangen door o2, dan is S een subtype van T.

Laten we deze definities een beetje beter begrijpen.

4.2. Wanneer kan een subtype worden vervangen door zijn supertype?

Een subtype wordt niet automatisch substitueerbaar voor zijn supertype. Om substitueerbaar te zijn, moet het subtype zich gedragen als zijn supertype.

Het gedrag van een object is het contract waarop zijn klanten kunnen vertrouwen. Het gedrag wordt gespecificeerd door de openbare methoden, eventuele beperkingen die aan hun invoer worden gesteld, eventuele statusveranderingen die het object doormaakt en de bijwerkingen van de uitvoering van methoden.

Subtypen in Java vereist de eigenschappen van de basisklasse en de methoden zijn beschikbaar in de subklasse.

Gedragssubtypering betekent echter dat een subtype niet alleen alle methoden in het supertype biedt, maar moet voldoen aan de gedragsspecificatie van het supertype. Dit zorgt ervoor dat aan alle aannames van de klanten over het supertype-gedrag wordt voldaan door het subtype.

Dit is de extra beperking die het Liskov-vervangingsprincipe met zich meebrengt voor objectgeoriënteerd ontwerp.

Laten we nu onze bankapplicatie herstructureren om de problemen aan te pakken die we eerder tegenkwamen.

5. Herstructurering

Om de problemen op te lossen die we in het bankvoorbeeld hebben gevonden, laten we beginnen met het begrijpen van de hoofdoorzaak.

5.1. De oorzaak

In het voorbeeld onze FixedTermDepositAccount was geen gedragssubtype van Account.

Het ontwerp van Account ten onrechte aangenomen dat alles Account typen staan ​​opnames toe. Bijgevolg zijn alle subtypen van Account, inclusief FixedTermDepositAccount die geen opnames ondersteunt, erfde het terugtrekken methode.

Hoewel we dit kunnen omzeilen door het contract van te verlengen Accountzijn er alternatieve oplossingen.

5.2. Herzien klassendiagram

Laten we onze accounthiërarchie anders ontwerpen:

Omdat niet alle accounts opnames ondersteunen, hebben we de terugtrekken methode van de Account klasse naar een nieuwe abstracte subklasse Intrekbare account. Beide Huidig ​​account en Spaarrekening opnames toestaan. Ze zijn nu dus in subklassen van het nieuwe gemaakt Intrekbare account.

Dit betekent BankingAppWithdrawalService kan vertrouwen op het juiste type account om het terugtrekken functie.

5.3. Opnieuw ontworpen BankingAppWithdrawalService

BankingAppWithdrawalService moet nu de Intrekbare account:

openbare klasse BankingAppWithdrawalService {private WithdrawableAccount intrekbareAccount; openbare BankingAppWithdrawalService (WithdrawableAccount intrekbareAccount) {this.withdrawableAccount = intrekbareAccount; } openbare ongeldige opname (BigDecimal-bedrag) {opnamableAccount.withdraw (bedrag); }}

Wat betreft FixedTermDepositAccount, behouden we Account als bovenliggende klasse. Bijgevolg erft het alleen de storting gedrag dat het betrouwbaar kan vervullen en niet langer de terugtrekken methode die het niet wil. Dit nieuwe ontwerp vermijdt de problemen die we eerder zagen.

6. Regels

Laten we nu eens kijken naar enkele regels / technieken met betrekking tot methodesignaturen, invarianten, randvoorwaarden en postcondities die we kunnen volgen en gebruiken om ervoor te zorgen dat we goed gedragende subtypen creëren.

In hun boek Programmaontwikkeling in Java: abstractie, specificatie en objectgeoriënteerd ontwerp, Hebben Barbara Liskov en John Guttag deze regels in drie categorieën gegroepeerd: de handtekeningregel, de eigenschappenregel en de methoderegel.

Sommige van deze praktijken worden al afgedwongen door Java's dwingende regels.

We zouden hier wat terminologie moeten opmerken. Een breed type is algemener - Voorwerp kan bijvoorbeeld ELK Java-object betekenen en is breder dan, laten we zeggen, CharSequence, waar Draad is heel specifiek en daardoor smaller.

6.1. Handtekeningregel - Argumenttypen voor methoden

Deze regel stelt dat de overschreven argumenttypen van de subtype methode kunnen identiek of breder zijn dan de argumenttypen van de supertype methode.

Java's methode-overschrijvende regels ondersteunen deze regel door af te dwingen dat de overschreven methode-argumenttypen exact overeenkomen met de supertype-methode.

6.2. Handtekeningregel - Retourtypen

Het retourtype van de overschreven subtype-methode kan smaller zijn dan het retourtype van de supertype-methode. Dit wordt covariantie van de retourtypen genoemd. Covariantie geeft aan wanneer een subtype wordt geaccepteerd in plaats van een supertype. Java ondersteunt de covariantie van retourtypen. Laten we naar een voorbeeld kijken:

openbare abstracte klasse Foo {openbare abstracte Number genererenNumber (); // Andere methodes } 

De GenereerNummer methode in Foo heeft retourtype als Aantal. Laten we deze methode nu negeren door een smaller type Geheel getal:

public class Bar breidt Foo uit {@Override public Integer generationNumber () {return new Integer (10); } // Andere methodes }

Omdat Geheel getal IS EEN Aantal, een klantcode die verwacht Aantal kan vervangen Foo met Bar probleemloos.

Aan de andere kant, als de overschreven methode in Bar zouden een breder type teruggeven dan Aantal, b.v. Voorwerp, dat kan elk subtype van Voorwerp bijv. een Vrachtauto. Elke clientcode die afhankelijk was van het retourtype Aantal kon een Vrachtauto!

Gelukkig voorkomen Java's methode die regels overschrijft, dat een override-methode een breder type retourneert.

6.3. Handtekeningregel - Uitzonderingen

De subtype-methode kan minder of smallere (maar geen aanvullende of bredere) uitzonderingen opleveren dan de supertype-methode.

Dit is begrijpelijk omdat wanneer de clientcode een subtype vervangt, deze de methode kan verwerken met minder uitzonderingen dan de supertype-methode. Als de methode van het subtype echter nieuwe of bredere gecontroleerde uitzonderingen genereert, zou het de clientcode breken.

Java's methode die regels overschrijft, dwingt deze regel al af voor gecontroleerde uitzonderingen. Echter, overschrijvende methoden in Java KUNNEN elke RuntimeException ongeacht of de overschreven methode de uitzondering declareert.

6.4. Eigenschappenregel - Klasse-invarianten

Een klasse-invariant is een bewering betreffende objecteigenschappen die waar moet zijn voor alle geldige toestanden van het object.

Laten we naar een voorbeeld kijken:

openbare abstracte klasse Auto {beschermde int limiet; // invariant: snelheid <limiet; beschermde int snelheid; // postcondition: speed <limit protected abstract void accelerate (); // Andere methodes... }

De Auto class specificeert een klasse-invariant die snelheid moet altijd onder de limiet. De invariantenregel stelt dat alle subtype-methoden (overgeërfd en nieuw) moeten de klasse-invarianten van het supertype behouden of versterken.

Laten we een subklasse definiëren van Auto dat behoudt de klasse-invariant:

openbare klasse HybridCar breidt Car {// invariant: charge> = 0; privé int charge; @Override // postcondition: speed <limit protected void accelerate () {// Accelerate HybridCar zorgt voor snelheid <limit} // Andere methoden ...}

In dit voorbeeld is de invariant in Auto wordt bewaard door het onderdrukte versnellen methode in Hybride auto. De Hybride auto definieert bovendien zijn eigen klasse-invariant lading> = 0, en dit is prima.

Omgekeerd, als de klasse-invariant niet wordt behouden door het subtype, breekt het elke clientcode die afhankelijk is van het supertype.

6.5. Eigenschappenregel - Geschiedenisbeperking

De geschiedenisbeperking stelt dat de subklassemethoden (overgeërfd of nieuw) zouden geen toestandswijzigingen moeten toestaan ​​die de basisklasse niet toestond.

Laten we naar een voorbeeld kijken:

openbare abstracte klasse Auto {// Mag één keer worden ingesteld op het moment van maken. // Waarde kan alleen daarna worden verhoogd. // Waarde kan niet worden gereset. beschermde int kilometerstand; openbare auto (int kilometerstand) {this.mileage = kilometerstand; } // Andere eigenschappen en methoden ...}

De Auto class specificeert een beperking op de kilometerstand eigendom. De kilometerstand eigenschap kan slechts één keer worden ingesteld op het moment van aanmaken en kan daarna niet worden gereset.

Laten we nu een Speelgoedauto dat strekt zich uit Auto:

openbare klasse ToyCar verlengt auto {openbare ongeldige reset () {kilometerstand = 0; } // Andere eigenschappen en methoden}

De Speelgoedauto heeft een extra methode resetten dat reset de kilometerstand eigendom. Daarbij de Speelgoedauto negeerde de beperking die door de bovenliggende instantie werd opgelegd aan de kilometerstand eigendom. Dit verbreekt elke clientcode die afhankelijk is van de beperking. Zo, Speelgoedauto is niet substitueerbaar voor Auto.

Evenzo, als de basisklasse een onveranderlijke eigenschap heeft, mag de subklasse niet toestaan ​​dat deze eigenschap wordt gewijzigd. Dit is de reden waarom onveranderlijke klassen zouden moeten zijn laatste.

6.6. Methodenregel - Voorwaarden

Er moet aan een voorwaarde zijn voldaan voordat een methode kan worden uitgevoerd. Laten we eens kijken naar een voorbeeld van een voorwaarde met betrekking tot parameterwaarden:

public class Foo {// voorwaarde: 0 <num <= 5 public void doStuff (int num) {if (num 5) {throw new IllegalArgumentException ("Input buiten bereik 1-5"); } // enige logica hier ...}}

Hier is de voorwaarde voor de dingen doen methode stelt dat de num parameterwaarde moet tussen 1 en 5 liggen. We hebben deze voorwaarde afgedwongen met een bereikcontrole binnen de methode. Een subtype kan de voorwaarde voor een methode die het overschrijft, verzwakken (maar niet versterken). Wanneer een subtype de voorwaarde verzwakt, versoepelt het de beperkingen die worden opgelegd door de supertype-methode.

Laten we nu het dingen doen methode met een verzwakte randvoorwaarde:

public class Bar breidt Foo uit {@Override // voorwaarde: 0 <num <= 10 public void doStuff (int num) {if (num 10) {throw new IllegalArgumentException ("Input out of range 1-10"); } // enige logica hier ...}}

Hier wordt de randvoorwaarde verzwakt in het overschreven dingen doen methode om 0 <aantal <= 10, waardoor een breder scala aan waarden mogelijk is voor num. Alle waarden van num die geldig zijn voor Foo.doStuff zijn geldig voor Bar.doStuff ook. Bijgevolg een klant van Foo.doStuff merkt geen verschil wanneer het vervangt Foo met Bar.

Omgekeerd, wanneer een subtype de voorwaarde versterkt (bijv. 0 <aantal <= 3 in ons voorbeeld), het past strengere beperkingen toe dan het supertype. Bijvoorbeeld waarden 4 en 5 voor num zijn geldig voor Foo.doStuff, maar zijn niet langer geldig voor Bar.doStuff.

Dit zou de clientcode breken die deze nieuwe strengere beperking niet verwacht.

6.7. Methodenregel - Postcondities

Een postcondition is een voorwaarde waaraan moet worden voldaan nadat een methode is uitgevoerd.

Laten we naar een voorbeeld kijken:

openbare abstracte klasse Auto {beschermde int snelheid; // postcondition: snelheid moet beschermde abstracte lege rem () verminderen; // Andere methodes... } 

Hier de rem methode van Auto specificeert een postconditie dat de Auto‘S snelheid moet aan het einde van de methode-uitvoering verminderen. Het subtype kan de postconditie versterken (maar niet verzwakken) voor een methode die het overschrijft. Wanneer een subtype de postconditie versterkt, biedt het meer dan de supertype-methode.

Laten we nu een afgeleide klasse definiëren van Auto dat deze randvoorwaarde versterkt:

public class HybridCar breidt Car uit {// Sommige eigenschappen en andere methoden [email protected] // postcondition: snelheid moet verminderen // postcondition: lading moet beschermde lege rem verhogen () {// HybridCar-rem toepassen}}

Het overschreven rem methode in Hybride auto versterkt de postconditie door er bovendien voor te zorgen dat de in rekening brengen wordt ook verhoogd. Bijgevolg is elke clientcode die vertrouwt op de postconditie van het rem methode in de Auto class merkt geen verschil wanneer het vervangt Hybride auto voor Auto.

Omgekeerd, als Hybride auto zouden de postconditie van het opgeheven verzwakken rem methode, zou het niet langer garanderen dat de snelheid zou worden verminderd. Dit kan de clientcode breken, gegeven een Hybride auto als vervanging voor Auto.

7. Code geuren

Hoe kunnen we een subtype herkennen dat niet substitueerbaar is voor zijn supertype in de echte wereld?

Laten we eens kijken naar enkele veelvoorkomende codegeuren die tekenen zijn van een schending van het Liskov-vervangingsprincipe.

7.1. Een subtype vormt een uitzondering voor een gedrag dat het niet kan vervullen

Een voorbeeld hiervan hebben we eerder gezien in ons voorbeeld van een bankapplicatie.

Voorafgaand aan de refactoring, de Account klasse had een extra methode terugtrekken dat zijn subklasse FixedTermDepositAccount wilde niet. De FixedTermDepositAccount klasse werkte hier omheen door de UnsupportedOperationException voor de terugtrekken methode. Dit was echter slechts een hack om een ​​zwak punt in de modellering van de overervingshiërarchie te verhullen.

7.2. Een subtype biedt geen implementatie voor een gedrag dat het niet kan vervullen

Dit is een variatie op de bovenstaande codegeur. Het subtype kan een bepaald gedrag niet vervullen en doet dus niets in de overschreven methode.

Hier is een voorbeeld. Laten we een Bestandssysteem koppel:

openbare interface FileSystem {File [] listFiles (String path); void deleteFile (String path) gooit IOException; } 

Laten we een Alleen-lezen bestandssysteem dat implementeert Bestandssysteem:

openbare klasse ReadOnlyFileSystem implementeert FileSystem {openbaar bestand [] listFiles (tekenreekspad) {// code om bestanden weer te geven als resultaat nieuw bestand [0]; } public void deleteFile (String path) genereert IOException {// Niets doen. // deleteFile-bewerking wordt niet ondersteund op een alleen-lezen bestandssysteem}}

Hier de Alleen-lezen bestandssysteem ondersteunt niet de Verwijder bestand operatie en biedt dus geen implementatie.

7.3. De klant kent subtypen

Als de clientcode moet worden gebruikt instantie van of downcasting, dan is de kans groot dat zowel het Open / Gesloten principe als het Liskov-vervangingsprincipe zijn geschonden.

Laten we dit illustreren met een FilePurgingJob:

openbare klasse FilePurgingJob {privé FileSystem fileSystem; openbare FilePurgingJob (FileSystem fileSystem) {this.fileSystem = fileSystem; } public void purgeOldestFile (String path) {if (! (fileSystem instanceof ReadOnlyFileSystem)) {// code om het oudste bestand fileSystem.deleteFile (pad) te detecteren; }}}

Omdat de Bestandssysteem model is fundamenteel incompatibel met alleen-lezen bestandssystemen, het Alleen-lezen bestandssysteem erft een Verwijder bestand methode die het niet kan ondersteunen. Deze voorbeeldcode gebruikt een instantie van vink aan om speciaal werk te doen op basis van een subtype-implementatie.

7.4. Een subtype-methode retourneert altijd dezelfde waarde

Dit is een veel subtielere overtreding dan de andere en is moeilijker te herkennen. In dit voorbeeld Speelgoedauto retourneert altijd een vaste waarde voor de overgebleven Brandstof eigendom:

openbare klasse ToyCar breidt Car {@Override protected int getRemainingFuel () {return 0; }} 

Het hangt af van de interface en wat de waarde betekent, maar over het algemeen is het hardcoderen van wat een veranderlijke toestandswaarde van een object zou moeten zijn een teken dat de subklasse niet het hele supertype vervult en niet echt substitueerbaar is.

8. Conclusie

In dit artikel hebben we gekeken naar het Liskov Substitution SOLID-ontwerpprincipe.

Het Liskov-substitutieprincipe helpt ons bij het modelleren van goede overervingshiërarchieën. Het helpt ons modelhiërarchieën te voorkomen die niet voldoen aan het Open / Gesloten-principe.

Elk overervingsmodel dat zich aan het Liskov-vervangingsprincipe houdt, volgt impliciet het Open / Gesloten-principe.

Om te beginnen hebben we gekeken naar een use-case die probeert het Open / Closed-principe te volgen, maar in strijd is met het Liskov-vervangingsprincipe. Vervolgens hebben we gekeken naar de definitie van het Liskov-substitutieprincipe, het begrip gedragssubtypering en de regels die subtypes moeten volgen.

Ten slotte hebben we gekeken naar enkele veelvoorkomende codegeuren die ons kunnen helpen overtredingen in onze bestaande code op te sporen.

Zoals altijd is de voorbeeldcode uit dit artikel beschikbaar op GitHub.