Software Development

SOLID: Liskov Substitution Principle

This is the third in the series of posts on SOLID Software Principles. We previously covered the Single Responsibility Principle and the Open-Close Principle. In this post, I will take you through the L in SOLID, the Liskov Substitution Principle.

The primary idea behind the Open-Closed principle is achieved using inheritance i.e. introduce new classes for new functionality and keep the classes related to existing functionality untouched. But what differentiates a good inheritance structure from a bad one? That is where the Liskov Substitution Principle comes into play.

The Liskov Substitution Principle is a simple yet effective way to improve the code. However, it is not so straight forward to identify when the code is breaking the principle. Generally, a piece of code that breaks the Liskov Substitution Principle also breaks the Open-Close principle.

Liskov Substitution Principle

Originally written by Barbara Liskov, It states:

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

More simpler definitions have arisen since the original paper and they go as:

If S is a subtype of T, then objects of type T may be replaced with objects of type S without altering any of the desirable properties of the program.

That sounds simple enough, but there is more than meets the eye. Additionally, there a number of requirements that every derived class should adhere to while inheriting from a base class.

Signatures requirements

The Signature requirements on inheritance will be explained using an example. Assume three classes exist such that the direction of the relationship is Vehicle->Car->Ford i.e. Vehicle is the supertype of Car, and Car is the base class of Ford.

Contravariance of method arguments in the subtype

As per this rule, the variance must be contrary to the direction of the inheritance relation, i.e. each input parameter in methods of the subtype S must be the same or a super-type of the corresponding input parameters in the base class T.

//Supertype
void drive(Car v);

//Invalid subtype
void drive(Ford f);

//Valid subtype
void drive(Vehicle v);

//or

void drive(Car c);

Covariance of return types in the subtype

It means that the variance must be in the direction of the inheritance relation, i.e. the output of each method of the subtype S must be the same or a subtype of the corresponding output parameters in the base class T.

//Supertype
Car getInstance();

//Invalid subtype
Vehicle getInstance();

//Valid subtype
Car getInstance();

//or

Ford getInstance();

No new exceptions

No new exceptions should be thrown by methods of the subtype, except where those exceptions are themselves subtypes of exceptions thrown by the methods of the base class.

//Supertype
Car getInstance() throws CarNotFoundException;

//Invalid subtype
Vehicle getInstance() throws VehicleNotFoundException;

//Valid subtype
Car getInstance() throws CarNotFoundException;

//or

Ford getInstance() throws FordNotFoundException;

The signature requirements are generally easy to recognise because in most statically typed modern languages, the compiler would enforce type safety and will point out the error. It is the behavioural requirements that are hard to recognise.

The behavioural conditions that the inheritance must satisfy are covered next.

Behavioural requirements

Invariants of the supertype must be preserved in a subtype

An invariant is a condition that can be relied upon to be true during execution of a program. The invariance should remain unchanged in the implementation of the subtype.

Preconditions cannot be strengthened in a subtype

A precondition is a condition or predicate that must always be true just prior to the execution of some section of code. Preconditions can be modified in subtype, but they may only be weakened, not strengthened.

Postconditions cannot be weakened in a subtype

A postcondition is a condition or predicate that must always be true just after the execution of some section of code. Postconditions can be modified in subtype, but they may only be strengthened, not weakened.

Let us see what they mean by taking a commonly used example.

Example

Mathematically, a Square is a Rectangle. Note the words “is a“. However, while coding, is the square really a rectangle with both sides equal? Most people would say yes and they misinterpret the “is a” relation and model the relationship between the rectangle and a square with inheritance.

In that case, you should be able to use a Square anywhere you can use a Rectangle class, isn’t it? But doing so results in some unexpected problems and breaking the Liskov Substitution Principle. Lets us examine why?

Let us define the Rectangle class as follows with the behavioural requirements as mentioned in the inline comments.

/* The rectangle class and its behavioural conditions */

public class Rectangle {
    /*    
     * Invarients:
     * Height and width cannot change together in the same method i.e.
     *  setHeight() should not change width
     *  setWidth() should not change height
     */
    private int height;
    private int width;

    /*
     * Post condition:
     * height==newHeight && width==oldwidth
     *
     * Pre condition:
     * The rectangle is not a rhombus, if it is, then it is a square. 
     * The setHeight should only execute on a rectangle.
     */
    public void setHeight(int newHeight) {
        this.height = newHeight;
    }

    /*
     * Post condition:
     * width==newWidth && height==oldHeight
     * 
     * Pre condition:
     * The rectangle is not a rhombus, if it is, then it is a square. 
     * The setWidth should only execute on a rectangle.
     */
    public void setWidth(int newWidth) {
        this.width = newWidth;
    }
    
    public int getWidth() {
        return width;
    }
    
    public int getHeight() {
        return height;
    }
}

In the first attempt, using inheritance to extend the Rectangle into a Square would likely result in something like :

public class Square extends Rectangle {

    @Override
    public void setHeight(int height) {
        super.setHeight(height);
    }

    @Override
    public void setWidth(int width) {
        super.setWidth(width);
    }
}

But hey, there is a problem isn’t it? You could end up having two different dimensions for the sides – and when that happens, it definitely isn’t a square. Easy to fix. Most people would do this.

public class Square extends Rectangle {

    @Override
    public void setHeight(int height) {
        super.setHeight(height);
        super.setWidth(height); 
    }

    @Override
    public void setWidth(int width) {
        super.setWidth(width); 
        super.setHeight(width);
    }
}

The code above fails the Liskov Substitution Principle for all the behavioural rules. i.e. when we pass an instance of Square using the type Rectangle.

  1. As per the invariant rule specified in the base class Rectangle, it is not allowed to set both the dimensions in the same method. So, the behaviour rule on invariant fails. That alone is good enough reason not to model Square by inheriting from the Rectangle.
  2. The post-condition defined in the base class Rectangle will fail for the setHeight and setWidth methods. Since, we are modifying both height and width in both setWidth() and setHeight(), the check for old values will fail. We cannot remove the post condition to check for the old values because it will weaken the post-condition.
  3. The pre-condition will fail because the operation on the base class is permitted only when the object is not a rhombus. Since a square is a rhombs but a rectangle is not a rhombus, the pre-condition that exists on the Rectangle class will not execute the code when a Square object is passed into it. We cannot add any conditions to allow the operation if it is a square because it will strengthen the pre-condition.

So, how to correctly model inheritance?

The most critical aspect to inheritance is that we should model inheritance based on behaviours, not object properties.

The general tendency is to model objects in the code based on real world objects properties. But that turns out to be incorrect because the objects in the code are not real objects, they are just representations of the real object.

For example, the rectangle and square objects in the previous examples are themselves not rectangles and squares, they are just representation those shapes. The representations need not always share the same relationship between them as the real objects they are representing. To take another example, when I am playing video games with my wife, we both are represented by our respective xbox avatars, but the avatars themselves are not married to each other!

Reference: SOLID: Liskov Substitution Principle from our JCG partner Deepak Karanth at the Software Yoga blog.

Deepak Karanth

Deepak is a tech consultant working on Software development, Agile and DevOps. He drives the overall technical strategy for organizations and relentlessly improves various aspects of the organization to deliver more value to customer. He helps companies build great software by bringing together three critical aspects to ensure successful software delivery – Technology, People and Process.
Subscribe
Notify of
guest

This site uses Akismet to reduce spam. Learn how your comment data is processed.

0 Comments
Inline Feedbacks
View all comments
Back to top button