So at what point does a class become too complex to be immutable?
In my opinion it's not worth bothering to make small classes immutable in languages like the one you are showing. I'm using small here and not complex, because even if you add ten fields to that class and it does really fancy operations on them, I doubt it's going to take kilobytes let alone megabytes let alone gigabytes, so any function using instances of your class can simply make a cheap copy of the whole object to avoid modifying the original if it wants to avoid causing external side effects.
Persistent Data Structures
Where I find personal use for immutability is for big, central data structures that aggregate a bunch of teeny data like instances of the class you're showing, like one that stores a million NamedThings
. By belonging to a persistent data structure which is immutable and being behind an interface that only allows read-only access, the elements that belong to the container become immutable without the element class (NamedThing
) having to deal with it.
Cheap Copies
The persistent data structure allows regions of it to be transformed and made unique, avoiding modifications to the original without having to copy the data structure in its entirety. That's the real beauty of it. If you wanted to naively write functions that avoid side effects that input a data structure that takes gigabytes of memory and only modifies a megabyte's worth of memory, then you'd have to copy the entire freaking thing to avoid touching the input and return a new output. It's either copy gigabytes to avoid side effects or cause side effects in that scenario, making you have to choose between two unpleasant choices.
With a persistent data structure, it allows you to write such a function and avoid making a copy of the entire data structure, only requiring about a megabyte of extra memory for the output if your function only transformed a megabyte's worth of memory.
Burden
As for the burden, there's an immediate one at least in my case. I need those builders people are talking about or "transients" as I call them to be able to effectively express transformations to that massive data structure without touching it. Code like this:
void transform_stuff(MutList<Stuff>& stuff, int first, int last)
{
// Transform stuff in the range, [first, last).
for (; first != last; ++first)
transform(stuff[first]);
}
... then has to be written like this:
ImmList<Stuff> transform_stuff(ImmList<Stuff> stuff, int first, int last)
{
// Grab a "transient" (builder) list we can modify:
TransientList<Stuff> transient(stuff);
// Transform stuff in the range, [first, last)
// for the transient list.
for (; first != last; ++first)
transform(transient[first]);
// Commit the modifications to get and return a new
// immutable list.
return stuff.commit(transient);
}
But in exchange for those two extra lines of code, the function is now safe to call across threads with the same original list, it causes no side effects, etc. It also makes it really easy to make this operation an undoable user action since the undo can just store a cheap shallow copy of the old list.
Exception-Safety or Error Recovery
Not everyone might benefit as much as I did from persistent data structures in contexts like these (I found so much use for them in undo systems and non-destructive editing which are central concepts in my VFX domain), but one thing applicable to just about everyone to consider is exception-safety or error recovery.
If you want to make the original mutating function exception-safe, then it needs rollback logic, for which the simplest implementation requires copying the entire list:
void transform_stuff(MutList<Stuff>& stuff, int first, int last)
{
// Make a copy of the whole massive gigabyte-sized list
// in case we encounter an exception and need to rollback
// changes.
MutList<Stuff> old_stuff = stuff;
try
{
// Transform stuff in the range, [first, last).
for (; first != last; ++first)
transform(stuff[first]);
}
catch (...)
{
// If the operation failed and ran into an exception,
// swap the original list with the one we modified
// to "undo" our changes.
stuff.swap(old_stuff);
throw;
}
}
At this point the exception-safe mutable version is even more computationally expensive and arguably even harder to write correctly than the immutable version using a "builder". And a lot of C++ developers just neglect exception-safety and maybe that's fine for their domain, but in my case I like to make sure my code functions correctly even in the event of an exception (even writing tests that deliberately throw exceptions to test exception safety), and that makes it so I have to be able to rollback any side effects a function causes halfway into the function if anything throws.
When you want to be exception-safe and recover from errors gracefully without your application crashing and burning, then you have to revert/undo any side effects a function can cause in the event of an error/exception. And there the builder can actually save more programmer time than it costs along with computational time because: ...
You don't have to worry about rolling back side effects in a function which doesn't cause any!
So back to the fundamental question:
At what point do immutable classes become a burden?
They're always a burden in languages that revolve more around mutability than immutability, which is why I think you should use them where the benefits significantly outweigh the costs. But at a broad enough level for big enough data structures, I do believe there are many cases where it's a worthy trade-off.
Also in mine, I only have a few immutable data types, and they're all huge data structures intended to store massive numbers of elements (pixels of an image/texture, entities and components of an ECS, and vertices/edges/polygons of a mesh).