What is the Liskov Substitution Principle (LSP) in the context of SOLID principles?
Liskov is an American computer scientist who got Turing award on the year 2008. On the year 1987 Liskov developed a definition of a subtype, how a subtype should behave and what constraints it should follow.
if S is a subtype of T, then objects of type T in a program may be replaced with objects of type S without altering any of the desirable properties of that program. reference
In other words a super class can be replaced by a subclass without causing issues in the program. That might sounds logical and obvious but it was not that obvious back then by 1987. For example Java’s complier allows the user to replace a type by its subtype and reject replacing a type with totally different type. Compliers today designed in this fashion to follow that principle introduced by Liskov.
Let’s have an example on LSP.
We will assume we have a school management system that has users. A user can be an employee or a student as shown the in the below UML diagram.
In the diagram illustrated above, student class is a subtype of a user class, hence student object can replace user object in the program without breaking it. Likewise, employee object can replace user object. Since employee class is not a subtype of student class, we cannot replace student object by employee object and vice versa as illustrated in the below code.
//this code will work
User user = new User()
User employee = new Employee()
User student = new Student()
//this code will have compilation errors
Employee employee = new Student()
Student student = new Employee()
In order to make sure replacing a user with a student or employee will not break the program in the compilation and run time we need to follow certain rules. In the follow paragraphs we will discuss the roles to follow to make this substitution successful without breaking the program.
LSP Covariant return types:
Overriding method can return more specific return type than the overridden method. That means, overriding method should not return more broader type than the type return by the overridden method. Overriding method should not return a totally different object that does not exist in the inheritance tree of the return type of the overridden method.
Let’s have an example
The below diagram shows the method student.login() returns HttpSession, and overrides user.login() which returns Session object. HttpSession is a subtype of Session.
As per the covariant return types rule the overriding method cannot return broader type than the return type of the overridden method, hence student.login() is not allowed to return a session object if user.login is returning HttpSession object.
Why it is important to follow covariant return types
It is important for the overriding method to return the same type or more specific type of the return type of the overridden method, because the clients uses user.login() understand and expect a session object. As we know a student can substitute user in our school management program.
If a client expected session object but got HttpSession object that would be fine as HttpSession “IS A/inherits ” session. At the same time if a client expected HttpSession and got session object the client will not be able to deal with it as Session is not HttpSession and does not support HTTP methods, hence the program will break in that scenario.
LSP – Contravariant Argument Types.
The overloading method can take broader argument types than the overloaded method. In other words, when a method is overloaded, the overloading method should accept broader/more general types as an argument. That will enable the overloading method to handle cases that were not handled in the overloaded method.
In the below example the method user.enroll() can take course as an argument, while a student.enroll() can take any enrollable type as an argument, that could be a course object or a program object. That will allow student to handle more cases that were not handled in user.enroll() method.
LSP – no new exceptions
Subclasses show not throw new exceptions, that are not thrown in the parent class. In other words, a derived class should not define new or broader exceptions that are not thrown by its parent class.
When this rule is followed, the derived class remains substitutable for the base class, and any code that relies on the base class can also work with the derived class without any unhandled exceptions.
In the above example, SqlDatabase.save() is throwing SqlDatabaseException. Since it is throwing a subclass of DatabaseException, which is thrown by Database class, then it is following the role of no new exceptions. If SqlDatabse.save() throws Exception, then it will be breaking the no new exceptions role as Exception is more broader than Database exception. The overriding method can return same exception or more specific exception than the overridden method. At the same time the overriding method cannot return new exception or more broader exception than the overridden method.
LSP – Preconditions cannot be strengthened
Preconditions cannot be strengthened in a derived class. A derived class should not require more strict preconditions than its base class. If a derived class does require stricter preconditions, then objects of the derived class cannot be substituted for objects of the base class without causing unexpected behavior. Clients that are used to passing certain arguments to the parent class will encounter exceptions when they pass the same arguments to the subclass, because the preconditions have been strengthened in the subclass.
Example:
In the following example, the Calculator.add()
method accepts any two integers, regardless of whether they are positive or negative, and returns the sum of the two arguments. However, when the DistanceCalculator
class extends the Calculator
class, the DistanceCalculator.add()
method adds an additional precondition. Since distance cannot be negative, the method checks if either of the arguments are negative, and if they are, it throws an IllegalArgumentException
. In this case, any client that uses the Calculator
class cannot use the DistanceCalculator
class without implementing code to check that the arguments are positive before passing them to the DistanceCalculator.add()
method. This means that strengthening the preconditions in a subclass can lead to failures when the subclass is substituted for the parent class. Therefore, preconditions should not be strengthened.
class Calculator{
public int add(int number1, int number2){
return number1 + number2;
}
}
class DistanceCalculator extends Calculator{
@Override
public int add(int number1, int number2) {
if(number1 < 0 || number2 < 0) {
throw new IllegalArgumentException();
}
return number1 + number2;
}
}
LSP – Postconditions cannot be weakened
If a base class has a postcondition, any subclass derived from it must maintain that postcondition or make it even stronger. If a subclass weakens the postcondition, existing clients that depend on the postcondition may break. If the contract changes unexpectedly, clients may not be able to handle the change and may throw errors.
Example:
In the following UML diagram, the method ShippingStrategy.calculateShippingCost()
has a postcondition that does not return zero to the client. When the method is overridden by WorldWideShippingStrategy.calculateShippingCost()
, the logic has been changed to implement free domestic shipping as per business requirements. This means that the method will return zero in case of domestic shipping. If a client uses the returned value from the calculateShippingCost()
method as the denominator in a math formula, and the subclass weakens the postcondition to allow for a zero value, the client may throw a DivideByZeroException
when the class is substituted with its subclass. Therefore, postconditions cannot be weakened to avoid any impact on clients that use the superclass and expect specific output conditions to be met.
Summary
In this article, we learned about the Liskov substitution principle (LSP) in the context of SOLID principles, which states that a subclass can be substituted for its superclass without affecting the correctness of the program. In order to achieve this, some other rules must be followed, such as the following:
- Covariant return types: The overriding method must return the same return type as the overridden method, or a more specific type.
- Contravariant argument types: The argument types of an overloaded method can be broader types to handle new cases that were not handled in the overloaded method.
- No new exceptions: The overriding method should not throw new exceptions that were not thrown in the overridden method.
- Preconditions cannot be strengthened: The constraints expected on the method inputs should not be increased or strengthened in the subclass.
- Postconditions cannot be weakened: The rules and conditions met by the method output should not decrease in the subclass.
These rules are what is called design by contract. To put it another way, in order to be substitutable, the contract of the base class must be honored by the derived class (Robert C. Martin).
This article is a part of series of articles on SOLID principles. You may also need to read the below related articles
SOLID Principles – Open/Closed Principle – OCP