Liskov-Substitutionsprinzip in Java

1. Übersicht

Die SOLID-Designprinzipien wurden von Robert C. Martin in seinem 2000 erschienenen Artikel Design Principles and Design Patterns vorgestellt . SOLID-Designprinzipien helfen uns , wartbarere, verständlichere und flexiblere Software zu erstellen.

In diesem Artikel werden wir das Liskov-Substitutionsprinzip diskutieren, das das „L“ im Akronym ist.

2. Das offene / geschlossene Prinzip

Um das Liskov-Substitutionsprinzip zu verstehen, müssen wir zuerst das offene / geschlossene Prinzip (das „O“ von SOLID) verstehen.

Das Ziel des Open / Closed-Prinzips ermutigt uns, unsere Software so zu gestalten, dass wir neue Funktionen nur durch Hinzufügen von neuem Code hinzufügen . Wenn dies möglich ist, haben wir lose gekoppelte und damit leicht zu wartende Anwendungen.

3. Ein beispielhafter Anwendungsfall

Schauen wir uns ein Beispiel für eine Bankanwendung an, um das Open / Closed-Prinzip besser zu verstehen.

3.1. Ohne das offene / geschlossene Prinzip

Unsere Bankanwendung unterstützt zwei Kontotypen - "aktuell" und "Ersparnisse". Diese werden durch die Klassen CurrentAccount und SavingsAccount dargestellt .

Der BankingAppWithdrawalService bietet seinen Benutzern die Auszahlungsfunktion:

Leider gibt es ein Problem bei der Erweiterung dieses Designs. Der BankingAppWithdrawalService kennt die beiden konkreten Implementierungen des Kontos . Daher müsste der BankingAppWithdrawalService jedes Mal geändert werden, wenn ein neuer Kontotyp eingeführt wird.

3.2. Verwenden des Open / Closed-Prinzips, um den Code erweiterbar zu machen

Lassen Sie uns die Lösung so umgestalten, dass sie dem Open / Closed-Prinzip entspricht. Wir schließen BankingAppWithdrawalService vor Änderungen, wenn neue Kontotypen benötigt werden, indem wir stattdessen eine Konto- Basisklasse verwenden:

Hier haben wir eine neue abstrakte Account- Klasse eingeführt, die CurrentAccount und SavingsAccount erweitern.

Der BankingAppWithdrawalService hängt nicht mehr von konkreten Kontoklassen ab. Da es jetzt nur noch von der abstrakten Klasse abhängt, muss es nicht geändert werden, wenn ein neuer Kontotyp eingeführt wird.

Folglich ist die BankingAppWithdrawalService ist offen für die Erweiterung mit neuen Kontoarten, aber für die Änderung geschlossen , dass die neuen Typen erfordern es nicht zu ändern , um zu integrieren.

3.3. Java Code

Schauen wir uns dieses Beispiel in Java an. Definieren wir zunächst die Account- Klasse:

public abstract class Account { protected abstract void deposit(BigDecimal amount); /** * Reduces the balance of the account by the specified amount * provided given amount > 0 and account meets minimum available * balance criteria. * * @param amount */ protected abstract void withdraw(BigDecimal amount); } 

Und definieren wir den BankingAppWithdrawalService :

public class BankingAppWithdrawalService { private Account account; public BankingAppWithdrawalService(Account account) { this.account = account; } public void withdraw(BigDecimal amount) { account.withdraw(amount); } }

Schauen wir uns nun an, wie in diesem Entwurf ein neuer Kontotyp gegen das Liskov-Substitutionsprinzip verstoßen kann.

3.4. Ein neuer Kontotyp

Die Bank möchte ihren Kunden nun ein hochverzinsliches Festgeldkonto anbieten.

Um dies zu unterstützen, führen wir eine neue FixedTermDepositAccount- Klasse ein. Ein Festgeldkonto in der realen Welt ist eine Art Konto. Dies impliziert Vererbung in unserem objektorientierten Design.

Machen wir FixedTermDepositAccount zu einer Unterklasse von Account :

public class FixedTermDepositAccount extends Account { // Overridden methods... }

So weit, ist es gut. Die Bank möchte jedoch keine Abhebungen für die Festgeldkonten zulassen.

Dies bedeutet , dass die neue FixedTermDepositAccount Klasse nicht sinnvoll , die bietet entziehen Methode , das Konto definiert. Eine häufige Problemumgehung besteht darin, FixedTermDepositAccount eine UnsupportedOperationException in der Methode auslösen zu lassen, die nicht erfüllt werden kann:

public class FixedTermDepositAccount extends Account { @Override protected void deposit(BigDecimal amount) { // Deposit into this account } @Override protected void withdraw(BigDecimal amount) { throw new UnsupportedOperationException("Withdrawals are not supported by FixedTermDepositAccount!!"); } }

3.5. Testen mit dem neuen Kontotyp

Während die neue Klasse einwandfrei funktioniert, versuchen wir, sie mit dem BankingAppWithdrawalService zu verwenden :

Account myFixedTermDepositAccount = new FixedTermDepositAccount(); myFixedTermDepositAccount.deposit(new BigDecimal(1000.00)); BankingAppWithdrawalService withdrawalService = new BankingAppWithdrawalService(myFixedTermDepositAccount); withdrawalService.withdraw(new BigDecimal(100.00));

Es überrascht nicht, dass die Bankanwendung mit dem Fehler abstürzt:

Withdrawals are not supported by FixedTermDepositAccount!!

Bei diesem Entwurf stimmt eindeutig etwas nicht, wenn eine gültige Kombination von Objekten zu einem Fehler führt.

3.6. Was schief gelaufen ist?

Der BankingAppWithdrawalService ist ein Client der Account- Klasse. Es wird erwartet, dass sowohl das Konto als auch seine Untertypen das Verhalten garantieren, das die Kontoklasse für ihre Auszahlungsmethode angegeben hat :

/** * Reduces the account balance by the specified amount * provided given amount > 0 and account meets minimum available * balance criteria. * * @param amount */ protected abstract void withdraw(BigDecimal amount);

Da die Methode " Zurückziehen" jedoch nicht unterstützt wird, verstößt FixedTermDepositAccount gegen diese Methodenspezifikation . Deshalb können wir nicht zuverlässig ersetzen FixedTermDepositAccount für Konto .

Mit anderen Worten, das FixedTermDepositAccount hat das Liskov-Substitutionsprinzip verletzt.

3.7. Können wir den Fehler in BankingAppWithdrawalService nicht behandeln ?

We could amend the design so that the client of Account‘s withdraw method has to be aware of a possible error in calling it. However, this would mean that clients have to have special knowledge of unexpected subtype behavior. This starts to break the Open/Closed principle.

In other words, for the Open/Closed Principle to work well, all subtypes must be substitutable for their supertype without ever having to modify the client code. Adhering to the Liskov Substitution Principle ensures this substitutability.

Let's now look at the Liskov Substitution Principle in detail.

4. The Liskov Substitution Principle

4.1. Definition

Robert C. Martin summarizes it:

Subtypes must be substitutable for their base types.

Barbara Liskov, defining it in 1988, provided a more mathematical definition:

If for each object o1 of type S there is an object o2 of type T such that for all programs P defined in terms of T, the behavior of P is unchanged when o1 is substituted for o2 then S is a subtype of T.

Let's understand these definitions a bit more.

4.2. When Is a Subtype Substitutable for Its Supertype?

A subtype doesn't automatically become substitutable for its supertype. To be substitutable, the subtype must behave like its supertype.

An object's behavior is the contract that its clients can rely on. The behavior is specified by the public methods, any constraints placed on their inputs, any state changes that the object goes through, and the side effects from the execution of methods.

Subtyping in Java requires the base class's properties and methods are available in the subclass.

However, behavioral subtyping means that not only does a subtype provide all of the methods in the supertype, but it must adhere to the behavioral specification of the supertype. This ensures that any assumptions made by the clients about the supertype behavior are met by the subtype.

This is the additional constraint that the Liskov Substitution Principle brings to object-oriented design.

Let's now refactor our banking application to address the problems we encountered earlier.

5. Refactoring

To fix the problems we found in the banking example, let's start by understanding the root cause.

5.1. The Root Cause

In the example, our FixedTermDepositAccount was not a behavioral subtype of Account.

The design of Account incorrectly assumed that all Account types allow withdrawals. Consequently, all subtypes of Account, including FixedTermDepositAccount which doesn't support withdrawals, inherited the withdraw method.

Though we could work around this by extending the contract of Account, there are alternative solutions.

5.2. Revised Class Diagram

Let's design our account hierarchy differently:

Because all accounts do not support withdrawals, we moved the withdraw method from the Account class to a new abstract subclass WithdrawableAccount. Both CurrentAccount and SavingsAccount allow withdrawals. So they've now been made subclasses of the new WithdrawableAccount.

This means BankingAppWithdrawalService can trust the right type of account to provide the withdraw function.

5.3. Refactored BankingAppWithdrawalService

BankingAppWithdrawalService now needs to use the WithdrawableAccount:

public class BankingAppWithdrawalService { private WithdrawableAccount withdrawableAccount; public BankingAppWithdrawalService(WithdrawableAccount withdrawableAccount) { this.withdrawableAccount = withdrawableAccount; } public void withdraw(BigDecimal amount) { withdrawableAccount.withdraw(amount); } }

As for FixedTermDepositAccount, we retain Account as its parent class. Consequently, it inherits only the deposit behavior that it can reliably fulfill and no longer inherits the withdraw method that it doesn't want. This new design avoids the issues we saw earlier.

6. Rules

Let's now look at some rules/techniques concerning method signatures, invariants, preconditions, and postconditions that we can follow and use to ensure we create well-behaved subtypes.

In their book Program Development in Java: Abstraction, Specification, and Object-Oriented Design, Barbara Liskov and John Guttag grouped these rules into three categories – the signature rule, the properties rule, and the methods rule.

Some of these practices are already enforced by Java's overriding rules.

We should note some terminology here. A wide type is more general – Object for instance could mean ANY Java object and is wider than, say, CharSequence, where String is very specific and therefore narrower.

6.1. Signature Rule – Method Argument Types

This rule states that the overridden subtype method argument types can be identical or wider than the supertype method argument types.

Java's method overriding rules support this rule by enforcing that the overridden method argument types match exactly with the supertype method.

6.2. Signature Rule – Return Types

The return type of the overridden subtype method can be narrower than the return type of the supertype method. This is called covariance of the return types. Covariance indicates when a subtype is accepted in place of a supertype. Java supports the covariance of return types. Let's look at an example:

public abstract class Foo { public abstract Number generateNumber(); // Other Methods } 

The generateNumber method in Foo has return type as Number. Let's now override this method by returning a narrower type of Integer:

public class Bar extends Foo { @Override public Integer generateNumber() { return new Integer(10); } // Other Methods }

Because Integer IS-A Number, a client code that expects Number can replace Foo with Bar without any problems.

On the other hand, if the overridden method in Bar were to return a wider type than Number, e.g. Object, that might include any subtype of Object e.g. a Truck. Any client code that relied on the return type of Number could not handle a Truck!

Fortunately, Java's method overriding rules prevent an override method returning a wider type.

6.3. Signature Rule – Exceptions

The subtype method can throw fewer or narrower (but not any additional or broader) exceptions than the supertype method.

This is understandable because when the client code substitutes a subtype, it can handle the method throwing fewer exceptions than the supertype method. However, if the subtype's method throws new or broader checked exceptions, it would break the client code.

Java's method overriding rules already enforce this rule for checked exceptions. However, overriding methods in Java CAN THROW any RuntimeException regardless of whether the overridden method declares the exception.

6.4. Properties Rule – Class Invariants

A class invariant is an assertion concerning object properties that must be true for all valid states of the object.

Let's look at an example:

public abstract class Car { protected int limit; // invariant: speed < limit; protected int speed; // postcondition: speed < limit protected abstract void accelerate(); // Other methods... }

The Car class specifies a class invariant that speed must always be below the limit. The invariants rule states that all subtype methods (inherited and new) must maintain or strengthen the supertype's class invariants.

Let's define a subclass of Car that preserves the class invariant:

public class HybridCar extends Car { // invariant: charge >= 0; private int charge; @Override // postcondition: speed < limit protected void accelerate() { // Accelerate HybridCar ensuring speed < limit } // Other methods... }

In this example, the invariant in Car is preserved by the overridden accelerate method in HybridCar. The HybridCar additionally defines its own class invariant charge >= 0, and this is perfectly fine.

Conversely, if the class invariant is not preserved by the subtype, it breaks any client code that relies on the supertype.

6.5. Properties Rule – History Constraint

The history constraint states that the subclassmethods (inherited or new) shouldn't allow state changes that the base class didn't allow.

Let's look at an example:

public abstract class Car { // Allowed to be set once at the time of creation. // Value can only increment thereafter. // Value cannot be reset. protected int mileage; public Car(int mileage) { this.mileage = mileage; } // Other properties and methods... }

The Car class specifies a constraint on the mileage property. The mileage property can be set only once at the time of creation and cannot be reset thereafter.

Let's now define a ToyCar that extends Car:

public class ToyCar extends Car { public void reset() { mileage = 0; } // Other properties and methods }

The ToyCar has an extra method reset that resets the mileage property. In doing so, the ToyCar ignored the constraint imposed by its parent on the mileage property. This breaks any client code that relies on the constraint. So, ToyCar isn't substitutable for Car.

Similarly, if the base class has an immutable property, the subclass should not permit this property to be modified. This is why immutable classes should be final.

6.6. Methods Rule – Preconditions

A precondition should be satisfied before a method can be executed. Let's look at an example of a precondition concerning parameter values:

public class Foo { // precondition: 0 < num <= 5 public void doStuff(int num) { if (num  5) { throw new IllegalArgumentException("Input out of range 1-5"); } // some logic here... } }

Here, the precondition for the doStuff method states that the num parameter value must be between 1 and 5. We have enforced this precondition with a range check inside the method. A subtype can weaken (but not strengthen) the precondition for a method it overrides. When a subtype weakens the precondition, it relaxes the constraints imposed by the supertype method.

Let's now override the doStuff method with a weakened precondition:

public class Bar extends Foo { @Override // precondition: 0 < num <= 10 public void doStuff(int num) { if (num  10) { throw new IllegalArgumentException("Input out of range 1-10"); } // some logic here... } }

Here, the precondition is weakened in the overridden doStuff method to 0 < num <= 10, allowing a wider range of values for num. All values of num that are valid for Foo.doStuff are valid for Bar.doStuff as well. Consequently, a client of Foo.doStuff doesn't notice a difference when it replaces Foo with Bar.

Conversely, when a subtype strengthens the precondition (e.g. 0 < num <= 3 in our example), it applies more stringent restrictions than the supertype. For example, values 4 & 5 for num are valid for Foo.doStuff, but are no longer valid for Bar.doStuff.

This would break the client code that does not expect this new tighter constraint.

6.7. Methods Rule – Postconditions

A postcondition is a condition that should be met after a method is executed.

Let's look at an example:

public abstract class Car { protected int speed; // postcondition: speed must reduce protected abstract void brake(); // Other methods... } 

Here, the brake method of Car specifies a postcondition that the Car‘s speed must reduce at the end of the method execution. The subtype can strengthen (but not weaken) the postcondition for a method it overrides. When a subtype strengthens the postcondition, it provides more than the supertype method.

Now, let's define a derived class of Car that strengthens this precondition:

public class HybridCar extends Car { // Some properties and other methods... @Override // postcondition: speed must reduce // postcondition: charge must increase protected void brake() { // Apply HybridCar brake } }

The overridden brake method in HybridCar strengthens the postcondition by additionally ensuring that the charge is increased as well. Consequently, any client code relying on the postcondition of the brake method in the Car class notices no difference when it substitutes HybridCar for Car.

Conversely, if HybridCar were to weaken the postcondition of the overridden brake method, it would no longer guarantee that the speed would be reduced. This might break client code given a HybridCar as a substitute for Car.

7. Code Smells

How can we spot a subtype that is not substitutable for its supertype in the real world?

Let's look at some common code smells that are signs of a violation of the Liskov Substitution Principle.

7.1. A Subtype Throws an Exception for a Behavior It Can't Fulfill

We have seen an example of this in our banking application example earlier on.

Prior to the refactoring, the Account class had an extra method withdraw that its subclass FixedTermDepositAccount didn't want. The FixedTermDepositAccount class worked around this by throwing the UnsupportedOperationException for the withdraw method. However, this was just a hack to cover up a weakness in the modeling of the inheritance hierarchy.

7.2. A Subtype Provides No Implementation for a Behavior It Can't Fulfill

This is a variation of the above code smell. The subtype cannot fulfill a behavior and so it does nothing in the overridden method.

Here's an example. Let's define a FileSystem interface:

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

Let's define a ReadOnlyFileSystem that implements FileSystem:

public class ReadOnlyFileSystem implements FileSystem { public File[] listFiles(String path) { // code to list files return new File[0]; } public void deleteFile(String path) throws IOException { // Do nothing. // deleteFile operation is not supported on a read-only file system } }

Here, the ReadOnlyFileSystem doesn't support the deleteFile operation and so doesn't provide an implementation.

7.3. The Client Knows About Subtypes

If the client code needs to use instanceof or downcasting, then the chances are that both the Open/Closed Principle and the Liskov Substitution Principle have been violated.

Let's illustrate this using a FilePurgingJob:

public class FilePurgingJob { private FileSystem fileSystem; public FilePurgingJob(FileSystem fileSystem) { this.fileSystem = fileSystem; } public void purgeOldestFile(String path) { if (!(fileSystem instanceof ReadOnlyFileSystem)) { // code to detect oldest file fileSystem.deleteFile(path); } } }

Because the FileSystem model is fundamentally incompatible with read-only file systems, the ReadOnlyFileSystem inherits a deleteFile method it can't support. This example code uses an instanceof check to do special work based on a subtype implementation.

7.4. A Subtype Method Always Returns the Same Value

This is a far more subtle violation than the others and is harder to spot. In this example, ToyCar always returns a fixed value for the remainingFuel property:

public class ToyCar extends Car { @Override protected int getRemainingFuel() { return 0; } } 

It depends on the interface, and what the value means, but generally hardcoding what should be a changeable state value of an object is a sign that the subclass is not fulfilling the whole of its supertype and is not truly substitutable for it.

8. Conclusion

In this article, we looked at the Liskov Substitution SOLID design principle.

The Liskov Substitution Principle helps us model good inheritance hierarchies. It helps us prevent model hierarchies that don't conform to the Open/Closed principle.

Any inheritance model that adheres to the Liskov Substitution Principle will implicitly follow the Open/Closed principle.

Zunächst haben wir uns einen Anwendungsfall angesehen, der versucht, dem Open / Closed-Prinzip zu folgen, aber gegen das Liskov-Substitutionsprinzip verstößt. Als nächstes haben wir uns die Definition des Liskov-Substitutionsprinzips, den Begriff der Verhaltensuntertypisierung und die Regeln angesehen, denen Subtypen folgen müssen.

Schließlich haben wir uns einige gängige Codegerüche angesehen, mit denen wir Verstöße in unserem vorhandenen Code erkennen können.

Wie immer ist der Beispielcode aus diesem Artikel auf GitHub verfügbar.