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:
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:
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:
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