Literal Composition Is Not Abstraction
Code is read far more often than it is written, and “hard to read” has a measurable cognitive substrate (Code and Cognition). Every piece of information a reader encounters should either have a strong association in long-term memory or a short resolution path through working memory. When neither is true, comprehension becomes costly.
This part of the Hardly Readable series is about a common misunderstanding: removing duplication can support readability, but it can also have the opposite effect. The latter occurs quite often if we do not introduce an abstraction but a Literal Composition.
Removing duplication means extracting code - but into what?
We cannot simply remove duplication. The duplicated code has to go somewhere: into a method, a class, a generic, or a higher-order function. Extraction is unavoidable. The mistake is to assume that extraction itself creates an abstraction.
An extraction can result in one of two fundamentally different things:
-
An abstraction: a new concept. Its name conveys more than the sequence of operations it replaces, the interface hides the implementation, and readers who understand the concept rarely need to look inside.
-
A literal composition: a named bundle of concrete steps. Replacing
add(x); delete(y);withaddAndDelete(x, y)introduces no new concept. Nothing became less concrete; the same operations merely moved behind another level of indirection.
The distinction matters because indirection is not free. An abstraction repays its cost by allowing readers to reason at a higher level. A literal composition offers no such benefit. It preserves all the detail while making the reader travel further to recover it. (And ruling out a literal composition is only the first hurdle - even a genuine abstraction can be the wrong one, but that is a topic for a later part of this series.)
A literal composition is worse than the duplication
Literal composition shortens the textual repetition, but we should ask whether that shortening buys anything else. Take an extreme example:
add(1);
del(2);
add(3);
becomes
addAndDelAndAdd(1, 2, 3);
This is shorter, but it destroys the association between each predicate (verb) and its argument. add(1) ties the predicate add to its argument 1 directly; addAndDelAndAdd(1, 2, 3) has to be parsed first - the reader works out that the first operation is add(1), the second del(2), and so on, before understanding anything. The duplication was longer but honest: every operation sat next to its argument. The literal composition hid nothing and added a decoding step, so it is strictly worse.
How to recognize a literal composition
The reliable indicator for literal compositions is conjunctional words in the name of the method or class, like addAndDelete, validateAndSave, loadOrCreate, buildThenSend, computeIfAbsent. A conjunction in the name reveals that the name denotes no concept of its own - it is an enumeration of its parts. A real concept does not need “and” to describe itself.
Yet there are subtle differences between different conjunctions. and and then are very often easy to replace; or, if and when are harder.
Alternatives to literal composition
A conjunction in a name is a signal, and it shows up in two situations. Sometimes you are reading existing code and want to repair a name that has gone wrong. Just as often you are writing new code, reach for a name, and the first one that comes to mind is a literal composition - loadOrCreate, validateAndSave, writeAndClose - which is the moment to look for the abstraction before it sets in the codebase. Either way the question is the same: what concept should stand here instead of the enumeration of steps? The answer depends on which conjunction you are tempted by - and the two cases from the previous section diverge sharply here.
Unconditional conjunctions
A sequence joined by and or then is usually held together by a shared purpose, and that purpose is the name we are looking for. Consider this sequence, scattered across the code:
var file = open("file.txt");
file.write(2);
file.close();
The literal composition would be openFileAndWriteValueAndCloseFile("file.txt", 2) - which almost nobody would write. The name everyone reaches for instead is:
writeToFile("file.txt", 2);
and this is a genuine abstraction. It hides opening and closing because the purpose is simply that the value ends up in the file; opening and closing are necessary details of that purpose, not concerns the caller chose. We can even keep each argument next to the predicate it belongs to with a fluent form:
writeTo("file.txt").value(2);
// or
write(2).toFile("file.txt");
The same move dissolves validateAndSave (validation is a precondition of saving, not a separate concern the caller chose) or serializeAndSend (the purpose is to send; serialization is merely how).
This is no accident. I have never found a cohesive sequence - statements that must run in a fixed order and belong together - that could only be expressed as a literal composition. The reason is structural: cohesion arises because the statements share a purpose (write a value to a file), and that purpose is exactly the name the abstraction wants (writeToFile). So for and and then, the resolution is almost always the same: name the purpose, and the conjunction is gone.
Conditional conjunctions
A conditional conjunction - signaled by or, if, or when - is harder, because the concept itself contains a decision rather than a straight sequence. The two read differently:
oris not a logical or; it marks a fallback - use the second thing if the first is absent or fails (getOrDefault,loadOrCreate).ifandwhenmake a condition of execution explicit (computeIfAbsent,removeIfPresent).
Finding a single concept to replace such a composition is harder, because natural language gives us few words for “do this, or that under a condition”. But they exist:
- In SQL we routinely want a row inserted if it is absent and updated otherwise.
insertOrUpdateis possible, but the community settled on a real concept:upsert. - Many languages simply hide the condition. Java’s
HashMaphasV get(K key), which is reallygetOrReturnNull- the branch is there, but the name omits it, and we just have to know it is not meant literally. - A custom verb can wrap the decision. A method that returns an existing value by key or creates one can be named
register(K key, Supplier<V> ifAbsent). This only works once readers carry a chunk for “register”: there is no shared meaning to lean on, so the name has to be learned (Code and Cognition). Naming the condition itself well is a related problem, one for a later part of this series.
Often there is a better move than naming the branch at all. Because there is a decision, we can make the decision point explicit - split the thing being decided on from what we do about it - and a fluent API does this naturally.
Java bakes the branch into the verb; the name is the control flow:
map.computeIfAbsent(key, k -> new ArrayList<>()).add(value);
Rust first names the concept - an entry in the map, which may or may not be occupied - and the alternative becomes something you do to that entry:
map.entry(key).or_insert_with(Vec::new).push(value);
entry(key) is a noun the reader can hold; or_insert_with builds on it. The control flow is still there, but it is organized around a concept rather than crammed into a single verb.
Java itself uses the readable form elsewhere. For a possibly-absent value, Optional is the named concept and the alternative is expressed as a follow-up operation:
findUser(id).orElseGet(User::guest);
findUser(id).orElseThrow();
orElseGet still contains “or”, but the conjunction now hangs off a concept (Optional) instead of being the whole name. That is precisely the difference between a fluent alternative and a literal composition: one is a step in a concept’s vocabulary, the other is an if wearing a method name.
Conclusion
Removing duplication always means extracting code somewhere, but extraction is not the same as abstraction. When the name of the extracted thing needs a conjunction to describe what it does, no concept was created - only a bundle of steps was moved behind a level of indirection that the reader now has to unpack. That is a literal composition, and it can leave the code harder to read than the duplication it replaced.
The conjunction is the signal, and the way out depends on which one it is. Where and or then join a cohesive sequence, the statements share a purpose, and naming that purpose gives you the abstraction for free - the conjunction simply disappears. Where or, if, or when encode a decision, the honest move is usually not to name the branch but to make the decision explicit and attach it to a concept the reader can hold, the way Optional or a map entry carries its alternative as a follow-up operation rather than burying it in a verb. In both cases the goal is the same: let the name stand for an idea, not for the control flow it happens to contain.
Naming that idea well is its own problem - a good abstraction can still be the wrong one - and that is where the next part of this series will go.
More
For related discussion and background, see:
- Code and Cognition - why “hard to read” has a measurable cognitive cost, and what chunks and working memory have to do with naming
- Code Smell: No AND in Function name (Samantha Ming) - the conjunction-in-the-name tell as a standalone rule of thumb, reached from the single-responsibility direction