Understanding the Liskov Substitution Principle (LSP) with C#

Understanding the Liskov Substitution Principle (LSP) with C#

In our ongoing series on SOLID principles, we've already covered the Single Responsibility Principle (SRP) and the Open/Closed Principle (OCP). In this post, we'll dive into the Liskov Substitution Principle (LSP), the third principle in the SOLID acronym.

Liskov Substitution Principle

The Liskov Substitution Principle (LSP) was introduced by Barbara Liskov in 1987. The principle states that objects of a base class should be replaceable with objects of a derived class without affecting the correctness of the program.

In simpler terms, if a class B is a derived class of class A, then instances of A should be replaceable with instances of B without altering the desirable properties of the program.

This principle helps us design a system where derived classes extend the functionality of the base class without changing the expected behavior from the client’s perspective. If a derived class modifies the behavior of a base class in such a way that it violates the expectations set by the base class, then it’s not adhering to LSP.

Example Scenario

Let’s look at a real-world example to understand LSP in the context of a Library Management System.

Initial Design

Imagine we have a simple class hierarchy to manage library items. We start with a base class LibraryItem and have two derived classes: Book and ReferenceBook.

public class LibraryItem
{
    public string Title { get; set; }
    public string Author { get; set; }

    public virtual void BorrowItem()
    {
        Console.WriteLine($"{Title} borrowed.");
    }

    public virtual void ReturnItem()
    {
        Console.WriteLine($"{Title} returned.");
    }
}

public class Book : LibraryItem
{
    public override void BorrowItem()
    {
        base.BorrowItem();
        Console.WriteLine($"{Title} is due in 14 days.");
    }
}

public class ReferenceBook : LibraryItem
{
    public override void BorrowItem()
    {
        throw new InvalidOperationException("Reference books cannot be borrowed.");
    }

    public override void ReturnItem()
    {
        throw new InvalidOperationException("Reference books cannot be borrowed or returned.");
    }
}

In the above design:

  • LibraryItem is the base class with common properties like Title and Author.

  • Book is a regular library item that can be borrowed and returned. This is a derived class.

  • ReferenceBook, however, is a type of LibraryItem that cannot be borrowed or returned. This is also a derived class.

Violating LSP

Now, consider the following code that uses these classes:

public void ProcessBorrowing(LibraryItem item)
{
    item.BorrowItem();
}

If we pass an instance of Book to ProcessBorrowing method, it works as expected. However, if we pass an instance of ReferenceBook, it throws an exception. This violates LSP because ReferenceBook does not behave as a proper derived class of LibraryItem as it changes the expected behavior of BorrowItem method.

Refactoring to Adhere to LSP

To adhere to LSP, we need to rethink our design. Since not all LibraryItems can be borrowed, we should separate the borrowable items from non-borrowable ones. This can be achieved by introducing an interface:

public interface IBorrowable
{
    void BorrowItem();
    void ReturnItem();
}

public class LibraryItem
{
    public string Title { get; set; }
    public string Author { get; set; }
}

public class Book : LibraryItem, IBorrowable
{
    public void BorrowItem()
    {
        Console.WriteLine($"{Title} borrowed.");
        Console.WriteLine($"{Title} is due in 14 days.");
    }

    public void ReturnItem()
    {
        Console.WriteLine($"{Title} returned.");
    }
}

public class ReferenceBook : LibraryItem
{
    // No implementation of IBorrowable, as reference books can't be borrowed.
}

Now, our LibraryItem class is a general class that doesn't impose borrowing behavior. The Book class implements the IBorrowable interface, making it clear that Book can be borrowed. The ReferenceBook class doesn't implement IBorrowable, so it doesn't participate in borrowing operations.

The ProcessBorrowing method can now be refactored to handle only borrowable items:

public void ProcessBorrowing(IBorrowable item)
{
    item.BorrowItem();
}

This way, only objects that implement IBorrowable will be passed to ProcessBorrowing, adhering to LSP.

Benefits of Adhering to LSP

By adhering to LSP, our code becomes more reliable and easier to maintain. We avoid unexpected exceptions and ensure that our derived class implementations are consistent with the expectations set by the base class.

Here are some key benefits:

  1. Predictable Behavior: Substituting one class for another does not introduce unexpected behavior or errors.

  2. Extensibility: Adding new types of borrowable or non-borrowable items becomes straightforward.

  3. Maintenance: The code is easier to understand and maintain since the responsibilities and behaviors of classes are well-defined.

Summary

The Liskov Substitution Principle ensures that a class hierarchy remains consistent and predictable, which is essential for building scalable and maintainable systems. In our Library Management System example, refactoring our design to adhere to LSP resulted in a cleaner and more robust implementation.