Principio di sostituzione di Liskov in Java

1. Panoramica

I principi di progettazione SOLID sono stati introdotti da Robert C. Martin nel suo documento del 2000, Design Principles and Design Patterns . I principi di progettazione SOLIDA ci aiutano a creare software più manutenibile, comprensibile e flessibile.

In questo articolo, discuteremo il principio di sostituzione di Liskov, che è la "L" nell'acronimo.

2. Il principio aperto / chiuso

Per comprendere il principio di sostituzione di Liskov, dobbiamo prima comprendere il principio aperto / chiuso (la "O" di SOLID).

L'obiettivo del principio Aperto / Chiuso ci incoraggia a progettare il nostro software in modo da aggiungere nuove funzionalità solo aggiungendo nuovo codice . Quando ciò è possibile, abbiamo applicazioni ad accoppiamento libero e quindi facilmente manutenibili.

3. Un caso d'uso di esempio

Diamo un'occhiata a un esempio di applicazione bancaria per comprendere un po 'di più il principio aperto / chiuso.

3.1. Senza il principio aperto / chiuso

La nostra applicazione bancaria supporta due tipi di conto: "corrente" e "risparmio". Questi sono rappresentati rispettivamente dalle classi CurrentAccount e SavingsAccount .

Il BankingAppWithdrawalService offre la funzionalità di prelievo ai suoi utenti:

Sfortunatamente, c'è un problema con l'estensione di questo design. Il BankingAppWithdrawalService è a conoscenza delle due implementazioni concrete del conto . Pertanto, BankingAppWithdrawalService dovrebbe essere modificato ogni volta che viene introdotto un nuovo tipo di conto.

3.2. Utilizzo del principio aperto / chiuso per rendere estensibile il codice

Riprogettiamo la soluzione per conformarci al principio Aperto / Chiuso. Chiuderemo BankingAppWithdrawalService dalla modifica quando sono necessari nuovi tipi di account, utilizzando invece una classe base Account :

Qui abbiamo introdotto una nuova classe Account astratta che CurrentAccount e SavingsAccount estendono.

Il BankingAppWithdrawalService non dipende più da classi di conto concrete. Poiché ora dipende solo dalla classe astratta, non è necessario modificarlo quando viene introdotto un nuovo tipo di account.

Di conseguenza, il BankingAppWithdrawalService è aperto per l'estensione con nuovi tipi di conto, ma chiuso per modifiche , in quanto i nuovi tipi non ne richiedono la modifica per integrarsi.

3.3. Codice Java

Diamo un'occhiata a questo esempio in Java. Per cominciare, definiamo la classe Account :

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); } 

E definiamo il BankingAppWithdrawalService :

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

Ora, diamo un'occhiata a come, in questo progetto, un nuovo tipo di account potrebbe violare il principio di sostituzione di Liskov.

3.4. Un nuovo tipo di account

La banca ora vuole offrire ai suoi clienti un conto di deposito a tempo determinato con alti interessi.

Per supportare ciò, introduciamo una nuova classe FixedTermDepositAccount . Un conto di deposito a tempo determinato nel mondo reale "è un" tipo di conto. Ciò implica l'ereditarietà nel nostro design orientato agli oggetti.

Quindi, rendiamo FixedTermDepositAccount una sottoclasse di Account :

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

Fin qui tutto bene. Tuttavia, la banca non vuole consentire prelievi per i conti di deposito a tempo determinato.

Ciò significa che la nuova FixedTermDepositAccount classe non può fornire il significato ritirare metodo che Account definisce. Una soluzione comune per questo è di fare FixedTermDepositAccount gettare un UnsupportedOperationException nel metodo che non può soddisfare:

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. Test utilizzando il nuovo tipo di account

Anche se la nuova classe funziona bene, proviamo a usarla con BankingAppWithdrawalService :

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

Non sorprende che l'applicazione bancaria si arresti in modo anomalo con l'errore:

Withdrawals are not supported by FixedTermDepositAccount!!

C'è chiaramente qualcosa di sbagliato in questo design se una valida combinazione di oggetti risulta in un errore.

3.6. Che cosa è andato storto?

Il BankingAppWithdrawalService è un cliente del conto di classe. Si aspetta che sia Account che i suoi sottotipi garantiscano il comportamento che la classe Account ha specificato per il suo metodo di prelievo :

/** * 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);

Tuttavia, non sostenendo il recesso metodo, il FixedTermDepositAccount viola questa specifica metodo . Pertanto, non possiamo sostituire in modo affidabile FixedTermDepositAccount con Account .

In altre parole, il FixedTermDepositAccount ha violato il principio di sostituzione di Liskov.

3.7. Non possiamo gestire l'errore in BankingAppWithdrawalService ?

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.

Per cominciare, abbiamo esaminato un caso d'uso che tenta di seguire il principio di apertura / chiusura ma viola il principio di sostituzione di Liskov. Successivamente, abbiamo esaminato la definizione del principio di sostituzione di Liskov, la nozione di sottotipizzazione comportamentale e le regole che i sottotipi devono seguire.

Infine, abbiamo esaminato alcuni odori di codice comuni che possono aiutarci a rilevare le violazioni nel nostro codice esistente.

Come sempre, il codice di esempio di questo articolo è disponibile su GitHub.