top of page

Liskov Substitution Principle in C#



The Liskov substitution principle (LSP) is a collection of guidelines for creating inheritance hierarchies in which a client can reliably use any class or subclass without compromising the expected behavior. If the rules of the LSP are not followed, an extension to a class hierarchy—that is, a new subclass—might necessitate changes to any client of the base class or interface. If the LSP is followed, clients can remain unaware of changes to the class hierarchy. As long as there are no changes to the interface, there should be no reason to change any existing code. The LSP, therefore, helps to enforce both the open/closed principle and the single responsibility principle.


The definition of the LSP by prominent computer scientist Barbara Liskov is a bit dry, so it requires further explanation. Here is the official definition:


Barbara Liskov: If S is a subtype of T, then objects of type T may be replaced with objects of type S, without breaking the program.


There are three code ingredients relating to the LSP:

  1. Base type – The type (T) that clients have reference to. Clients call various methods, any of which can be overridden—or partially specialized—by the subtype.

  2. Subtype – Any one of a possible family of classes (S) that inherit from the base type (T). Clients should not know which specific subtype they are calling, nor should they need to. The client should behave the same regardless of the subtype instance that it is given.

  3. Context – The way in which the client interacts with the subtype. If the client doesn’t interact with a subtype, the LSP can neither be honored nor contravened.


LSP Rules:

There are several “rules” that must be followed for LSP compliance. These rules can be split into two categories:

  • contract rules (relating to the expectations of classes) and

  • variance rules (relating to the types that can be substituted in code).


1. contract rules

These rules relate to the contract of the supertype and the restrictions placed on the contracts that can be added to the subtype.

  • Preconditions cannot be strengthened in a subtype.

  • Postconditions cannot be weakened in a subtype.

  • Invariants — conditions that must remain true — of the supertype must be preserved in a subtype.


2. variance rules

These rules relate to the variance of arguments and return types.

  • There must be contravariance of the method arguments in the subtype.

  • There must be covariance of the return types in the subtype.

  • No new exceptions can be thrown by the subtype unless they are part of the existing exception hierarchy.



Example:


Making coffee with the Liskov Substitution Principle


Most articles about the Liskov Substitution Principle use an example in which they implement a Rectangle and a Square class to show that you break the design principle if your Square class extends the Rectangle class.


So, let’s create an example that’s a little bit more fun.

If you enjoy coffee as much as I do, you most likely used several different coffee machines in the past.

There are relatively basic ones that you can use to transform one or two scoops of ground coffee and a cup of water into a nice cup of filter coffee. And there are others that include a grinder to grind your coffee beans and you can use to brew different kinds of coffee, like filter coffee and espresso.


If you decide to implement an application that automatically brews a cup of coffee every morning so that you don’t have to get out of bed before it’s ready, you might decide to model these coffee machines as two classes with the methods addCoffee and brewCoffee.


A basic coffee machine

The BasicCoffeeMachine can only brew filter coffee. So, the brewCoffee method checks if the provided CoffeeSelection value is equal to FILTER_COFFEE before it calls the private brewFilterCoffee method to create and return a CoffeeDrink object.

public class BasicCoffeeMachine 
{
    private Map configMap;
    private Map groundCoffee;
    private BrewingUnit brewingUnit;
    
    public BasicCoffeeMachine(Map coffee) 
    {
        this.groundCoffee = coffee;
        this.brewingUnit = new BrewingUnit();
        
        this.configMap = new HashMap();
        this.configMap.put(CoffeeSelection.FILTER_COFFEE,
                            new Configuration(30, 480));
    }
    public CoffeeDrink brewCoffee(CoffeeSelection selection)
    
        throws CoffeeException 
        {
        switch (selection) 
        {
            case FILTER_COFFEE:
                return brewFilterCoffee();
            default:
                throw new CoffeeException(
                "CoffeeSelection [" + selection + "] not supported!");
        }
    }
    
    private CoffeeDrink brewFilterCoffee() 
    {
        Configuration config = configMap.get
                                (CoffeeSelection.FILTER_COFFEE);
        
        // get the coffee 
        GroundCoffee groundCoffee = this.groundCoffee.get
                                (CoffeeSelection.FILTER_COFFEE);
        
        // brew a filter coffee 
        return this.brewingUnit.brew(CoffeeSelection.FILTER_COFFEE, 
            groundCoffee, config.getQuantityWater());
    }
    
    public void addCoffee(CoffeeSelection sel, GroundCoffee newCoffee)
        throws CoffeeException 
        {
        GroundCoffee existingCoffee = this.groundCoffee.get(sel);
        
        if (existingCoffee != null) 
        {
            if (existingCoffee.getName().equals(newCoffee.getName())) 
            { 
                existingCoffee.setQuantity(
                existingCoffee.getQuantity() + newCoffee.getQuantity());
            } else {
                throw new CoffeeException("Only one kind of coffee 
                supported for each CoffeeSelection.");
            }
        } else {
            this.groundCoffee.put(sel, newCoffee);
        }
    }
}

The addCoffee method expects a CoffeeSelection enum value and a GroundCoffee object. It uses the CoffeeSelection as the key of the internal groundCoffee Map.


These are the most important parts of the BasicCoffeeMachine class. Let’s take a look at the PremiumCoffeeMachine.


A premium coffee machine

The premium coffee machine has an integrated grinder, and the internal implementation of the brewCoffee method is a little more complex. But you don’t see that from the outside. The method signature is identical to the one of the BasicCoffeeMachine class.

public class PremiumCoffeeMachine 
{
    private Map<CoffeeSelection, Configuration> configMap;
    private Map<CoffeeSelection, CoffeeBean> beans; 
    private Grinder grinder;
    private BrewingUnit brewingUnit;
    
    public PremiumCoffeeMachine(Map<CoffeeSelection, CoffeeBean> beans) 
    {
        this.beans = beans;
        this.grinder = new Grinder();
        this.brewingUnit = new BrewingUnit();
        
        this.configMap = new HashMap<>();
        this.configMap.put(CoffeeSelection.FILTER_COFFEE,
                            new Configuration(30, 480));
        this.configMap.put(CoffeeSelection.ESPRESSO,
                            new Configuration(8, 28));
    }
    @Override
    public CoffeeDrink brewCoffee(CoffeeSelection selection)
    
        throws CoffeeException 
        {
        switch(selection) 
        {
            case ESPRESSO:
                return brewEspresso();
            case FILTER_COFFEE:
                return brewFilterCoffee();
            default:throw new CoffeeException("CoffeeSelection 
                            [" + selection + "] not supported!");
         }
     }
     
     private CoffeeDrink brewEspresso() 
     {
         Configuration config = configMap.get(CoffeeSelection.ESPRESSO);
         
         // grind the coffee beans 
         GroundCoffee groundCoffee = this.grinder.grind
         (this.beans.get(CoffeeSelection.ESPRESSO), 
            config.getQuantityCoffee());
            
        // brew an espresso 
        return this.brewingUnit.brew(CoffeeSelection.ESPRESSO, 
            groundCoffee, config.getQuantityWater());
    }
    
    private CoffeeDrink brewFilterCoffee() 
    {
        Configuration config = configMap.get
                                (CoffeeSelection.FILTER_COFFEE);
        
        // grind the coffee beans 
        GroundCoffee groundCoffee = this.grinder.grind(
            this.beans.get(CoffeeSelection.FILTER_COFFEE), 
                config.getQuantityCoffee());
        
        // brew a filter coffee 
        return this.brewingUnit.brew(CoffeeSelection.FILTER_COFFEE, 
            groundCoffee, config.getQuantityWater());
    }
    
    public void addCoffee(CoffeeSelection sel, CoffeeBean newBeans)
        throws CoffeeException 
        {
            CoffeeBean existingBeans = this.beans.get(sel);
            if (existingBeans != null) 
            {
                if (existingBeans.getName().equals(newBeans.getName())) 
                { 
                    existingBeans.setQuantity(
                    existingBeans.getQuantity() + newBeans.getQuantity());
                } else {
                    throw new CoffeeException("Only one kind of coffee 
                    supported for each CoffeeSelection.");
                }
            } else {
                this.beans.put(sel, newBeans);
        }
    }
}

But that’s not the case for the addCoffee method. It expects an object of type CoffeeBean instead of an object of type GroundCoffee. If you add a shared superclass or an interface that gets implemented by the BasicCoffeeMachine and the PremiumCoffeeMachine class, you will need to decide how to handle this difference.



Benefits:

1) Code Reusability

2) Easier Maintenance

3) Reduced Coupling




Source Code: Stackigy.com


The Tech Platform

0 comments
bottom of page