Making code reusable through public class inheritance (PCI) is so convenient and easy that to say it should be avoided may sound a bit heretical. After all, isn’t this what OOP is about? And yet that’s the position I hold.
To be clear, by PCI I refer to inheriting from an implementation class, not from an abstract interface.
The reason I don’t regard PCI as a premium reuse mechanism has to do with all the extra coupling. Class inheritance (“is-a” relationship) is a stronger form of coupling than composition (“uses-a” relationship). A class that offers its services primarily through PCI is basically saying “to use my services you can’t just hire me, you must become me”. That’s a strong commitment!
The Liskov Substitution Principle (LSP) says that a derived class must not break the contract of its base classes. In other words, it must be possible to pass an instance of a derived class to code that only knows about the base class without breaking that code’s correctness. The classic counter-example is to derive Square from Rectangle and then pass an instance of Square to code that modifies the width of a Rectangle. However the PCI is implemented, virtual or not, it’d be incorrect. Either a square will end rectangular (thus breaking its type’s traits) or the code expecting Rectangle will observe it behave rather non-rectangularly. Therefore per LSP one should not define Square in terms of a PCI of Rectangle, period.
But LSP is not just about what makes good derivations. It’s also about what makes good bases. A corollary of LSP is that a class intended for reuse through PCI must not put its derivations in a position of breaking LSP. Note this implies the base class is committed not only to its own interface, but weirdly to the interfaces of all its derivations too. That’s the funny thing about PCI, it increases the dependency both ways. In the Square/Rectangle example, if we were to disregard PCI LSP and force the inheritance while still trying to maintain some sort of semantic consistency, that’d very likely impact Rectangle’s interface. Not only that, now any future change to Rectangle’s interface must consider the impact on Square, because now Rectangle’s interface belongs to Square too! The flip-side of “you must become me” is “well, in part that makes me become a little like you too”. A base class is forever responsible for the derived classes it has tamed.
Another thing to watch for is that a base class is too convenient a place to put code that is common among its derivations, and very often that convenience gets in the way of good design. The shared code starts to bubble up to the common ancestors (it’s just too convenient) whether it has to do with the class’ original responsibilities or not. Sometimes that code implements a policy that would be better as a late decision, but now the class is forcing its descendents to commit to that policy upfront. I’m sure you have seen it. You need to derive from A to reuse some logic, but A’s grand-grandparent imposes unrelated behavior you’d rather not have in your class. It’s the family curse! Big ripple in the code. In PCI-heavy code, classes have a propensity to become hoarders of functionality, becoming more bloated, complex and brittle as inheritance tree goes down. With Composition, classes tend to stay smaller and more focused (i.e., ‘simpler’), and then you can pick-and-choose the functionalities you need.
That’s not to say PCI doesn’t have its uses. Sometimes you do have a genuine “is-a” relationship. Other times the convenience of PCI is worth the trade-offs and you can keep the usage limited and the hierarchy flat (but you should be ready to refactor at the first sign of trouble). The important thing here is to understand the implications in order to make good design decisions. PCI is a valid OOD technique, but it’s not a panacea. Here’s a quick comparison of PCI and Composition.
The sub-class and super-class interfaces are tightly coupled.
The front-end and back-end interfaces are loosely coupled.
Changes to the interface of the super-class also change the interface of the sub-class and are more likely to ripple to the sub-class’ consumers.
Changes to the interface of the back-end may change the implementation of the front-end, but are less likely to ripple to the front-end’s interface and consumers.
Every change to the interface of the sub-class must be compatible with the interface of the super-class.
Not every change to the interface of the front-end needs to be compatible with the interface of the back-end.
The sub-class must pick the super-class upfront, at compilation time.
The front-end can delay selecting the back-end until needed, at runtime.
The super-class must be instantiated with the sub-class and remains for the lifetime of the sub-class.
The back-end can be instantiated only when needed and destroyed earlier than the front-end.
It’s easy to add new sub-classes because inheritance comes with polymorphism
It’s hard to add new front-ends because delegation has to be written by hand, even when it’s a just bypass
Calls to the interface of the super-class can be dispatched directly to the sub-class through polymorphism
Calls to the interface of the front-end must be relayed to the back-end programmatically