NDepend Blog

Improve your .NET code quality with NDepend

SOLID Design in C#: The Liskov Substitution Principle (LSP)

November 13, 2023 7 minutes read

SOLID Design in C# The Liskov Substitution Principle

The Liskov Substitution Principle (LSP) is one of the five essential SOLID design principles. These principles are guidelines for the proper usage of object-oriented features. Named after computer scientist Barbara Liskov, the principle LSP  states that:

Objects of a base-class shall be replaceable with objects of its derived classes without causing errors in the application.

At first glance, the LSP seems redundant with the OOP concept of polymorphism. After all, the whole point of polymorphism is to consume an abstraction without knowing the implementation behind it, isn’t it?

However, it is a good thing that the community emphasizes the Liskov Substitution Principle. This principle is in fact a reminder for developers that creating a hierarchy of classes is both a powerful and tricky feature. In the real world, the usage of polymorphism often leads to a dead-end situation, it must be wisely used.

LSP is often summarized with a counter-example of Duck Test“If it looks like a duck, quacks like a duck, but needs batteries – you probably have the wrong abstraction”

SOLID Principles Summary

Before delving into the Liskov Substitution Principle, let’s take a quick look at how it stands in the SOLID design principles:

  • The Single Responsibility Principle (SRP): A class should have one reason to change. This principle is about how to partition your logic into classes and avoid ending up with some monster classes (known as god class).
  • The Open-Close Principle (OCP): Modules should be open for extension and closed for modification. To implement a new feature, it is better to add a new derived class instead of having to modify some existing code.
  • The Liskov Substitution Principle (LSP): Methods that use references to base classes must be able to use objects of derived classes without knowing it. Array implements IList<T> but throws NotSupportException on IList<T>.Add(). This is a clear LSP violation.
  • The Interface Segregation Principle (ISP): The client should not depend by design on methods it does not use. This is why designers introduced interfaces like IReadOnlyCollection<T>. Often the client just needs a subset of features like read-only access instead of full read-write access.
  • The Dependency Inversion Principle (DIP): Depend on abstractions, not on implementations. Interfaces are much less subject to changes than classes, especially the ones that abide by the (ISP) and the (LSP).

Let’s detail some common polymorphism pitfalls that the LSP attempts to prevent by reminding the developer to adopt the client’s perspective.

Prevent situations where a method cannot be implemented

Refactoring tooling offers the possibility to generate the method stubs when a class derives from a base class or implements an interface. Typically the default body of such a method generated is throw NotImplementedException().

The Liskov Substitution Principle Implement Base Methods

Clearly, this behavior should be temporary and must not be deployed in a production environment. Client code that holds a reference on the interface or the base class doesn’t expect to get a NotImplementedException raised upon a method call. This is why NDepend has a default rule named Do implement methods that throw NotImplementedException, to prevent such situation.

Conversely, if implementing an abstract method doesn’t make sense, it indicates a flaw in the design. Here is such a wrong design, assuming that all birds can fly:

This code could then be refactored to:

The problem was that Bird with its Fly() method is too coarse. We needed some refinement because not all birds can fly. From my experience such wrong assumptions on interfaces and base classes happen quite often in the real world. When you stumble on such a situation, see it as a good starting point for refactoring … if possible. Indeed, sometimes refactoring is not an option if many clients depend already on the wrong design.

A Common LSP violation in the .NET Base Class Library

One dreaded LSP violation in .NET is the class System.Array implementing the ICollection<T> interface. Hence Array has to implement the ICollection<T>.Add() method but calling this method on an array throws at runtime a NotSupportedException:

The C# compiler doesn’t even warn on such a simple erroneous program.

Ideally, we would need to ensure that ICollection<T>.IsReadOnly is false before modifying a collection through a reference of IList<T> or a ICollection<T>. But frankly, who does that? This is an error-prone design. I can remember having stumbled on this situation quite a few times during the last 20 years I am programming daily with C#.

Moreover refactoring this original design mistake is not an option anymore. Even when .NET Core was introduced. Millions of programs are relying on this API. The interface IReadOnlyCollection<T> was introduced with .NET v4.5 in 2012 but the original design cannot be changed.

Think twice before applying the ISA trick

The Liskov Substitution Principle can also be explained with the Rectangle and Square paradigm. A square is-a rectangle isn’t it? So we should be able to write such code:

Clearly, this is a wrong usage of the ISA principle. A square is a rectangle, a special rectangle. The client doesn’t expect that when modifying the height of the rectangle, the width will also be modified.

Here also such wrong design emerges quite often in the real world. For example, when dealing with a complex control hierarchy. There are many fields to maintain at various inheritance levels. It can become quite tricky to maintain coherence in your objects’ states at runtime.

Liskov Substitution Principle and Covariance and Contravariance

We saw that LSP is about presenting some methods not implementable by all derived classes. But the Liskov Substitution Principle is also about in-parameters and return-value:

  • When overriding a method in a subclass, it’s crucial to ensure that the overridden method accepts identical input parameter values as the base method. While you have the flexibility to apply less stringent validation rules, imposing stricter rules in the subclass is prohibited. Doing so could potentially lead to exceptions when the method is invoked through a reference of the base class.
  • Similarly, in .NET, the return value of an overridden method in a subclass must adhere to the same rules as the return value of the corresponding method in the base class. The flexibility exists to implement even more stringent rules by returning a specific subclass of the defined return value or by providing a subset of the valid return values of the superclass.

These restrictions are named C# Covariance and Contravariance:

CSharp Covariance and Contravariance

Here is an example of LSP violation because of contravariance. The BaseClass.SquareRoot() method can accept any positive double. However the DerivedClass.SquareRoot() can only accept doubles higher than 1. From the client’s perspective, BaseClass cannot be replaced with DerivedClass. With BaseClass, doubles in the interval [0,1] return a result. With DerivedClass, doubled in the interval [0,1] throw an exception.

Enforcing the Liskov Substitution Principle at Development Time

These classical Bird and Rectangle examples show how polymorphism and inheritance can quickly lead to rotten design, even in apparently simple situations. The Array class implementing ICollection<T> situation shows that in practice LSP violations just happen.

Take the point of view of the client of your API

Here is some advice to enforce the Liskov Substitution Principle at development time. When writing a public API relying on polymorphism, you should first take the point of view of the client of your API before writing any interface and any class hierarchy.

  • Do really all birds can fly? What happens if I try to call Fly() on a bird that cannot fly?
  • Is a square really a rectangle? What happens if I change the width of a square?

Question your design

In the real world, this looks like this:

  • Can all collections really be modified? What happens if I add or remove an element to an array?
  • Do all controls are scrollable? What happens if a scrollbar is displayed on a control that should not scroll?
  • Does withdrawal apply to all bank accounts? What happens if we try to withdraw money from a frozen long-term deposit account? Should we fail to withdraw badly in this situation? Or should we prevent such a situation with an IAccountWithdrawable abstraction?

A pattern emerges here: for each member of your interface you should question yourself: Does this member apply naturally to all objects that will implement this interface?

Notice that this advice applies to API writing in general, not just when polymorphism is involved. When writing an API first take the point of view of the client of your API. This is the only way to achieve an elegant API that clients will love to consume. This is another way to explain Test-Driven Development (TDD), where client code must be written for test and design purposes before writing the code itself.

In a previous post, we explained why NDepend proposes the default rule Class with no descendant should be sealed if possible. In the real world, a class is never well designed for inheritance by chance. Thus it should be sealed by default. Designing well a class for inheritance requires quite an extensive understanding of your domain. As for everything in life, it is difficult to build something that others will find easy to use.

Conclusion

In conclusion, mastering the Liskov Substitution Principle (LSP) is a crucial stride toward achieving SOLID design principles in C#. By fostering the interchangeability of objects within a class hierarchy, LSP not only enhances code maintainability and flexibility but also paves the way for robust and scalable software systems

 

 

Comments:

  1. Okay, suppose if I have an interface say INotify which has two methods sms and email. How should I use LSP is this case!

  2. Your interface violets the Single Responsibility Principle. You should create iNotify with one method notify() and implement it in two concrete classes – SmsSender and EmailSender

  3. Lev Elbert says:

    Why not to implement ICollecttion.Add like this:

    void Add(T val)
    {
    Array.REsize(ref arr, arr.Length+1)
    arr[arr.Length-1]=val;
    }

  4. Why Rectangle has mutable properties with side effects in the first place? It rather should have a constructor accepting single value, the size of its sides. And if you will, a method Resize() that would return another Rectangle.

Comments are closed.