Classes are Cheap. Object Orientation is Expensive
An object can have private fields and still be changed by code that never calls its methods. Classes make object orientation easy to adopt and surprisingly expensive to live with.
AI can generate classes faster than we can tell whether their objects still own their representation.
The Five Pillars
Object orientation is often explained through five pillars, usually classes, inheritance, abstraction, encapsulation, and polymorphism.
Classes and inheritance are ways to implement and organize a program. Abstraction belongs to all programming, and polymorphism takes many forms. The form relevant here is dynamic single dispatch. A message is addressed to one identifiable object, which decides at runtime how to respond.
That leaves two ideas at the center of this article. An object receives messages and owns its representation.
Ownership of Representation
Encapsulation should let an object decide whether and how its state changes. Private fields restrict direct access, but they do not guarantee ownership of the mutable data the object holds.
Ownership does not require secrecy. An object can let others see its state without giving them the authority to change it. The trouble begins when a reference crosses that boundary and someone else retains the ability to change what the object depends on.
This is usually a misunderstanding of the contract, not ill intent. Consider the following C# code.
using System.Collections.Generic;
public class Inventory<T>
{
// private backing field
private readonly List<T> _items = new();
// public getter exposing items as read only
public IReadOnlyList<T> Items => _items.AsReadOnly();
// The inventory should be in control, not the client
public void Add(T item) => _items.Add(item);
}
And simple usage of that inventory:
var inventory = new Inventory<string>();
var items = inventory.Items; // Before AddItem
inventory.Add("Foo");
// The read only list fetched before it contains "Foo"
// even though it's a read-only list
foreach (var item in items)
{
Console.WriteLine(item);
}
We are misled into believing that items will not change because Inventory inventory exposes it as read-only. The read-only view may still change underneath the client. This is a matter of trust. We think that things that can’t be changed, won’t change. But it did change, right in front of our eyes.
IReadOnlyList limits what we can do through this reference. It does not promise that what we see will stay the same.
If the elements themselves are mutable, the ownership problem becomes more serious. Lets say we have an inventory of some classic composite like:
public record Item(string Name, int Age)
{
public string Name {get; set; } = Name;
public int Age {get; set; } = Age;
}
and hand a read only list of these over to a foreign function like:
public void ForeignFunction (IReadOnlyList<Item> items)
{
var first = items[0];
...
}
The function is foreign, either because someone else wrote it, own it, or just simply because it’s happens to be out of context from the programmers view. We simply don’t bother about the implementation right now, and perhaps not in the future either. It’s a client after all.
We use it with our inventory:
var inventory = new Inventory<Item>()
inventory.Add( new("Adam", 4));
var items = inventory.Items;
ForeignFunction(items);
...
Suppose ForeignFunction saves items to use later. We then add a Clear operation to Inventory and call it.
...
inventory.clear();
The saved read-only view is now empty. ForeignFunction still holds the same reference, but what it sees through that reference has changed.
Other users of that inventory would be out of luck when the programmer of the foreign function realize, in its context, that items are indeed mutable.
public void ForeignFunction (IReadOnlyList<Item> items)
{
var first = items[0];
...
items[0].Name = "Bravo";
}
… as the inventory will unexpectedly change, and affect every user of that inventory.
These semantic mistakes happen far too frequently in most object oriented systems. It is very difficult to make sure objects own their own representation. Most systems aren’t just a few objects to reason about, but at least hundreds. The accidental complexity, and the inability to reason about it, explodes, and most of us stop caring.
Fully immutable objects help, but that is not what object orientation is about. An object was supposed to own its representation, and allow it to change.
Memory Is Time
An object’s state changes through time. An object has an initial state, followed by new states. It has one state before receiving a message and another afterwards. The difference between those values is the change caused by the message.
Comparing the state before and after a message separates what objects tend to combine: identity, value, and time. The identity remains the same, while distinct values describe the object at different points in time.
Consider a thermometer. At one point in time, the thermometer reports the value 20. At a later point, the same thermometer reports the value 21. The thermometer retains its identity, while the values 20 and 21 are distinct and ordered in time. The number 20 does not change into 21. The number 20 is always 20.
The same when an object’s state is composite, as with the items in the previous example. An item may first have the value Adam, 4 and later the value Bravo, 7. Its identity has not changed. We still refer to the item as the same entity, but it has been associated with different values at different points in time. The fields name and age can both change in response to a single message, and we can compare the complete value before the message with the complete value afterwards.
The values describing the object’s state through time can be immutable. A message then causes the object’s identity to become associated with a new immutable value. No intermediate value needs to become the object’s observable state. Its state is either the value before the message or the value afterwards, regardless of how it is observed.
The problem goes away
When state is represented by immutable values, the problem of owning the representation goes away. Anyone can receive and retain a state value without gaining the ability to change the object. There are no surprises hidden inside the value since the value itself remains stable.
When asked for its current state, the object can return the current value directly, regardless of whether that value is simple or composite. The caller receives a stable snapshot. The object may later move on to another value, while the value already returned remains.
The object owns the decision to move from one state to another. It no longer needs to protect the state value itself. And those that need the current value can just ask for it.
If it now feels more natural to reason about the values than about the objects that hold them, we are moving toward data-oriented programming.
Copying Does Not Scale
One way to prevent Inventory from exposing its list is to return a copy. The caller can change that list without changing the inventory.
But the copy still contains references to the same items. If a caller changes an item, the inventory can still change behind its own back.
A deep copy could provide a stable value by copying the items and every mutable part they contain. That becomes expensive as the representation grows. Arrays make the cost particularly clear. Preserving an old array while changing one element requires allocating a new array and copying all its elements.
Persistent Data Structures
We want to keep the old value without copying everything to produce the next one. Persistent data structures do this by sharing the parts that have not changed.
Lisp programmers have long used a simple persistent data structure without necessarily calling it one. Adding an element to the front of a linked list creates a new cons cell that points to the old list. Both lists remain available, and their shared tail needs no copying.
A persistent data structure preserves earlier values when a new one is produced. It also keeps its operations efficient as the collection grows. Trees make this possible. Changing one part creates new nodes along the path to that part, while the rest of the tree is shared with the old value. This is called path copying.
The Clojure language applied this approach to their vector-like collection. Its vector uses a very shallow tree with up to 32 branches at each node. Changing an element copies the path to that element rather than the entire vector. I later implemented persistent vectors for Java and F# because I wanted the same way of working with values in those languages.
Identity and CAS
Suppose an Inventory holds its current state as one immutable value. What happens when two operations try to change it at the same time?
To add an item, we read the current value and calculate a new one. Compare-and-swap, or CAS, replaces the current value only if it is still the one we read. If another operation changed the inventory in the meantime, our replacement fails. We read its new current value and calculate again.
The inventory retains its identity throughout this process. Readers can safely hold on to either the old or the new value. The inventory’s state changes, but neither value does.
Because the calculation may run again, it must not perform an external effect each time. Sending a message or writing to a database belongs after a successful transition, where the outcome can be handled explicitly.
For this model to work, values published through CAS must remain immutable. CAS orders successful updates to one reference. It does not tell us which things should have references of their own. The items may simply be parts of the inventory’s state. We still have to decide what may change independently and what must change together.
Values in Object-Oriented Languages
Working with immutable values no longer requires a language designed entirely around functional programming. Java has records and factories for unmodifiable collections. C# has records, init-only properties, and immutable collections that produce new versions when changed. These tools make it easier to describe a value, construct the next one, and keep the previous one available.
They also require care. A record can contain a mutable object, and a collection that cannot be changed may still contain mutable items. The tools help us express the model, but we must choose values that remain stable all the way down to the parts we share.
Platforms like Java and .NET expose CAS, through AtomicReference, and Interlocked.
With that in place, we can stop treating each object as a separate hiding place for state and start looking at the state of a whole system.
The state
Suppose two objects must change together when a message passes between them. We can represent their combined state as one value. Before the message, that value describes both objects as they were. Afterwards, a new value describes them as they are. The difference tells us what the interaction changed.
The same reasoning can extend to three objects, four objects, or the state of an entire system. We can calculate a possible next value without making it current. If we reject it, the previous value remains. If we accept it, one CAS operation can publish the new value, so all its parts become current together.
This leads naturally to a pattern called functional core, imperative shell. The functional core receives the current state and a message, then calculates a proposed new state and any effects that should follow. The imperative shell publishes the new state and performs those effects at the edge of the system.
The outside world may refuse an effect. A message may not be delivered or a database write may fail. When that outcome is reported back, it becomes another message to the functional core and part of the next state transition. Failure can then be visible in the state instead of being hidden in a log.
A system has a state, the state. It can be the program’s one moving part, held by a single reference that moves from one immutable value to the next. Hiding its parts inside separate objects only makes that state harder to reason about.
Two Answers to Procedural Complexity
A procedure, as in procedural programming, is easy to understand when its inputs and effects are clear. Trouble begins when many procedures can reach into the same mutable state. To understand one operation, we must then find every other operation that might have changed the data it uses.
Object orientation addresses this by giving an object authority over its state and behavior. Other parts of the program send it messages, and the object decides how to respond. Functional programming addresses the problem by making state an explicit value. Functions calculate new values from old ones, while effects are kept at a boundary.
The approaches make different kinds of change easy. In a class-based design, we can often add a new kind of object that responds to existing messages. Adding a new operation may require changing every class that should understand it. With functions over a fixed set of data variants, adding an operation is straightforward. Adding a new variant may require revisiting every function that handles the existing ones.
This tension is known as the expression problem. We want to extend a system with both new kinds of data and new operations without repeatedly rewriting what already exists. Neither putting all behavior inside objects nor putting all behavior in functions settles that question by itself.
Immutable values solve the problem of safely sharing state. They do not decide how behavior should be extended. We still have to choose where decisions belong and how new behavior can be added.
Clojure and the Expression Problem
Clojure’s protocols offer another way to organize behavior. A protocol defines operations without requiring a type to implement them when it is created. New types can be added to existing protocols, and new protocols can be added to existing types. An implementation can even be supplied by someone who defined neither the type nor the protocol.
This makes both directions of extension possible without placing every operation inside the type it acts upon. Protocol functions use dynamic single dispatch on their first argument, but the value does not necessarily own its behavior. Clojure gains flexibility by allowing behavior to be defined separately from data.
This addresses the expression problem. It also takes us away from the strict object orientation described at the start of this article, where the receiving object decides how to respond.
Elixir, the True Object-Oriented Language
Elixir adopted the idea of protocols from Clojure. Its process model, however, comes from Erlang. The two mechanisms serve different purposes. Protocols let functions behave differently for different kinds of values. Processes receive messages.
An Elixir process has an identity and owns its local state. Other processes cannot reach into that state and change it. They can send messages, but the receiving process decides how to respond. After handling a message, it can continue with a new immutable state value.
The process keeps its identity while the values describing it change through time. It combines message passing with ownership of representation, without requiring a class. By the two criteria used in this article, an Elixir process may be one of the truest objects in programming.
Where Objects Belong
Objects are useful when the things we model have identities of their own. A simulated person, a GUI component, or a running process may receive messages, react, and continue to exist in a new state. Their behavior depends on which particular entity receives the message.
In such systems, addressing an entity and letting it decide how to respond is a natural model. Its internal state can still be represented by immutable values, so other parts of the system can observe it without gaining control over it.
But many things we put into classes have no independent life. They are values we calculate, compare, combine, and pass around. Giving every value an identity and a private place to hide state makes a system harder to understand.
Python, AI, and the Speed of Complexity
Python makes small programs easy to write. As they grow, ownership becomes harder to see. Python has no private instance variables, and its usual programming model relies on mutable data passed between functions and objects. A class may look self-contained while its state can be changed from elsewhere.
AI can extend that pattern quickly. It can add classes, services, and tests that work locally while following the ownership rules implied by the existing code. If those rules were never clear, each plausible addition makes the system harder to reason about.
Classes are cheap to generate. Understanding what has an identity, who controls its state, and what happens when effects fail is still expensive.