Click here to Skip to main content
65,938 articles
CodeProject is changing. Read more.
Articles / Languages / C#4.0

SOLID Principles: The Liskov Principle -> What, Why and How

5.00/5 (14 votes)
11 Nov 2018CPOL7 min read 34.2K   151  
SOLID principles: The Liskov Principle, a simple example in C#

Introduction

This article will give an explanation of the Liskov Principle and will show a simple example in C#.

Background

What

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

Why

When a method or class consumes a base class and must be modified when consuming a derivative of that base class, then this method or class is violating the Liskov substitution principle. The designer of this abstraction consuming class should be able to program against this abstraction without having to worry that there will be a derivative that will “break” its code.

How

Certain rules must be followed to make sure that when designing a class, it does not violate the Liskov principle. The first rule is to implement inheritance based on behaviour, not on "physical" properties. The next important rule is the pre- and postconditions rule of Bertrand Meyer. Both will be demonstrated by code in the next examples.

Liskov Rules to Remember

  • implement inheritance based on behaviour
  • obey the pre- and postconditions rules of Bertrand Meyer

Explanation

Explained: The Liskov Violating Rectangle - Square Example

Implement Inheritance Based on Behaviour

The following is a known example how inheritance not based on behavior can violate the Liskov principle:

The Rectangle - Square example. Based on physics, a square is a special rectangle but its behaviour is not consistent with a rectangle. The Rectangle class consists of two simple properties, Width and Height. When I change the value of the width property, the height property will keep its original value and vice versa. Both can be set independent of each other. This sounds trivial but it's an important behavioral aspect in regards to the behavior of a square as we will see later.

C#
public class Rectangle
{
    protected int _width;
    protected int _height;
 
    public int Width
    {
        get { return _width; }
    }
    public int Height
    {
        get { return _height; }
    }
 
    public virtual void SetWidth(int width)
    {
        _width = width;
    }
 
    public virtual void SetHeight(int height)
    {
        _height = height;
    }
 
    public virtual int CalculateArea()
    {
        return _height*_width;
    }
}

Now if I want to create a Square class, the obvious way to do this is to make it a derived class from Rectangle because Square is a rectangle regarding the physical properties, both have a width and a height. To let the Square really be a square, I must make sure that the width and height are always equal. The Square class commits to the physical characteristic of a square by overriding the SetWidth and SetHeight methods with its own version assuring that width and height are always equal. Now it is important to realize that we changed the behaviour of the Square and it is not consistent anymore with the Rectangle's behaviour. When the value of the Width property is changed, the value of the Height property is also changed and vice versa. In other words, the Height and Width of a rectangle cannot be set independently and that is an important change in behaviour, which is violating the Liskov principle as we can see in the next code example.

C#
public class Square : Rectangle
{
 public override void SetWidth(int width)
 {
    _width = width;
    _height = width;
 }
 public override void SetHeight(int height)
 {
    _width = height;
    _height = height;
 }
}

When the following client consumes a Square, it expects the area also to be width x height but this is not the case. The client cannot consume a square and let the assert being valid without changing the client code. This is because the behaviour is not consistent with the rectangle and behaviour is what software is really about!

C#
public void Test_ClientForRectangle(Rectangle rectangle)
{
  int width = 4;
  int height = 5;
  rectangle.SetWidth(width);
  rectangle.SetHeight(height);
  int area = rectangle.CalculateArea();
  //Works for a rectangle but does not for a Square, so violates the Liskov Principle
  Assert.AreEqual<int>(width * height, area);
}

Executing the tests to show that a Rectangle instance cannot be substituted by a Square instance:

C#
Rectangle rectangle = new Rectangle(); 
Test_ClientForRectangle(rectangle); //Ok 

Square square = new Square();
Test_ClientForRectangle(square);    //Not Ok

The Rectangle - Square example shows the problems that can arise when using inheritance in the wrong way. The solution is to always apply inheritance based on behaviour. The next example is based on a Calculator class and it will demonstrate some other Liskov rules and the difference between a Liskov and a Non-Liskov compliant derived class.

Obey the Pre- and Postconditions Rules of Bertrand Meyer

The next rule preventing a design from violating the Liskov principle is the rule of pre- and postconditions. To reassure that behaviour of a derived class is inherited from a base class, the derived class must obey the pre- and postconditions rules of Bertrand Meyer. According to Bertrand Meyer, it is important that when redefining a routine/method in a derivative, you may only replace its preconditions by a weaker and the post condition by a stronger one.

A precondition of an operation is an assertion which must be true just before the operation is called. A precondition states if it makes sense to call an operation. A postcondition of an operation is an assertion which must be true just after the operation has been completed.

Thus Liskov Principle:

  • Derivative: preconditions weaker
  • Derivative: postconditions stronger

Preconditions cannot be strengthened in the derivative. (You cannot require more than the parent.) Postconditions cannot be weakened in the derivative. (You cannot guarantee less than the parent.)

In the example of the rectangle-square, these conditions can be stated:

Precondition rectangle:

  • Height and width must be positive
  • Height is different then its width

Precondition square:

  • Height and width must be positive
  • Must have the same height and width (stronger precondition)

Postcondition rectangle:

  • When setting the width of a rectangle, the height must remain the same as the initial height

Postcondition square:

  • When setting the width of a square, the height does not have to be the same as the initial height (weaker one)

This depicts that the preconditions of an area calculation of a square are actually stronger then the area calculation of a rectangle and the post conditions are weaker which is why it violates the Liskov principle.

The Liskov Calculator Example

Another example to explain the pre- and postconditions is based on a simple BaseCalculator. It is a class that has one calculation method with some pre- and postconditions. The BaseCalculator class has two derived classes named LiskovCalculator which meets. The Liskov principle and NonLiskovCalculator which violates the principle. The LiskovCalculator has a weaker precondition and a stronger postcondition than the BaseCalculator. The NonLiskovCalculator has a stronger precondition and a weaker postcondition than the BaseCalculator.

BaseCalculator

C#
/// <summary>
/// Precondition : input > 1 && input < 20 
/// Postcondition result = input - 10 stronger
/// Postcondition : ShowResultOnDisplay = true
/// </summary>
public class BaseCalculator
{
    public int result;
    public bool ShowResultOnDisplay;
    public bool ShowResulOnPrintOut;

    public virtual void DoSomeCalculation(int input)
    {
        if (input > 0 && input < 20)
        {
            result = input - 10;
            ShowResultOnDisplay = true;
        }
        else
        {
            throw (new ArgumentException("Preconditions are not met"));
        }
    }
}

LiskovCalculator: Obeys the Liskov principle

C#
/// <summary>
/// Weaker preconditions
/// Stronger postconditions 
/// 
/// Precondition input > 0
///  
/// Postcondition result = input - 10
/// Postcondition : ShowResultOnDisplay = true
/// Postcondition : ShowResulOnPrintOut = true
//  
/// </summary>
public class LiskovCalculator : BaseCalculator
{
    public override void DoSomeCalculation(int input)
    {
        if (input > 0)
        {
            result = input - 10;
            ShowResultOnDisplay = true;
            ShowResulOnPrintOut = true;
        }
        else
        {
            throw (new ArgumentException("Preconditions are not met"));
        }
    }
}

NonLiskovCalculator: Violates the Liskov principle.

C#
/// <summary>
/// Stronger preconditions
/// Weaker postconditions 
/// 
/// Precondition input > 0 && input < 5
/// Postcondition result = input - 10
/// </summary>
public class NonLiskovCalculator : BaseCalculator
{
    public override void DoSomeCalculation(int input)
    {
        if (input > 1 && input < 5)
        {
            result = input - 10;
        }
        else
        {
            throw (new ArgumentException("Preconditions are not met"));
        }
    }
}

To test the pre- and postconditions, I have added unit tests that test different clients consuming the BaseCalculator, LiskovCalculator, and the NonLiskovCalculator. The BaseClient can consume the BaseCalculator and the LiskovCalculator without any problem but when consuming the NonLiskovCalculator a problem occurs. Immediately, you can see that the problem occurs because of the BaseCalculator preconditions that are expected to be weaker than the preconditions of the NonLiskovCalculator and therefore already the input of the BaseCalculator violates the NonLiskovCalculator input. The clients expects the postconditions of the BaseCalculator. When the stronger postconditions of the LiskovCalculator are returned, the check is valid but when the weaker postconditions of the NonLiskovCalculator are returned, the check is invalid.

Pre- and Postconditions of the Calculator Classes

Precondition BaseCalculator.DoSomeCalculation:

  • 0 < Input < 20

Precondition LiskovCalculator.DoSomeCalculation:

  • Input > 0
    (Weakest precondition)

Precondition NonLiskovCalculator.DoSomeCalculation:

  • 1 < Input < 5
    (Strongest precondition)

Postcondition BaseCalculator.DoSomeCalculation:

  • Result = Input – 10
  • ShowResultOnDisplay = True

Postcondition SpecialCalculator.DoSomeCalculation:

  • Result = Input – 10;
  • ShowResultOnDisplay = True
  • ShowResultOnPrintOut = True
    (Strongest postcondition)

Postcondition NonSpecialCalculator.DoSomeCalculation:

  • Result = Input – 10;
    (Weakest postcondition)

Liskov illustration

Al becomes more clear when you go investigate the attached solution. When you run all UnitTests, it will show that tests that are consuming a NonLiskov Calculator will fail and the tests that are consuming a Liskov Calculator will succeed.

Base client which can consume a BaseCalculator and all derived classes of BaseCalculator.

C#
public void Test_BaseClient(BaseCalculator calculator)
{
    int input = 15;
    //When this client consumes a derived class that conforms Liskov,
    //a stronger PreCondition in the Client is compared with
    //a Weaker PreCondition in the derived class

    //PreCondition Check
    Assert.IsTrue(input > 0);
    Assert.IsTrue(input < 20);

    calculator.DoSomeCalculation(input);

    //When this client consumes a derived class that conforms Liskov,
    //a stronger PostCondition in the derived class method is compared
    //with a Weaker PostCondition in the Client

    //PostCondition Check
    Assert.AreEqual<int />(input - 10, calculator.result);
    Assert.IsTrue(calculator.ShowResultOnDisplay);
}

LiskovCalculator client which can consume only a LiskovCalculator:

C#
public void Test_LiskovClient(LiskovCalculator calculator)
{
    int input = 100;
    //PreCondition Check
    Assert.IsTrue(input > 0);
    calculator.DoSomeCalculation(input);

    //PostCondition Check
    Assert.AreEqual<int />(input - 10, calculator.result);
    Assert.IsTrue(calculator.ShowResultOnDisplay);
    Assert.IsTrue(calculator.ShowResulOnPrintOut);
}

NonLiskovCalculator client which can consume only a NonLiskovCalculator.

C#
public void Test_NonLiskovClient(NonLiskovCalculator calculator)
{
    int input = 2;
    //PreCondition Check
    Assert.IsTrue(input > 1);
    Assert.IsTrue(input < 5);
    calculator.DoSomeCalculation(input);

    //PostCondition Check
    Assert.AreEqual<int />(input - 10, calculator.result);
}

More Rules

The Contra- and Covariance Rule

Next to the above mentioned rules to confirm to the Liskov principle, there are a couple more: The Contravariance rule (go from more derived to generic): Arguments in methods defined in the derived class must be equal or of a less derived (more generic) type. This can be seen as a weaker precondition. When a client calls the BaseCalculcator DoSomeCalculation method, it should also be able to call the LiskovCalculator DoSomeCalculation method without having to change the input arguments.

A value assigned to an int can also be assigned to an object.

C#
//BaseCalculator
DoSomeCalculation(int input)
//LiskovCalculator
DoSomeCalculation(object input)

The Covariance rule (go from generic to more derived): Return types of methods defined in the derived class must be equal or of a more derived (narrower) type. This can be seen as a stronger postcondition. When a client consumes the return type of the BaseCalculcator DoSomeCalculation method, it should also be able to consume the return type the LiskovCalculator DoSomeCalculation method.

When I can handle an int, I can also handle a short.

C#
//BaseCalculator
DoSomeCalculation(int input)
{
    return output as int;
}
//LiskovCalculator
DoSomeCalculation(int input)
{
    return output as short;
}

The Historic Constraint Rule

The last rule is the Historic Constraint rule. Simply said, when a BaseType has a constraint on state, the Derived type cannot change this state constraint. For example, when a BaseType has a readonly property that has to be set once, e.g., clear the display, the Derived type may not introduce a method that assigns a new value to this property.

C#
BaseCalculator
protected string _display;
public string Display
{
    get { return _display; }
}
//Constructor
BaseCalculator()
{
    _display = string.empty;
}
 
NonLiskovCalculator
SetDisplay()
{
    _display = "9999";
}

When you program conform the rules mentioned in this article, you are Liskov compliant.

Have fun!

License

This article, along with any associated source code and files, is licensed under The Code Project Open License (CPOL)