The Liskov Substitution Principle (or LSP) is one of the big five SOLID principles and one that is often poorly understood. Yet, ignorance of the LSP can lead to subtle, expensive, and sometimes embarrassing bugs.

The LSP can be stated in many ways, from the original formulation (which has to be regarded by non-mathematicians as dense to the point of being cryptic):

Let phi(x) be a property provable about objects x of type T. Then phi(y) should be true for objects y of type S where S is a subtype of T.

To the oft-stated:

If code uses a Base class, then the reference to the Base class can be replaced with a reference to a Derived class without affecting the functionality of the code.

What is often missed, however, is that the type compatibility goes both ways - they are promises made by types as much as they are promises required by methods.

Consider this method declaration:

public void SendMessages(IList<EmailAddress> addresses)

Under the LSP this method needs to be prepared to accept any implementation of IList<EmailAddress> - it must not be written in a way that requires, say, List<EmailAddress> to be passed every time.

The reverse is true as well. Consider this class declaration:

public class ContactList : IList<EmailAddress>

By declaring that the class ContactList implements IList, we are making a promise that all the semantics of the IList interface will be correctly met, not just the syntax (with the obvious note that only the syntax is enforced by the compiler).

What happens if we implement our own version of IList with subtly different semantics, say that our Add() preserves uniqueness, like so:

public class Set<T> : IList<T>
{
    public void Add(T item)
    {
        if (!Contains(item))
        {
            _items.Add(item);
        }
    }

    // elided
}

This is dangerous exactly because it looks superficially acceptable - most methods that require an IList<T> to be provided as an argument will work fine when given a Set<T>, giving a false sense of safety.

But then you’ll inadvertently find the one method that relies on the ability to store the same instance in a list multiple times. If you’re lucky, you’ll end up with a subtle and hard to diagnose error. If you’re not, your system will quietly do quite the wrong thing - such as sending a payment with the wrong amount, or issuing a prescription with too few drugs.

Unfortunately, the CLR includes some well known examples of what not to do, such as the way Array implements IList but throws an exception if you call Add() or Remove(). It would have been far better had it simply implemented IEnumerable (and then IEnumerable<T>), but that boat sailed years ago and we can’t fix the problem now without breaking too much working code.

A non-CLR example that I saw several years ago was a configuration file reader for .INI files that provide access to the keys and values in each section by implementing IDictionary<string, IDictionary<string,string>>. This went poorly because it had very tight restrictions on what was permitted for section, keys, and values, throwing unexpected exceptions when other values were provided.

The key takeaway here is that you should be conservative with the promises your classes make. If you indicate your class implements IList<T>, then it needs to implement all of the interface, including the semantics that the compiler doesn’t check for you automatically.

Comments

blog comments powered by Disqus
Next Post
Redux Middleware Implementation  28 Mar 2020
Prior Post
Redux Middleware  14 Mar 2020
Related Posts
Browsers and WSL  31 Mar 2024
Factory methods and functions  05 Mar 2023
Using Constructors  27 Feb 2023
An Inconvenient API  18 Feb 2023
Method Archetypes  11 Sep 2022
A bash puzzle, solved  02 Jul 2022
A bash puzzle  25 Jun 2022
Improve your troubleshooting by aggregating errors  11 Jun 2022
Improve your troubleshooting by wrapping errors  28 May 2022
Keep your promises  14 May 2022
Archives
March 2020
2020