Liskov Substitution
Formal definition of Liskov substitution principle is –
Let φ(x) be a property provable about objects x of type T. Then φ(y) should also be true for objects y of type S where S is a sub-type of T.
Or we can say – If class A is a sub-type of class B, then we should be able to replace B with A without disrupting the behaviour of our program.
Another way we can describe this as – If a piece of code that depends on an object of type P should be able to operate properly with objects of type C, where C is a sub-type of P.
Or in another way – Sub-types must not change any super-type’s significant behaviour. Here, “significant behaviour” means behaviour upon which clients of those objects expressly depend.
While the other principles are concerned with coupling and cohesion, LSP stands alone in dealing with code correctness. The LSP is about semantic consistency in a type hierarchy. It’s not enough to implement the same public interface across said hierarchies, the behaviour should also be consistent.
The Liskov Substitution Principle (LSP) serves as a fundamental guideline in object-oriented programming, ensuring that subtypes can be substituted for their base types without altering the correctness of the program. This principle hinges on semantic consistency within type hierarchies—more than just implementing the same interface, subclasses must uphold the expected behavior of their superclasses.
The Liskov Substitution Principle (LSP) is a cornerstone of object-oriented design, ensuring that derived classes can substitute their base classes without altering the correctness of the program. This principle emphasizes more than just inheriting the same interface—it mandates that subclasses maintain the same expected behaviors as their superclasses. This ensures that any code relying on a base class can seamlessly work with instances of its derived classes, promoting flexibility and robustness in software systems.
To illustrate, imagine a scenario with a base class Shape
and its derived classes Square
and Circle
. According to LSP, if a function in our program expects a Shape
, it should be able to handle both Square
and Circle
objects without any issues. This is because both Square
and Circle
inherit from Shape
and adhere to its defined behaviors. By following LSP, developers can write generic code that operates on the superclass Shape
, confident that any subclass will uphold the expected behaviors.
English, Please!
Still cryptic, right?
Let me simplify with a code example:
public abstract class ApiConnection
{
public abstract T[] ReadData<T>(Filter filter);
}
public class SimpleApiConnection : ApiConnection
{
public virtual T[] ReadData<T>(Filter filter)
{
... fetch data from api ...
}
}
public class TokenAuthApiConnection : SimpleApiConnection
{
private object authToken;
public virtual void GetAuthToken(string tokenUrl)
{
... gets auth token ...
this.authToken = authTokenFromApi;
}
public override T[] ReadData<T>(Filter filter)
{
... use this.authToken to Connect to some service ...
... and use that connection to fetch data ...
}
}
There are three classes here: the ApiConnection
class is abstract; because it has no code, has no properties other than it’s type signature. The ‘properties provable’ about this class are therefore only properties about its type which the compiler knows about and enforces on its inheritors.
So we can be sure that the inheritors also have these properties.
Then we have SimpleApiConnection
class implementing ApiConnection
.
We see that ReadData()
method does not require any other method to be called before it in order to perform properly. It can function on its own. This is ‘property provable’ or contract binding of SimpleApiConnection
.
Now this class inherited by the TokenAuthApiConnection
.
However the TokenAuthApiConnection
class does require the GetAuthToken()
method to be called before ReadData()
to set authToken
which is subsequently used in ReadData
, or the connection will not succeed. But the ‘provable’ of this ‘property’ was that it will not depend on any other method to operate properly.
Hence it breaks the Liskov Substitution Principle.
What does this mean? It means that any code using SimpleApiConnection
and relying on being able to safely call ReadData()
will break if the compiler-allowed substitution of a TokenAuthApiConnection
is made when calling that code. That code cannot call GetAuthToken
because it doesn’t exist on SimpleApiConnection
and so the TokenAuthApiConnection
substitution will never work as the call will never be authenticated.
Explain to me like I am a junior developer!
The Liskov Substitution Principle is a rule that helps us make sure that our code works correctly. It says that if something is a certain type, we can use it in the same way as other things of that type.
For example, let’s say we have a class called “Shape”. And we have two different shapes: a square and a circle. The Liskov Substitution Principle says that if we have a square and we use it in a program that is expecting a shape, we can use it just like any other shape. We can do this because a square is a type of shape, so it can be used in the same way as any other shape.
Another example is a car and a truck. A truck is a type of car, so if we have a program that is expecting a car, we can use a truck just like we would use a car. This is because the truck follows the same rules as a car. So it can be used in the same way.
The Liskov Substitution Principle helps us make sure that our code works correctly by making sure that we can use different types of things in the same way as each other, as long as they follow the same rules.
Another Way to Look at: Contracts
In language of contracts, Sub-types must respect the contracts of their super-types. Sub-types must pass the same set of contract tests that their super-types pass. Super-classes clients are constrained to their restricted domain, and sub-classes can extend this domain. All they have to do is preserving the “old behaviour” for the super-class in regular conditions. So that excludes what happens when contract is violated, because contract must not be violated.
For example the old client use super-class “Money” that prescribe “only positive values”. Thus using negatives are not allowed and raise exceptions, and should not be used. The subclass introduces the idea of debt; hence in that case negative money is allowed, as the precondition is weakened, which covers the case of no exceptions are raised anymore for negatives.
Now any contract tests meant to check “irregular behaviour” for the super-class (precondition violation) will behave differently for sub-classes. Because this negativity is not a violation anymore in the Sub-classes.
Consider the scenario where a superclass “Money” enforces positive values only, while a subclass introduces the concept of debt, allowing negative values. This illustrates LSP in action: the subclass extends the domain of acceptable behaviors but still respects the foundational contract of the superclass—positive values. This adherence ensures that existing code relying on “Money” objects remains functional when dealing with its subclasses.
Input parameter and return value
A subclass’s overridden method must accept the same input parameters as the super-class method. This means we can have less strict validation, but not more strict. Otherwise, code calling the super-class method with a subclass object may cause an exception. The return value of the subclass method must also follow the same rules as the super-class method. We can only make stricter rules by returning a specific subclass of the defined return value or a subset of the super-class’s valid return values. The Liskov Substitution Principle allows us to use subclass objects in place of parent class objects without breaking the application. This requires all subclasses to behave like the parent class. However, the Liskov Substitution Principle and the Open/Closed Principle are not the same and achieving one does not guarantee achieving the other.
In practical terms, the Liskov Substitution Principle ensures that subclass implementations respect and extend the contracts defined by their superclasses. This contract adherence is crucial for maintaining code integrity and preventing unexpected failures when substituting objects. For instance, consider a superclass Document
that defines methods for saving and printing documents. A subclass EditableDocument
might extend Document
by adding methods for editing content, but it should still be able to save and print documents just like the base class.
By following LSP, developers can design systems where subclasses enhance and specialize behaviors without compromising the core functionalities inherited from their superclasses. This approach not only promotes code reuse and extensibility but also simplifies testing and maintenance by ensuring predictable behavior across the class hierarchy.
Violating one principle but following the other: Follows LSP but not OCP
Lets look at some code that follows LSP but not OCP.
public class LiskovBase {
public void doStuff() {
System.out.println("My name is Liskov");
}
}
public class LiskovSub extends LiskovBase {
public void doStuff() {
System.out.println("I'm a sub Liskov!");
}
}
public class Context {
private LiskovBase base;
// the good stuff
public void doLiskovyStuff() {
base.doStuff();
}
public void setBase(LiskovBase base) { this.base = base }
}
The code does LSP because the context can use LiskovBase
without knowing the actual type. We might think this code follows OCP as well; but look closely, is the class really closed?
What if the doStuff
method did more than just print out a line?
The truth is; it doesn’t follow OCP; because in this object design we’re required to override the code completely with something else. This opens up the cut-and-paste issues as we have to copy code over from the base class to get things working. The doStuff
method sure is open for extension, but it wasn’t completely closed for modification.
Remedy: Applying template method pattern
We can apply the Template method pattern on the above scenario. Here is that one way to close the doStuff
method for modification and making sure it stays closed by marking it with final keyword. That keyword prevents anyone from sub-classing the class further.
public class LiskovBase {
// this is now a template method
// the code that was duplicated
public final void doStuff() {
System.out.println(getStuffString());
}
// extension point, the code that "varies"
// in LiskovBase and it's subclasses
// called by the template method above
// we expect it to be virtual and overridden
public string getStuffString() {
return "My name is Liskov";
}
}
public class LiskovSub extends LiskovBase {
// the extension overridden
// the actual code that varied
public string getStuffString() {
return "I'm sub Liskov!";
}
}
Now this code follows both OCP and LSP.
Why We Need It?
The Liskov Substitution Principle helps maintain consistency in code. It allows us to use one object in place of another, as long as they have the same properties and functions. This means that if our program expects an object of a certain type, we can use any object that follows the same rules and behaves in the same way.
For example, let’s say we have a class called “Animal” and we have two animals: a dog and a cat. The principle says we can use a dog in a program that expects an animal because a dog is a type of animal. This principle is important because it allows us to reuse code and make our programs more efficient. We can create generic functions that work with any object, rather than having to write specific functions for each type. Overall, the Liskov Substitution Principle helps us write more flexible and easier to maintain code.
In essence, OCP says: If you will add new function, create a new class extending an existing one, rather than changing it.
LSP says: If you create a new class extending an existing class, make sure it’s completely interchangeable with its base.
The Liskov substitution principle allows users to work with objects that implement a super-type without knowing their actual type. Polymorphism works in a similar way. This principle offers an alternative to type-checking and type-conversion, which can become difficult as the number of types increases. You can use pull-up refactoring or the Visitor pattern to apply this principle.
In conclusion, the Liskov Substitution Principle enhances code flexibility and maintainability by promoting interchangeable usage of objects within a hierarchy. By adhering to LSP, developers can confidently utilize polymorphism and inheritance to streamline code reuse and promote modular design. Upholding semantic consistency across type hierarchies not only improves software reliability but also fosters scalability and extensibility in complex systems.
o sum up, the Liskov Substitution Principle is instrumental in fostering code reliability and scalability by enabling polymorphic substitution of objects within a class hierarchy. By adhering to LSP, developers ensure that derived classes can seamlessly replace their base classes without introducing bugs or unexpected behaviors. This principle encourages modular and flexible design practices, allowing for easier maintenance and enhancement of software systems over time.
By understanding and applying LSP in your object-oriented designs, you can leverage the power of inheritance and polymorphism to build robust and adaptable software solutions. Whether you’re designing simple class hierarchies or complex frameworks, LSP provides a solid foundation for ensuring semantic consistency and enhancing the overall quality of your codebase.
Read more on: https://en.wikipedia.org/wiki/Liskov_substitution_principle