I wanted to write a newsletter that was topical to the post, but none of my ideas really gelled, so instead here's something else on my mind: when is OOP inheritance better than composition? I'll assume you've all heard "prefer composition to inheritance", which is generally good advice, and I'm interested in where the advice doesn't apply.
(Caveat this is mostly theory crafting. I've run into a lot of cases where this approach seems correct but I wouldn't be surprised if someone found a "better way" without using inheritance.)
Let's take a really simple model case that looks like Python but is really pseudocode:1
@dataclass class TextElement: text: str # Inheritance @dataclass class LinkElementI(TextElement): url: str # Composition @dataclass class LinkElementC: element: TextElement url: str
Most of the advantages and drawbacks of composition have been discussed, in detail, for 30 years. It's more flexible, it doesn't break if someone updates
TextElement, but it means implementing a lot of delegation logic to call
One argument I haven't seen very much is this:
def render(t: TextElement) -> html: ...
render(LinkElementC()) does not.
Inheritance is a form of subtyping. Any function that accepts the parent class can also accept any inheriting classes. The same isn't true for composition, which is an entirely new type. So if you want to define functions over the parent type, you need to use inheritance.
Now the obvious problem with this:
LinkElementI can override
TextElement's methods, which means that you can't safely pass it into a function expecting a
TextElement. This leads to the Liskov Substitution Principle: to safely use inheritance, you should be able to pass the child object into methods expecting the parent object. IE subclasses should extend but not change the observable behavior of the parent class.
This'll come up later.
LSP as a design principle comes from Robert Martin. He got it from A Behavioral Notion of Subtyping, which tries to find sets of properties that satisfy the Subtype Requirement:
Let ϕ(x) be a property provable about objects x of type T. Then ϕ(y) should be true for objects y of type S where S is a subtype of T.
If you read the paper, you'll see that it's about formalizing subtypes, not finding good design principles. Barbara Liskov never said that inherited classes should be subtypes, just the conditions where they are subtypes. The LSP comes later.
(Also, SOLID papers usually leave out the "history rule", which is that state changes on the subtype have to compatible with state changes on the supertype. That's why
Square isn't a subtype of
Rect, if they're both mutable.)
Elisa Baniassad and Alexander Summers have this great paper Reframing the Liskov Substitution Principle through the Lens of Testing, where they teach LSP as "the superclasses test suite should automatically be runnable, and pass, on the child class." Go read it, it's great.
Okay, back composition vs inheritance. There's an approach that gets type-safety without inheritance: interfaces!
interface Renderable: ... class TextNode implements Renderable: text: str class LinkElementC implements Renderable: element: TextElement url: str def render(r: Renderable) -> html: ...
Funnily enough, interfaces are a variant of abstract data types, which were also invented by Liskov. It won her a Turing Award.
So are there any advantages of inheritance over composition + interfaces? There is one: interfaces aren't implementations. They're just type signatures. With inheritance, you get all of the superclass's implementation, which you can then override.
But then you run into LSP violations and all the other reasons you'd want to use composition instead. Hm.
So here are the benefits of inheritance:
So, here's when you want to use inheritance: when you need to instantiate both the parent and child classes and pass them to the same functions. That's it. That's the use case.
I really like this condition. For one, in most online examples of "prefer composition to inheritance", the condition doesn't apply. Here's the diagram from the wiki page:
It makes sense that
Duck compose with
Quackable and not inherit from them. Are you ever going to have a free-floating
Flyable in your codebase? Of course not! I'd even say that
MallardDuck shouldn't inherit from
Duck if you can cleanly avoid that, because you're never expecting to use a free-floating
Duck in your code. Compose it and use a delegator.
What's a case where the condition does hold? How about
LinkElement! That's in fact how docutils represents the restructuredText node tree, and it works very, very well. It's really easy to add custom nodes with custom logic to your documentation and have it integrate with the rest of the toolchain without any boilerplate or adaptors.
There's also a broad category of uses, one where I don't have a good word for it, so I'll just call it
If we do inheritance "properly", then we can swap every instance of the parent class with the subclass at runtime. So we can do something like:
class MallardDuck: ... class TestingMallardDuck(MallardDuck): ... class ProfilingMallardDuck(MallardDuck): ...
The base class of
MallardDuck is the "default" class we use in production. When we run our test suite, we swap it for
TestingMallardDuck, which has extra testing methods and assertions and stuff. When we run the profiler, we use
ProfilingMallardDuck, which stores extra calling information and adds extra hooks for our profiler. All of these substitutions produce the same observable behavior for all of our "production" methods, but give us extended behavior for our new execution purpose.
Inheritance is a useful tool if we need to run our program under the supervision of other code, for the purpose of querying or analysing the original program. That's probably the way I use it the most, and the situation in which I miss having inheritance the most.
I don't think it'll forever be the right tool for doing this. Someone will eventually come up with a better way, and we'll generally prefer that to inheritance. The same thing happened with code reuse, subtyping, namespacing, and specialization. Inheritance was the first feature that did any of these things, all of the better approaches we now use came after.
Why are composition and inheritance always taught with physical objects, when the vast majority of OOP classes are computational abstractions? Nobody actually makes a
Dog class, but they do make
Logger classes. I think it's because physical objects are universally known while computational abstractions aren't. If you use
TextElement in a class you risk alienating half the class. ↩