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.
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.
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!
public void Test_ClientForRectangle(Rectangle rectangle)
{
int width = 4;
int height = 5;
rectangle.SetWidth(width);
rectangle.SetHeight(height);
int area = rectangle.CalculateArea();
Assert.AreEqual<int>(width * height, area);
}
Executing the tests to show that a Rectangle
instance cannot be substituted by a Square
instance:
Rectangle rectangle = new Rectangle();
Test_ClientForRectangle(rectangle);
Square square = new Square();
Test_ClientForRectangle(square);
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
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
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.
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
:
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)
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
.
public void Test_BaseClient(BaseCalculator calculator)
{
int input = 15;
Assert.IsTrue(input > 0);
Assert.IsTrue(input < 20);
calculator.DoSomeCalculation(input);
Assert.AreEqual<int />(input - 10, calculator.result);
Assert.IsTrue(calculator.ShowResultOnDisplay);
}
LiskovCalculator
client which can consume only a LiskovCalculator
:
public void Test_LiskovClient(LiskovCalculator calculator)
{
int input = 100;
Assert.IsTrue(input > 0);
calculator.DoSomeCalculation(input);
Assert.AreEqual<int />(input - 10, calculator.result);
Assert.IsTrue(calculator.ShowResultOnDisplay);
Assert.IsTrue(calculator.ShowResulOnPrintOut);
}
NonLiskovCalculator
client which can consume only a NonLiskovCalculator
.
public void Test_NonLiskovClient(NonLiskovCalculator calculator)
{
int input = 2;
Assert.IsTrue(input > 1);
Assert.IsTrue(input < 5);
calculator.DoSomeCalculation(input);
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.
DoSomeCalculation(int input)
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
.
DoSomeCalculation(int input)
{
return output as int;
}
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.
BaseCalculator
protected string _display;
public string Display
{
get { return _display; }
}
BaseCalculator()
{
_display = string.empty;
}
NonLiskovCalculator
SetDisplay()
{
_display = "9999";
}
When you program conform the rules mentioned in this article, you are Liskov compliant.
Have fun!