What’s So Bad About Inheritance?
“Object-oriented programming considered harmful”
In recent(ish?) years, the software community seems to have converged on the opinion that “object-oriented programming is bad”1. This has rubbed off on me and I have inherited2 the habit of generally avoiding classes; unfortunately, I did so without really understanding why “OO” is bad. Here I am to collect my thoughts and discuss what parts of OO actually are bad (and what aren’t), and why.
Specifically people are speaking about OO in the tradition of Java (as opposed to e.g. Smalltalk).
haha
What I mean by “object-oriented”
The term “object-oriented” has more than one meaning, so it’s worth pinning down what sense of OO is the one people have come to dislike. Namely, it’s programming characterized by the following:
Most values carry a reference to another value, which we’ll call its “prototype”
Prototypes can themselves carry references to prototypes (the “prototype chain”)
When resolving an attribute/method on a value, we follow the prototype chain. To find
o.x
, we first look to see if o
has x
; if not, we look at its prototype, and then it’s prototype’s prototype, and so on. (this is inheritance)
Most value share their prototypes with a large number of other objects3
I’ve specifically avoided talking about “objects” or “classes” in this definition. That is because the style of OO I am trying to pin-down does not at all rely on language features but is really a design pattern which can (in theory) be performed in any language.
The problem is overrides
The fundamental issue that I understand with this-style OO is specifically inheritance with overrides. Let me give an example5 and we can discuss it abstractly afterwards.
Credit where credit’s due. Both my understanding of why “OO is bad” and this example stem from an article I read years ago which slowly fermented in my mind. Unfortunately, despite repeated attempts, I have been unable to find this article. If I recall correctly, it spoke about the “three pillars” of object-oriented design (inheritance, polymorphism, and abstraction?) and how OO fails to adhere to all of them.
Say we are working in a context providing a linked-list abstraction adhering to the following interface
class LinkedList {
static new() // construct an empty list
add(x) // add a single item
addAll(xs) // add all items from another list
length() // compute length
iter() // implement some iterator protocol
}
Because LinkedList
is a linked list, this means that its length()
method runs in time. Sometimes this is acceptable; sometimes it isn’t. Let’s say that we want a faster length()
operation. Well, we know how to solve this—subclass LinkedList
and override its methods to keep track of how long the list is at all times.
class OurList extends LinkedList {
static new() { super.new(); this.length = 0; }
add(x) { super.add(x); this.length++; }
addAll(xs) { super.addAll(xs); this.length += xs.length(); }
length() { return this.length; }
}
6 Kerblam! A few lines of code and now we have runtime for the length()
method. Plus, because our class extends LinkedList
, it can be used as if it were a LinkedList
. In other words, most of the codebase doesn’t even have to know that OurList
exists for us to reap the performance benefits!
I hope you can forgive my strange code formatting. The intent behind it is to increase the vertical density of the writing, so that a reader can more efficiently skim and traverse it.
Great, except that this code is fundamentally wrong. The OurList
implementation is at worst broken and at best can break at any moment.
Let’s say that LinkedList
implements addAll
like this:
class LinkedList {
add(x) { ... }
addAll(xs) { for (x in xs) this.add(x); }
}
Now what happens when we call OurList.new().addAll(xs)
? It first calls super.addAll(xs)
, which dispatches LinkedList.addAll
. This implemention loops over each x
in xs
and calls this.add(x)
on each. this.add
dispatches to OurList.add
, meaning that each x
gets added to the list and causes this.length
to get incremented. Then, once LinkedList.addAll
completes, we return to OurList.addAll
and again increment this.length
. We’ve double-counted!
Ooookayyyy... So remove the this.length += xs.length()
line, right? Increment length
only on add()
but not addAll()
. Problem solved?
Still no. Another reasonable implementation for LinkedList
looks like this:
class LinkedList {
addAll(xs) { ... }
add(x) { this.addAll([x]); } // [x] is the singleton array
}
Now we have the same problem for OurList.add
instead of addAll
. Calling OurList.new().add(x)
will dispatch through LinkedList.add
to this.addAll
which is OurList.addAll
, which will increment length
; then, OurList.add
will again increment length
.
Because LinkedList
makes no guarantees about how it implements add
and addAll
, it’s unsafe to add length
increments in either case.
What went wrong?
We wanted to have a class OurList extends LinkedList
which kept a length
counter. We found that the obvious way to do this is to override add
and/or addAll
to increment an internal length
counter. However, both options are unsafe because they depending on implementation details of LinkedList
.
And that’s the essential issue: overrides encourage poor abstraction boundaries.
On the one hand, a subclass ought to not know or care about how its superclass is implemented. This is the point of a class, after all—it’s supposed to be a black-box abstraction.
However, we’ve seen that practically this just isn’t the case. If we want—for example—OurList
to work properly, it has to know about how LinkedList
implements add
and addAll
.
More abstractly, we can phrase the issue like this:
Say we have
class Lo extends Hi
where Lo
overrides the m
method on Hi
eg,
OurList extends LinkedList
overriding addAll
The API for
Lo
wants to make some guarantees about what m
does
eg, guaranteeing
addAll
increments length
properly
In order to do so,
Lo
may need to make some assumptions about the implementation of Hi
eg, that
LinkedList.addAll
does not call this.add
Such an assumption is usually a violation of the class abstraction boundary7
A little disambiguation here.
LinkedList
may very well guarantee that "LinkedList.addAll
does not call this.add
". If this is the case, one can implement OurList.add
in a way that does not make invalid assumptions.
(NOTE TO SELF—uhhhh yeah doesn’t that actually make this all fine?)
However, this guarantee—that "LinkedList.addAll
does not call this.add
"—views classes as boxes of code, rather than as abstract objects, which is arguably problematic in its own right.
(section — what’s NOT the issue here — interface inheritance. classes are bad only b/c closed namespaces. instanceof is usually bad.) (section — so just don’t use inheritance? — then classes are pointless, all a class is is an implicit namespace and dynamic dispatch is moot)