The Liskov Substitution Principle is an Object-Oriented class design guideline that says that a sub-class should be substitutable for a super-class and that clients to the super-class should not be impacted.
You achieve this by specifying a contract for each public method of a class.
First, you specify a pre-condition/requirement.
If the caller meets the requirements, then the method makes a promise of a post-condition and result.
A sub-class is substitutable if it makes the same promises under the same requirements. It is also substitutable if it can make the same promise with fewer requirements. And, finally, it’s still substitutable if it makes more promises.
So, sub-classes can require less and promise more than the specification of their super-classes. But, they can neither require more nor promise less.
Some OO languages, like Eiffel, have ways of encoding the contract, but in Swift and many other languages, this is something usually stated in documentation.
But, substitution is not just an OO principle, or at least, I don’t only apply it to super/sub class relationships. I think it should be applied to versioning of any public library of any kind of code (OO, functional, whatever). To do this, think of vNext as a subclass of vCurrent.
So, vNext can require less and promise more. It can’t:
- Add new required parameters to a method
- Add exceptions to methods that didn’t have them before
- Change the name of a method
- Change the return type unless the new type is substitutable
- Change parameter types unless the old type is substitutable
- Add new required methods to a prototype
Unfortunately, many languages would think of some acceptable changes as a compile-time breaking change. For example, in Swift if a method returned T?
and you changed it to return T
, then callers that used guards would not compile. Strictly speaking, T
is always substitutable for T?
, but you should not do this change.
This is not how many library authors think, Instead, I think we’ve been corrupted by semantic versioning and the idea of acceptable breaking changes. Under substitutable versioning, there is no such thing as a breaking change.
Under semantic versioning (major.minor.patch), most library providers only think of patch version increases as being substitutable and for sure major version increases are not. I would say in practice, minor version increases are sometimes breaking.
In a fully substitutable versioning system, this would never be the case. vNext would always be substitutable for vCurrent. This means that:
- Old classes and methods could be marked as deprecated, but are never removed.
- Things that would be changed in major versions are done in new namespaces or with new class names.
- To plan for this, perhaps the original namespace would use a versioning scheme.
- Any client that compiled and worked with an old version would compile and work with a new version.
To be clear, if clients write to implementations and not specifications, they might break with future versions, so documentation needs to clearly explain what is required and promised. The actual behavior should not be confused with the contract.
A common example of this is relying on buggy behavior. If I call a method and it has some bug, I might write code to work-around that bug. If a future version fixes the bug and my software breaks because of my work-around, that’s still considered a substitutable change as the bug was not a promised behavior.
In a talk on clojure specs, Rich Hickey called this concept accretion and railed against breaking change. I know that semantic versioning is too entrenched right now to change, but something like what Rich is describing would do a lot to relieve dependency hell.