Do Not Apply Code Heuristics When You Need a Broader Perspective
You can only improve things inside the frame you pick. If your frame is too narrow for the problem you try to solve, you cannot properly take everything into perspective. That’s a trivial statement as it’s only re-stating the same thing, but it’s worth stressing.
Apply this to code.
If you focus on code heuristics to improve your code base, you cannot improve the structure of your program. Even though the structure is manifested as code, it’s not code you should be thinking about. It’s concepts. Code is just the textual representation you stare at all day. Structure is what the imaginary entities of your invention bring forth.
What are “code heuristics”?
In general terms, it’s thinking inside the terms of the solution space or implementation: Functions, classes, libraries. An opposite of the solution space is the problem space or problem domain. Your code to maintain air traffic contains methods that are called; the problem is about airplanes and collision courses. Your implementation is a translation of the problem domain, of what real people talk about, into machine-readable commands; that’s the translation from problem to solution space.
For example, the “Don’t Repeat Yourself” principle (DRY) is a code heuristic. It states that you should avoid pasting the same code all over the place. Instead, the DRY principle advises you to extract similar code into a shared code path, e.g. a function or method that can be reused.
Please note that “extract” is also a common refactoring term and “reuse” generally has a positive connotation. Doesn’t mean it’s a good idea to follow it blindly. Because when all you know are code-level tricks and heuristics to improve the plain text files, you sub-optimize for code. This endangers the problem–solution-mapping as a whole.
sub·op·ti·mi·za·tion:
especially : optimization of a part of a system or an organization rather than of the system or organization as a whole
You endanger the solution you are trying to improve when your criteria of “better” and “worse” are only relative to the level of code – like “do these parts of code look the same?”
One problem with following DRY blindly is you end up with extracting similar-looking code instead of encapsulating code that means or intends the same. This is sometimes called “coincidental cohesion”. You wind up reducing code duplication for the sake of reducing code duplication.
If you confuse mere repetition of the same characters in your source text files with indenting the same outcome, you can end up with 1 single function that you cannot change anymore without affecting all the wrong parts of the program. This becomes a problem when you need to adjust the behavior in one place but not another. You cannot adjust this if the code is shared. (If you’re lucky, you duplicate the code again; if you’re unlucky, you change the shared code and break another part of the app.)
Take this code:
let x = 19 * 2
let y = 19 * 4
A DRYer example:
func nineteenify(num: Int) -> Int {
return num * 19
}
let x = nineteenify(2)
let y = nineteenify(4)
But what if one of the 19
are about the German VAT and Germany’s tax legislation changes the rate to 12, and the other 19
is just your lucky number used for computing an object hash, or whatever? (Both similar-looking “magic numbers” represent concepts, albeit different ones in this case!)
Another code heuristic is called symmetry. The lines that make up your methods should look similar to increase readability.
Take the following code for example. You are not satisfied with it anymore because each line looks different than all the others:
func eatBanana(banana: Banana) {
let peeledBanana = banana.peeled
guard isEdible(peeledBanana) else { return }
self.assimilateNutrients(peeledBanana)
}
To improve symmetry of the code and thus increase readability, you figure that this sequence is essentially about a transformation in 3 steps. Also, you like custom operators for their brevity and how code reads with the bind
or apply
operator: |>
. Think of it as applying filters here. You increased terseness to reduce noise and end up with this
func eatBanana(banana: Banana) {
self.prepare(banana: banana)
|> self.checkEdibility
|> self.assimilateNutrients
}
// Extracted:
private func prepare(banana: Banana) -> PeeledBanana? {
return banana.peeled
}
private func checkEdibility(peeledBanana: PeeledBanana) -> PeeledBanana? {
guard isEdible(peeledBanana) else { return nil }
return peeledBanana
}
Looks better, maybe? If you understand how the operator works, this could be useful to state the intent of eatBanana
more clearly as a step of value transformations.
Questions not asked in the meanwhile:
- What does the rest of the class look like? Are there 100 other methods in the affected type? Does this one method become harder to read but the type becomes harder? (Another code-level heuristic.)
- Whose responsibility is the preparation of bananas? Do these methods fit together into a single object? Is the receiver of these methods the best or just whatever is accidentally owning
eatBanana
? Should this be separated into multiple objects with more focused responsibilities instead? If you did that, would the original problem of symmetry vanish already? (This is not a code-level decision anymore.)
I hope this illustrates what a focus on the solution space brings forth. To take your code as a piece of text you can run grep
or sed
transformations on to improve it may work, but if all you do is extracting similar strings of characters from text files manually, do you ask the right questions?
In a similar fashion, Design Patterns are code templates. They help write code that solves a particularly intricate problem. Design Patterns are complex solutions, but they are confined to the solution space nevertheless. This includes the popular MVVM as well as the Factory or Visitor design pattern.
“To implement MVVM, you need a Model type, a View type, and a View Model object/protocol/… that is based on the Model but exposes an Interface tailored to the View.” – This is a recipe for writing code. It does not and it cannot answer the tough question: what is the best abstraction for your model? What components do you need to translate the problems of air traffic control into an iOS app, for example?
Is an Aircraft
class a good abstraction?
This question cannot be answered by reading more code. The answer cannot be sped up with recipes.
This question can only be answered by thinking about the problem.
I think part of software architecture is modeling the problem domain, and isolating your translation of the original problem from e.g. UIKit code properly. That’s when architectural heuristics come into play, like “use layers to separate different parts of your app and avoid a mess.” The VIPER pattern is a higher-level code pattern that affects your architecture.
And this is what bugs me about most of the conversation in our community at the moment: the conflation of Design Patterns (code heuristics) with “Application Architecture” (which goes way beyond that). When we keep these concepts separate, we can continue to focus on one at a time and get better. It’s like the DRY problem from above all over again: if your frame is too narrowly focused on visible repetition on your screen, you cannot have the bigger picture in mind.
If we continue to fail to distinguish these concepts, we end up losing the ability to ask some kinds of questions.
And I argue some of these are the really important ones that help your software as a solution to a real-world problem continue to thrive.