Object-Oriented Programming: A Disaster Story
[Edit: I’ve greatly expanded on these ideas in a 45 min. video.]
Over my whole programming experience, I've gone back and forth on the question of object-oriented programming, yay or nay. In this last year, though, I've finally settled conclusively in the nay column, and this is my attempt to articulate exactly why.
First, a little story:
In the beginning, there was spaghetti code.
And Dijkstra said, ‘Let there be Structured Programming! Thou shalt consider goto harmful and organize your code into functions with proper control flow mechanisms.’
And programmers said, ‘OK, sure we’ll do that.’
Then Dijkstra saw that code was still spaghetti and said, ‘Stop sharing state willy-nilly! Thou shalt avoid global variables and instead pass all state through the call graph.’
And programmers said, ‘Er, um, wait, really? We haven’t really figured out this functional programming thing, nor do we want to pay the overhead of immutable data on today’s machines, so what you’re proposing is horribly impractical and inconvenient for non-trivial programs.’
But the programmers did agree that shared state is problematic and that maybe they could cut back on all these global variables.
And so Object-Oriented Programming was born, and the global variables lived happily ever after disguised as singleton object fields.
What is Object-Oriented Programming?
Before tearing OOP down, let me define what I’m talking about. OOP comes down to three things: polymorphism, inheritance, and encapsulation.
Polymorphism, whether good or bad, needn't really be tied to inheritance hierarchies and so probably shouldn't be considered exclusive to OOP.
Inheritance was a bad dream that (most) people have woken up from.
That leaves encapsulation, and encapsulation, confusingly, has at least two different meanings which I’ll tackle in separate parts:
Encapsulation = managing state
Though Object-Oriented Programming was popularized in the late 80’s and early 90’s under the banner of reusable code, the original impetus behind it in the 70’s was the desire to divide and conquer state by sorting state into separate boxes called ‘objects’.
In this original vision, an object-oriented program is composed of a graph of objects, each an island of state unto itself. Other objects do not read or write the state of other objects directly but instead send messages. While messages may trigger updates of state in the receiving object and the receiving object may return information about its state, state itself, strictly speaking, does not leak out of one object to another. In short, the objects do not share or pass around references to state but instead only report to each other with copies of stateful data. In this way, each object retains exclusive control of its own state, much like computers on a network may share copies of their memory content but remain in total control of their own memory.
It’s a beautifully simple story, so far. But if we take the ‘islands of state’ idea seriously, we end up not with a graph of stateful objects but rather a strict hierarchy of composition. All of a program’s state ends up in a single root object, itself composed of stateful objects, which in turn may be composed of stateful objects. The objects of this hierarchy may pass messages to their immediate children, but not to their ancestors, siblings, or further descendants.
What could be more organized than a clean hierarchy? Nothing. The problem, however, is that, while organizing program state into a hierarchy is easy, OOP demands that we then organize program state manipulation into the same hierarchy, which is extremely difficult when we have a non-trivial amount of state. Anytime we have a cross-cutting concern, an operation that involves multiple objects that aren't immediately related, that operation should reside in the common ancestor of those objects. Not only does this mean that operations often end up in unintuitive places, these operations necessitate a cascade of associated operations to reach down to the relevant pieces of state.
Of course, in real-world practice, programmers generally avoid this bothersome pattern by promiscuously sharing object references as they find convenient—but doing so totally breaks the strict hierarchy of contained state. Real-world object-oriented code tends to be a mish-mash of leaking encapsulated state, in which every object potentially mucks with every other. Chaos reigns with Cat and Dog objects living together as state encapsulation flies out the window the moment that programmers encounter its true costs.
Even if you don’t have concurrency, I think that large objected-oriented programs struggle with increasing complexity as you build this large object graph of mutable objects. You know, trying to understand and keep in your mind what will happen when you call a method and what will the side-effects be. —Rich Hickey (inventor of Clojure)
Now, the correct conclusion from this, I think, is not that encapsulating state in a hierarchy is a bad idea—on the contrary, to the extent we have state, all state should ideally be encapsulated, and stateful objects should ideally be structured in a strict hierarchy rather than as a free-form graph. But note the key phrase there: ‘to the extent we have state’. To successfully manage state without giving ourselves aneurysms, we should keep our object hierarchies as shallow as possible.
To pull this off, we need to minimize our use of state, and the paradigm that minimizes state is, of course, functional programming. So the ‘everything is an object’ form of OOP is the wrong solution for the problem of state management, but OOP at least (originally) attempted to solve the right problem.
OO makes code understandable by encapsulating moving parts. FP makes code understandable by minimizing moving parts — Michael Feathers @mfeathers.
Encapsulation = associating behaviors with data
Here’s the other problem with encapsulation:
As traditionally taught, OO design proceeds by first identifying the data entities in our problem domain, then associating behaviors with each entity. For example, having identified the need for a Message type and settled upon its fields, we’re then supposed to think of the things we might want to do with a Message and implement those things as methods on that class.
But when we consider the needed functionality of our code, many behaviors are inherently cross-cutting concerns and so don’t really belong to any particular data type. Yet these behaviors have to live somewhere, so we end up concocting nonsense Doer classes to contain them. Not only does this make little intuitive sense, by encapsulating functionality into more objects, we’re actually burdening ourselves with more state to manage. (And these nonsense entities have a habit of begetting more nonsense entities: when I have umpteen Manager objects, I then need a ManagerManager.)
Consider a very basic question: should a Message send itself? ‘Sending’ is a key thing I wish to do with Messages, so surely Message objects should have a ‘send’ method, right? If Messages don’t send themselves, then some other object will have to do the sending, like perhaps some not-yet-created Sender object. Or wait, every sent Message needs a Recipient, so maybe instead Recipient objects should have a ‘receive’ method.
This is the conundrum at the heart of object decomposition. Every behavior can be re-contextualized by swapping around the subject, verb, and objects. Senders can send messages to Recipients; Messages can send themselves to Recipients; and Recipients can receive messages.
Now, you might object that one of these options is the most natural. Maybe so. But message sending is a relatively concrete action. Most things we do in code are highly abstract with rarely any ‘natural’ division of responsibilities—which explains why, given the same non-trivial problem, no two programmers ever produce the same object design.
Sure, we also see with functional decomposition that no two programmers divide the work into functions the same way. However:
- Unlike objects, plain functions don’t have to be managed and orchestrated into all the places they get used.
- Restructuring functions requires restructuring data much less often than when moving methods between classes.
Object-Oriented Programming is supposed to help us manage state and model problems in terms of familiar objects, but it turns out to be really, really easy to put behaviors in the wrong objects and thereby produce Frankenstein entities that make no natural sense. We quickly end up with unnecessarily complicated and confusing code that actually exacerbates the proliferation of state and promiscuous state sharing (e.g. a Message wants to send itself, so now a Message needs a reference to a Connection object that it otherwise wouldn't need).
Because it’s so easy to put responsibilities in the wrong place, object-oriented code doesn't tolerate incremental design very well. Yes, in theory, perfectly decomposed classes are easily supplemented with additional classes, but in practice, class decompositions are never perfect, and so every new class and method tends to add to the existing confusion and produces more work for later restructuring.
With procedural code—particularly pure functional code—even when the division of responsibilities amongst the functions is sub-optimal, new functions and data types can generally be added without making the existing code messier. Object-oriented design, in contrast, much more often punishes programmers for not thinking ahead.
For years, I took it on authority that Object-Oriented Programming was The Right Way to code, even though I had the constant sense that every class and method I wrote was creating problems for myself down the line, that every possible object decomposition was at best arguable and would eventually need restructuring. Fitting every problem into the mold of classes felt like playing a fool’s game with no right answers.
Now that I've stopped chasing the chimera of ‘proper’ object decomposition, I’m much happier and more productive. Of course, in procedural programming, there is no one way to properly decompose a solution either. But with procedural programming, I no longer feel like I’m adding a layer of structure that provides no benefits yet adds complications and confusion to my code.