DEV Community

loading...

SOLID: Liskov Substitution Principle

victorpinzon1988eng profile image Victor Manuel Pinzon Updated on ・7 min read

This is a continuation of the SOLID principles series.

The Liskov substitution principle is the most technical principle of all. However, it is the one that most helps to develop decoupled applications, which is the foundation of designing reusable components.

Barbara Liskov defined this principle as follows:

Let Φ(x) be a property provable about objects x of type T. Then Φ(y) should be true for objects y of type S where S is a subtype of T”

The definition given by Liskov is based on the Design by Contract (DbC) defined by Bertrand Meyer. A contract that is identified by preconditions, invariants, and postconditions:

  • A routine can expect a certain condition to be guaranteed on entry by any client module that calls it: the routine’s precondition. This is an obligation for the client and benefit for the supplier, as it frees it from having to handle cases outside of the precondition.
  • A routine can guarantee a certain property on exit: the routine’s postcondition - an obligation for the supplier, and a benefit for the client.
  • Maintain a certain property, assumed on entry and guaranteed on exit: the class invariant.

The concept of contract and implementation is the foundation for inheritance and polymorphism in object-oriented programming.

In 1996, Robert C. Martin redefined the concept given by Liskov, as follows:

Function that use pointers of references to base classes must be able to use objects of derived classes without knowing it.

The redefinition given by Bob Martin helped to simplify the concept implemented by Liskov years before and its adoption by developers.

Violation of the Liskov Substitution Principle

As a developer for a banking entity, you are requested to implement a system for managing bank accounts. Your boss asks you to implement, in the first sprint of the project, a system for managing basic and premium bank accounts. The difference between them is that the latter accumulates preference points on any deposit.

You implement the following abstract class as the foundation of your system.

public abstract class BankAccount {

    /**
     * In charge of depositing a specific amount into the account.
     * @param amount            Dollar ammount.
     */
    public abstract void deposit(double amount);

    /**
     * In charge of withdrawing a specific amount from the account.
     * @param amount            Dollar amount.
     * @return                  Boolean result.
     */
    public abstract boolean withdraw(double amount);
}
Enter fullscreen mode Exit fullscreen mode

This abstract class defines an obligation for any derived class to override any abstract method defined in the BankAccount class. This means that the basic and premium accounts must override the deposit and withdrawal method.

public class BasicAccount extends BankAccount {

    private double balance;

    @Override
    public void deposit(double amount) {
        this.balance += amount;
    }

    @Override
    public boolean withdraw(double amount) {
        if(this.balance < amount)
            return false;
        else{
            this.balance -= amount;
            return true;
        }       
    }
}
Enter fullscreen mode Exit fullscreen mode
public class PremiumAccount extends BankAccount {

    private double balance;
    private int preferencePoints;

    @Override
    public void deposit(double amount) {
        this.balance += amount;
        accumulatePreferencePoints();
    }

    @Override
    public boolean withdraw(double amount) {
         if(this.balance < amount)
            return false;
        else{
            this.balance -= amount;
            accumulatePreferencePoints();
            return true;
        }
    }

    private void accumulatePreferencePoints(){
        this.preferencePoints++;
    }

}
Enter fullscreen mode Exit fullscreen mode

Please take into account that any of these classes have the minimum validations for a production environment.

All basic and premium accounts are discounted by $25.00 annually for administrative expenses. To implement this policy you defined the following class:

public class WithdrawalService {

    public static final double ADMINISTRATIVE_EXPENSES_CHARGE = 25.00;

    public void cargarDebitarCuentas(){

        BankAccount basiAcct = new BasicAccount();
        basiAcct.deposit(100.00);

        BankAccount premiumAcct = new PremiumAccount();
        premiumAcct.deposit(200.00);

        List<BankAccount> accounts = new ArrayList();

        accounts.add(basiAcct);
        accounts.add(premiumAcct);

        debitAdministrativeExpenses(accounts);

    }

    private void debitAdministrativeExpenses(List<BankAccount> accounts){
        accounts.stream()
                .forEach(account -> account.withdraw(WithdrawalService.ADMINISTRATIVE_EXPENSES_CHARGE));
    }
}
Enter fullscreen mode Exit fullscreen mode

On the second sprint of your project, your boss asks you to implement long-term accounts into your bank account managing system. The differences between long-term accounts and basic/premium accounts are the following:

  • Long-term accounts are exempt from administrative expenses.
  • Long-term accounts don't allow withdrawals. If the client wants to withdraw any amount of his / her account must be done through a different process.

As a developer in charge of the accounts system, you decide to extend the BankAccount class for the Long-term accounts.

public class LongTermAccount extends BankAccount {

    private double balance;

    @Override
    public void deposit(double amount) {
        this.balance += amount;
    }

    @Override
    public boolean withdraw(double amount) {
        throw new UnsupportedOperationException("Not supported yet."); 
    }
}
Enter fullscreen mode Exit fullscreen mode

This part is where the violation of the Liskov Substitution Principle is obvious. You cannot extend the BankAccount class in the LongTermAccount without overriding the withdrawal method. However, the long-term accounts don’t allow withdrawals according to your project’s requirements.

You have the following two options to solve this issue:

  • You can override the withdrawal method as an empty method or you can throw an UnsupportedOperationException. However, the BankAccount objects wouldn't be completely interchangeable with LongTermAccount objects because if we try to execute the withdrawal method we would get an exception. As a solution for this issue, we can condition the debitAdministrativeExpenses method, so we can skip the LongTermAccount objects but this would violate the Open/Closed Principle. For instance:
private void debitAdministrativeExpenses(List<BankAccount> accounts){

        for(BankAccount account : accounts){
            if(account instanceof LongTermAccount)
                continue;
            else
                account.withdraw(ADMINISTRATIVE_EXPENSES_CHARGE);
        }
    }
Enter fullscreen mode Exit fullscreen mode
  • You can make your code Liskov Substitution Principle compliant.

Implementing Liskov Substitution Principle

The main issue with the bank account structure is that the long-term account is not a regular bank account, at least is not the type defined in the BankAccount abstract class. There is a simple test on the abductive reasoning area that can be used to check if a class is a subtype from “X” type. The duck test states “If it looks like a duck, swims like a duck, and quacks like a duck, then it probably is a duck”. The long-term account looks like a regular bank account but it does not behave like a regular one. To solve this issue we have to change the current class structure.

To make our code LSP compliant, we’ll make the following changes:

  • All types of bank accounts will allow the deposit action.
  • Only the basic and premium bank accounts will allow the withdrawal action.
  • We’ll define an abstract bank account for all types of accounts. This abstract class will define only one method, the deposit method.
  • We’ll extend the BankAccount with WithdrawableAccount abstract class, which will define the debit method.
  • The basic and premium accounts will extend the WithdrawableAccount abstract class, while the long-term account will extend the BankAccount abstract class.

The abstract BankAccount class will define the deposit method.

public abstract class BankAccount {

    /**
     * In charge of depositing a specific amount into the account.
     * @param amount            Dollar ammount.
     */
    public abstract void deposit(double amount);
}
Enter fullscreen mode Exit fullscreen mode

The abstract WithdrawableAccount class will define the withdrawal method.

public abstract class WithdrawableAccount extends BankAccount {

    /**
     * In charge of withdrawing a specific amount from the account.
     * @param amount            Dollar amount.
     * @return                  Boolean result.
     */
    public abstract boolean withdraw(double amount);
}
Enter fullscreen mode Exit fullscreen mode

The basic and premium account classes will extend the WithdrawableAccount class, which extends the BankAccount class. This nested inheritance allows the basic/premium accounts to have both methods, deposit, and withdrawal.

public class BasicAccount extends WithdrawableAccount {

    private double balance;

    @Override
    public void deposit(double amount) {
        this.balance += amount;
    }

    @Override
    public boolean withdraw(double monto) {
        if(this.balance < monto)
            return false;
        else{
            this.balance -= monto;
            return true;
        }       
    }   
}
Enter fullscreen mode Exit fullscreen mode
public class PremiumAccount extends WithdrawableAccount {

    private double balance;
    private int preferencePoints;

    @Override
    public void deposit(double monto) {
        this.balance += monto;
        accumulatePreferencePoints();
    }

    @Override
    public boolean withdraw(double monto) {
         if(this.balance < monto)
            return false;
        else{
            this.balance -= monto;
            accumulatePreferencePoints();
            return true;
        }
    }

    private void accumulatePreferencePoints(){
        this.preferencePoints++;
    }
}
Enter fullscreen mode Exit fullscreen mode

The WithdrawalService class is implemented using only WithdrawableAccount types or subtypes.

public class WithdrawableService {

    public static final double ADMINISTRATIVE_EXPENSES_CHARGE = 25.00;

    public void cargarDebitarCuentas(){

        WithdrawableAccount basicAcct = new BasicAccount();
        basicAcct.deposit(100.00);

        WithdrawableAccount premiumAcct = new PremiumAccount();
        premiumAcct.deposit(200.00);

        List<WithdrawableAccount> accounts = new ArrayList();

        accounts.add(basicAcct);
        accounts.add(premiumAcct);

        debitarGastosAdmon(accounts);

    }

    private void debitarGastosAdmon(List<WithdrawableAccount> accounts){
        accounts.stream()
                .forEach(account -> account.withdraw(WithdrawableService.ADMINISTRATIVE_EXPENSES_CHARGE));
    }
}
Enter fullscreen mode Exit fullscreen mode

The changes done on the class structure assures that our code is LSP compliant. Now we are not required to implement the withdrawal method on the LongTermAccount class. Also, we interchange the WithdrawableAccount objects with any subtype of this abstract class. The class structure is also OCP compliant because if we added another bank account type that does not allow withdrawal, we wouldn't need to modify the current code just to extend the BankAccount type.

Importance of the Liskov Substitution Principle

The LSP allows us to identify incorrect generalization areas done during the design phase and correct them. The Liskov Substitution Principle is fundamental in the development of the dependency injection concept, which is widely used in Java Enterprise Edition and Spring Framework.

You can use the following tips if you want to easily detect violations of the Liskov Substitution Principle:

There is an LSP violation if you introduce a condition using the type of your object, as shown above in the instanceof example.
There is an LSP violation if you extend an abstract class and you set one of the abstract methods as an empty method or you throw a not defined exception.

Why not using interfaces instead of abstract classes

Interfaces and abstract classes are very similar, but they are not the same. Abstract classes define functionality that subclasses must implement. On the other hand, interfaces define functionality that must be implemented by any class that implements the given interface.

For instance, abstract classes define that a pony gallops because it is a type of horse. In contrast, an interface defines that a "thing" can "move" because there is an agreement, by implementing the interface, that the thing must move.

So this raises two questions:

  • Could we implement the bank account example using interfaces? Yes, Sure. There is no wrong answer here. You could implement the example by using either abstract classes or interfaces.

  • Would I still violate LSP by using interfaces?
    Well, there is no correct answer here. LSP was defined in the context of subtyping. However, I think, this is not valid anymore. LSP is not about abstract classes or interfaces, is all about honoring the contract.

If you like to read more about LSP, you can have a look at Uncle Bob’s Blog.

In the next post, we will talk about the Interface Segregation Principle.

You can follow me on Twitter or Linkedin.

Discussion (9)

Collapse
qm3ster profile image
Mihail Malo

These abstract classes sure sound like traits/interfaces.

Collapse
victorpinzon1988eng profile image
Victor Manuel Pinzon Author

LSP was originally defined under the context of subtyping, but sure, it can also be applied under the context of traits/interfaces.

Collapse
qm3ster profile image
Mihail Malo

I just don't understand what we achieve by enforcing the WithdrawableAccount extends BankAccount relationship. Seems like two independent traits would be fine here?

const ADMINISTRATIVE_EXPENSES: f64 = 25.0;
fn main() {
    let mut basic = BasicAccount::default();
    let mut premium = PremiumAccount::default();
    for (amt, acc) in (1..)
        .map(|x| x as f64 * 100.0)
        .zip([&mut basic as &mut dyn Deposit, &mut premium])
    {
        acc.deposit(amt);
    }
    for acc in [&mut basic as &mut dyn Withdraw, &mut premium] {
        acc.withdraw(ADMINISTRATIVE_EXPENSES);
    }
    println!("{:#?}", [&basic as &dyn core::fmt::Debug, &premium]);
}
trait Deposit {
    fn deposit(&mut self, amount: f64);
}
trait Withdraw {
    fn withdraw(&mut self, amount: f64) -> bool;
}
#[derive(Debug, Default)]
struct BasicAccount {
    balance: f64,
}
impl Deposit for BasicAccount {
    fn deposit(&mut self, amount: f64) {
        self.balance += amount;
    }
}
impl Withdraw for BasicAccount {
    fn withdraw(&mut self, amount: f64) -> bool {
        if self.balance < amount {
            return false;
        }
        self.balance -= amount;
        true
    }
}
#[derive(Debug, Default)]
struct PremiumAccount {
    balance: f64,
    preference_points: u32,
}
impl PremiumAccount {
    fn accumulate_preference_points(&mut self) {
        self.preference_points += 1;
    }
}
impl Deposit for PremiumAccount {
    fn deposit(&mut self, amount: f64) {
        self.balance += amount;
        self.accumulate_preference_points();
    }
}
impl Withdraw for PremiumAccount {
    fn withdraw(&mut self, amount: f64) -> bool {
        if self.balance < amount {
            return false;
        }
        self.balance -= amount;
        self.accumulate_preference_points();
        true
    }
}
Enter fullscreen mode Exit fullscreen mode

Look at it go!

Thread Thread
victorpinzon1988eng profile image
Victor Manuel Pinzon Author • Edited

Yes, it'd work but your code wouldn't be enforcing a bank account behavior. Remember, SOLID principles help to make OOP easier and one of the basic principles of OOP is to model real-world objects into programming objects. On the other hand, LSP helps you to model behaviors instead of just properties by using subtyping, is-a relationship between objects. What you achieve by setting with "WithdrawableAccount extends BankAccount" is to ensure that "WithdrawableAccount" extends whatever the behavior defined in the BankAccount class.

In your example, though I'm not very familiar with Rust lang, you defined independent traits which you implement as you need in your different structures but your code is not enforcing any relationship between bank account types . Suppose that BankAccount abstract class declares ten more different behaviors and not just deposit, same with WithdrawableAccount. Now, you need to implement a different type of bank account, if you wanted to have the behavior defined in the previous classes you would need to declare all the independent traits in your new type of class. What would happen if you forget to implement one of those traits? What would happen if a workmate is doing the implementation instead of you, would he/she know which traits to implement? This happens because there is no relationship enforcing in your code. On the other hand, in my example, by just extending the "WithdrawableAccount" type, I'm making sure that my new code extends whatever the"WithdrawableAccount" behavior and whatever the "BankAccount" behaviuour, and this is achieved because there is a hierarchical "is-a" relationship.

hierarchical "is-a" relationship

Hope this helps you.

Thread Thread
qm3ster profile image
Mihail Malo

But what if you want to have an account type where you can withdraw() but not deposit()?
In the article, you break out the Withdrawable behavior from BankAccount.
But I don't see how deposit() is the more fundamental method than withdraw(), so I broke out both.
Perhaps in a real architecture, you'd always have both methods, and they would be fallible.

trait Account {
    fn deposit(&mut self, amount: Money) -> Result<(), DepositError> {
        Err(DepositError::OperationUnsupported)
    }
    fn withdraw(&mut self, amount: Money) -> Result<(), WithdrawError> {
        Err(WithdrawError::OperationUnsupported)
    }
}
Enter fullscreen mode Exit fullscreen mode

(You would probably forego default implementation, to ensure implementers consider implementing both methods, but this is approximately what the implementation would be if the operation is not supported on a particular account type)
I understand you could do a bunch of runtime reflection in Java, but in the article you only did the negative check instanceof LongTermAccount to skip some, not check if withdrawals are positively supported.

However if it was the intention that LongTermAccounts don't make it into the list at all, the separate trait solution works.

Thread Thread
victorpinzon1988eng profile image
Victor Manuel Pinzon Author

But what if you want to have an account type where you can withdraw() but not deposit()?

I don't know of any type of bank account that allows withdrawals but no deposits. That's why in the post's example I designed the classes with the deposit method as the fundamental method in all bank account. Now, let's suppose that some crazy dude from product development department asks you to implement a bank account type that allows only withdrawals (even when I don't see any business case there), in that case, yes it is better to have separate interfaces and that separation is pretty much the "interface segregation principle" which is also part of the SOLID principles.

I understand you could do a bunch of runtime reflection in Java, but in the article you only did the negative check instanceof LongTermAccount to skip some, not check if withdrawals are positively supported.

Yes, you're right, I could've used reflection in order to validate if the class supported the withdraw method. But remember, reflection is mostly used when we want to inspect a class during runtime and we don't know the class specification or we don't have access to the code. My question is, why would you want to enforce the "withdrawal" validation during runtime using reflection, when you have full access to the code and you could easily enforce that during compilation time by changing the design of your classes?

Remember, SOLID principles are just guidelines not rules. I'm not saying that your independent trait solution is wrong, I'm just saying that is not LSP compliant.

Collapse
9uilherme profile image
Guilherme

Great tips

Some comments have been hidden by the post's author - find out more

Forem Open with the Forem app