The 'L' in SOLID
Uncle Bob's aptly coined SOLID Design Principles form the basis of a robust software application. Today, I want to talk about one of those principles, the Liskov's Substitution Principle (LSP) because it's easy to deviate from, and a few conscious design choices can prevent us from doing so.
In the simplest terms, LSP suggests that:
Any change that makes a subtype not replaceable for a supertype, should be avoided.
Suppose, we have a class hierarchy like so:
At the first glance, the relationships here seem fine, but if we carry out an IS-A test, the issue becomes obvious: that Tea isn't necessarily a CaffeinatedDrink (for instance: there's decaf!).
Thus, this design violates LSP, because it indicates that all Teas are Caffeinated Drinks. Now, a naïve approach would be to try to retrofit this design, to allow for decaf teas as well -- by adding a flag or suchlike -- but that would be clumsy!
There are several ways to deal with this anomaly, and the decision can be based on the stage of development we're at, along with other factors. So, let's continue with our example and see how can it be dealt with:
We know for sure that we'd need to pull Tea out of this hierarchy. Though Coffee looks more justified there, we can pull that out as well, to keep things crisp (and also because someone told you about 'Decaf Coffee' as well!).
A better option, thus, seems to be:
For common behaviour of Teas & Coffees, introduce a Drink type
Both
Tea
andCoffee
can then be subtypes ofDrink
Caffeinated
can just be an interface which is implemented as needed
Upon this change, we don't cringe anymore to say that Tea
IS-A Drink
, with Caffeinated
behaviour. Whereas, a DecafTea
differs from it. Another perspective could be, Coffee
is substitutable both for Drink
or Caffeinated
, but DecafTea
is substitutable ONLY for a Drink
.
Another approach is to follow Effective Java [Bloch, 2017, Item 18]: Favor composition over inheritance. With this,
Drink
becomes a member ofTea
andCoffee
, andCaffeinated
(interface) is implemented by all but, say,DecafTea
.
Here, we do away with the class hierarchy, and directly use the concrete instances of individual drinks. However, we do keep the Caffeinated
behaviour separated, and again, can safely say that Tea/Coffee
IS-A Caffeinated
drink. Moreover, we're also getting a more robust design because of disallowing (class-based-) inheritance.
How do we ensure we come-up with LSP-compliant design? Well, there are few simple things that can be borne in mind while working on class associations:
Intuition: Is it sounding right? [Example: Should
StudentEnrollment
really extendStudent
, when all it wants is to access someStudent
properties?]Concatenation test: Do the Parent and Child types sound right upon concatenation? [Example: While
Flyer
+Bird
may sound correct, aFlyer
+Chicken
may not. So does 'Flyer
' need to be a class type or an interface type?], and finally and most importantly,IS-A test: Is the IS-A condition holding good?