Problems with object-oriented programming
There’s a lot of writing out there about what “good code” looks like, especially within the object-oriented programming community. I mostly work with imperative and object-oriented programming languages, but a lot of the ideas I’ve heard about what “proper OOP” looks like don’t resonate with me.
I recently flipped through a copy of 99 Bottles of OOP, which is one such “what does good object-oriented code look like” book. I’ve heard good things about it, and I was curious how much it would align with my opinions and how much I’d disagree with. And, well… I disagreed with a lot.
The premise of the book is that it presents us with the problem of writing a program that prints out the “99 bottles of beer on the wall” song, and then walks us through an object-oriented development process to create a program using strategies that scale to larger projects. I’m not very impressed by the code the book comes up with, though.
Here’s the solution they provide:
class Bottles {
song() {
return this.verses(99, 0);
}
verses(starting, ending) {
return downTo(starting, ending)
.map(i => this.verse(i))
.join('\n');
}
verse(number) {
const bottleNumber = BottleNumber.for(number);
return (
capitalize(`${bottleNumber} of beer on the wall, `) +
`${bottleNumber} of beer.\n` +
`${bottleNumber.action()}, ` +
`${bottleNumber.successor()} of beer on the wall.\n`
);
}
}
class BottleNumber {
static for(number) {
let bottleNumberClass;
switch (number) {
case 0:
bottleNumberClass = BottleNumber0;
break;
case 1:
bottleNumberClass = BottleNumber1;
break;
case 6:
bottleNumberClass = BottleNumber6;
break;
default:
bottleNumberClass = BottleNumber;
break;
}
return new bottleNumberClass(number);
}
constructor(number) {
this.number = number;
}
toString() {
return `${this.quantity()}${this.container()}`;
}
quantity() {
returnthis.number.toString();
}
container() {
return 'bottles';
}
action() {
return `Take ${this.pronoun()} down and pass it around`;
}
pronoun() {
return 'one';
}
successor() {
return BottleNumber.for(this.number - 1);
}
}
class BottleNumber0 extends BottleNumber {
quantity() {
return 'no more';
}
action() {
return 'Go to the store and buy some more';
}
successor() {
return BottleNumber.for(99);
}
}
class BottleNumber1 extends BottleNumber {
container() {
return 'bottle';
}
pronoun() {
return 'it';
}
}
class BottleNumber6 extends BottleNumber {
quantity() {
return '1';
}
container() {
return 'six-pack';
}
}
The book is very proud of this code. It describes it like this —
This code is a tribute to the intense simplicity achieved by deft handling of complexity. It’s a testament to the efficacy of programming by identifying smells and removing them using well-known refactoring recipes.
— and that seems awfully generous to me.
Suppose you want to find out what verse the code prints when there are six bottles of beer on the wall.
- First you look at the
song
method, which tells you each verse is constructed withverse
. - You look at
verse
, which tells you that the verse number is turned into aBottleNumber
through the constructor methodfor
. - We scroll down to
BottleNumber.for
and see that when there are six bottles of beer, we construct aBottleNumber6
object. - We go back to
verse
and take a look at the rest of the method, and try to figure out what that prints. - In order to find out how
${bottleNumber}
is interpreted, we scroll down toBottleNumber6
and check whether it has a customtoString
method. - It doesn’t, so we scroll back up to
BottleNumber
and read itstoString
method, which calls thequantity
andcontainer
methods. So we scroll back down toBottleNumber6
to see if it has a customquantity
method, which it does, and whether it has a customcontainer
, which it also does. - Likewise, we scroll down to check whether
BottleNumber6
has a customaction
method. It does not, so we scroll back up to read the defaultaction
method, which invokes thepronoun
method. Again, we scroll down to look for an overriddenpronoun
method onBottleNumber6
, but we do not find one. So we scroll back up and read the defaultpronoun
implementation. - Lastly, we scroll down to
BottleNumber6
to look for asuccessor
implementation. There isn’t one, so we scroll back up and read the defaultsuccessor
method, which mercifully does not call any other methods.
I hope that list was less painful to read than it was to write. No wonder the code feels hard to read — everything is so indirected!
Here’s how I’d write this program:
function composeSong(numberBottles=99) {
return inclusiveRange(0, numberBottles)
.reverse()
.map((i) => verse(i, numberBottles))
.join("\n")
}
function verse(number, startNumber) {
let phrases1 = findLinePhrases(number)
let nextNumber = number == 0 ? startNumber : number - 1
let phrases2 = findLinePhrases(nextNumber)
let result =
`${capitalize(phrases1.quantity)} ${phrases1.items} `
+ `of beer on the wall, `
+ `${phrases1.quantity} ${phrases1.items} of beer.\n`
+ `${capitalize(phrases1.action)}, `
+ `${phrases2.quantity} ${phrases2.items} `
+ `of beer on the wall.`
return result
}
function findLinePhrases(number) {
let quantity, items, pronoun, action
if (number == 0) { quantity = "no more" }
else if (number == 6) { quantity = String(1) }
else { quantity = String(number) }
if (number == 1) { items = "bottle" }
else if (number == 6) { items = "six-pack" }
else { items = "bottles" }
if (number == 1) { pronoun = "it" }
else { pronoun = "one" }
if (number == 0) { action = "go to the store, buy some more" }
else { action = `take ${pronoun} down, pass it around` }
return {quantity: quantity, items: items, action: action}
}
That’s less than half as long as the original implementation, and substantially more legible.
If you want to find out what the program prints when there are six bottles, you read printSong
, which points you to verse
.
The template for the verse is in verse
, and all of the phrases that fill in that template are determined in findLinePhrases
.
There’s no scrolling from parent class to subclass to trace behaviour.
Nothing relevant is hidden away from you.
All of the program logic is condensed into four brief conditionals, almost reminiscent of a small spreadsheet.
This, I consider an example of intense simplicity.
I don’t think many people would dispute that this version is simpler than the original. An OOP advocate might argue, however, that it is less extensible and maintainable than the original polymorphic example. I disagree with that.
Instead of being thinly spread out over a variety of classes, the logic of the second program is all in one place. You always know where the code you’re looking for is without having to search for it. It’s easy to add a row to one of the conditionals, to change a row, or to add another conditional. Being concise is not the same as being hard to work with. In this case, I think being concise makes it easier to work with.
In my version of the code, I have findLinePhrases
split into four conditionals.
I could refactor it into one big conditional, if I wanted to.
I have it split up this way because I think it’s nicer, but we’re at a point where we could really go either way.
If the logic leans towards a few distinct alternatives, one big conditional might be the clearest option.
In other cases, though, setting it up as one big conditional leads to real problems.
What if we want to do this?
if (number == 0) { quantity = "no more" }
else if (number == 6) { quantity = String(1) }
else if (hasThreeDigits(number)) { quantity = `${number} (three digits!)` }
else { quantity = String(number) }
if (number == 0) { action = "go to the store, buy some more" }
else if (isEven(number)) { action = `take ${pronoun} down, put it in the fridge` }
else { action = `take ${pronoun} down, pass it around` }
This is harder to do if we have findLinePhrases
implemented as one big conditional.
As you add in overlapping conditions, the number of cases you have to write into that conditional grows rapidly.
And when you implement your logic using a class hierarchy, you’re necessarily using one big conditional.
You’re going to have to create new 3DigitBottleNumber
and EvenBottleNumber
classes.
And what about the even numbers with three digits?
Do they use multiple inheritance, or do we refactor from inheritance to composition & build our BottleNumber
s with a BottleNumberBuilder
?
I don’t think polymorphism is a good approach in this situation. In my opinion, the “bottles of beer” song is a pretty good example of a problem which invites a simple conditional solution.
Now, none of this is to say that class-based polymorphism doesn’t have its place. It absolutely does. For example, suppose we wanted a filesystem abstraction that could read and write files to a variety of storage backends, including local disks, remote disks, and cloud object storage. It absolutely makes sense to create a polymorphic interface which abstracts the file access methods across those backends. Each backend has wildly divergent behaviour and needs to be constructed in different ways, but we want them to be used interchangeably in a variety of contexts. Maybe the different backends are even being developed by different teams. Trying to centralize all those different implementations into one set of conditionals would be much more complicated than just splitting it up.
But 99 Bottles of OOP thinks that this kind of polymorphic solution should be used everywhere.
As an OO practitioner, when you see a conditional, the hairs on your neck should stand up. Its very presence ought to offend your sensibilities. You should feel entitled to send messages to objects, and look for a way to write code that allows you to do so.
I have a real problem with this. See, while polymorphism is an excellent choice for high-level, strongly-divergent logic like a storage backend, most of the code we write isn’t at that level. Day to day, most of the problems we deal with are bottles-of-beer problems. And when you apply high-level architectural patterns to pedestrian situations, you wind up with code that is more than twice as long as it needs to be, and substantially harder to read and work with.
I think the book’s perspective here is far too absolutist. We should prefer conditionals in most situations. I don’t have a problem with objects or classes; they’ve absolutely got their uses. But I do have a problem with a particular notion of “Good and Proper Object-Oriented Code” which asks us to use objects and classes everywhere, even when they’re the wrong tool for the job.
There’s a quote from Code Complete which goes like this:
Classes and routines are first and foremost intellectual tools for reducing complexity. If they’re not making your job simpler, they’re not doing their jobs.
I find myself reminded of that quote a lot when I engage with the 99 Bottles of OOP school of philosophy. When classes and objects are just getting in our way and making our code harder to understand and modify, we shouldn’t be using them.