Skip to content

SOLID: Liskov Substitution Principle

Published: at 08:05 PM

The Liskov Substitution Principle (LSP) is a fundamental concept in object-oriented design, ensuring that derived classes can be substituted for their base classes without affecting the correctness of a program. This principle, first introduced by Barbara Liskov, emphasizes the importance of maintaining behavioral consistency across inherited classes.

Definition

The original definition found in the paper by Barbara Liskov states:

Let ϕ(x)\phi(x) be a property provable about objects xx of type TT. Then ϕ(y)\phi(y) should be true for objects yy of type SS, where SS is a subtype of TT.

However, this can be translated into the much more digestable definition:

Subtypes must be substitutable for their base types.

While this may seem intuitive, some programming languages allow subtypes to modify the behavior of their supertypes in ways that can violate this principle, leading to unintended consequences in code. The LSP serves as a guideline to avoid such pitfalls.

Basics of Object-Oriented Design

When discussing object-oriented design, we often encounter two types of relationships:

The LSP suggests that the IS-A relationship alone is insufficient. Instead, it should be replaced with an IS-SUBSTITUTABLE-FOR relationship, ensuring that derived types can stand in for their base types without introducing errors.

Example - The Rectangle-Square Problem

Consider the classic example involving rectangles and squares:

Let’s explore this with some code:

public class Rectangle
{
    public virtual int Height { get; set; }
    public virtual int Width { get; set; }
}

public class AreaCalculator
{
    public static int CalculateArea(Rectangle r)
    {
        return r.Height * r.Width;
    }
}

If we implement a Square class inheriting from Rectangle, we might do something like this:

public class Square : Rectangle
{
    private int _side;

    public override int Height
    {
        get { return _side; }
        set { _side = value; }
    }

    public override int Width
    {
        get { return _side; }
        set { _side = value; }
    }
}

Since a square is a rectangle, Square inherits from Rectangle. We enforce that both the height and width are the same by setting them to _side.

The Problem

Consider the following code:

Rectangle myRect = new Square { Width = 4, Height = 5 };

Assert.Equal(20, AreaCalculator.CalculateArea(myRect)); 
// False - The actual result is 25.

Here, we expect the area to be 4 * 5 = 20, but instead, we get 25. This result is incorrect because the Square class violates the expectations set by the Rectangle class.

What Happened?

The design of the Square class breaks the Rectangle’s invariant, meaning we can no longer substitute Square for Rectangle without causing errors. This is a clear violation of the LSP.

Possible Solutions

To resolve this issue, we can approach it in a couple of ways:

  1. Remove the Square class and add a flag to Rectangle:

    public class Rectangle
    {
        public int Height { get; set; }
        public int Width { get; set; }
        public bool IsSquare => Height == Width;
    }
    
  2. Create a separate Square class without inheriting from Rectangle, representing a square explicitly:

    public class Rectangle
    {
        public int Height { get; set; }
        public int Width { get; set; }
    }
    
    public class Square
    {
        public int Side { get; set; }
    }
    

This approach avoids violating the LSP and provides a clearer representation of both shapes.

Detecting LSP Violations in Real Life

1. Type Checking with is or as in Polymorphic Code

Type checking in polymorphic code often signals an LSP violation.

Example

foreach (var user in users)
{
    if (user is Admin)
    {
        Helper.PrintAdmin(user as Admin);
        break;
    }
    Helpers.PrintUser(user);
}

This code checks if a user is an Admin, which suggests that the Admin type isn’t fully substitutable for the base User type. If additional subtypes like RestrictedUser are introduced, you’ll need to update all type checks, violating the Open/Closed Principle (OCP).

Solution

Ensure subtypes are fully substitutable by implementing custom functionality in each subtype:

foreach (var user in users)
{
    user.Print(); // Each user type implements its own Print method.
}

Alternatively, adjust the Helper method to work with any user type, ensuring the loop doesn’t violate the LSP.

2. Null Checks

Null checks can also indicate LSP violations, as nulls aren’t substitutable for actual instances.

foreach (var user in users)
{
    if (user == null)
    {
        throw new NullReferenceException();
    }
    Helpers.PrintUser(user);
}

Instead of checking for nulls, consider using the Null Object Pattern to avoid this issue.

3. NotImplementedException

Throwing a NotImplementedException can indicate that only some features of an interface or base class were implemented, violating LSP.

Example

public interface ILoggingService
{
    void SendWarning(string message);
    void SendInfo(string message);
}

public class WarningService : ILoggingService
{
    public void SendWarning(string message)
    {
        // Send the user a warning message.
    }

    public void SendInfo(string message)
    {
        throw new NotImplementedException();
    }
}

In this example, WarningService doesn’t fully implement ILoggingService, making it non-substitutable and thus violating the LSP.

LSP as a Subset of Polymorphism

Polymorphism describes an IS-A relationship, while the LSP extends this to an IS-SUBSTITUTABLE-FOR relationship. Classes that adhere to LSP also adhere to polymorphism. Violating LSP usually indicates a violation of polymorphism.

Fixing LSP Violations

1. Follow the “Tell, Don’t Ask” Principle

Avoid querying an object’s type or state to make decisions. Instead, encapsulate behavior within the object:

foreach (var user in users)
{
    user.Print(); // Tell the user to print itself.
}

This leads to more modular, cohesive designs.

2. Address Null References

Utilize C# features such as nullable reference types, null conditional operators, and guard clauses. Alternatively, implement the Null Object Pattern to handle nulls more gracefully.

3. Fully Implement Interfaces

When implementing an interface, ensure all methods are fully implemented, adhering to the Interface Segregation Principle (ISP) to avoid LSP violations.

Key Takeaways