Write Simple and No-surprise Code

And improve your productivity in the long run

Liu Tao
The Startup
5 min readJul 3, 2019

--

Photo by Khai Sze Ong on Unsplash

Why should we write simple code

As programmers, one of our most important tasks is to manage complexity. Cognitive load in our code is one primary source of it. If our code is only made up of dull statements, no decision points in between, no fancy data structure or algorithm, the cognitive load is much lower.

Of course, there are cases that we need to use advanced algorithms, maybe for performance reason. Then as a last resort, we should encapsulate them with boring interfaces, with a meaningful name and minimum parameter.

First thing first, try your best to avoid code

The simplest code we could ever produce is no code at all. Code is easy to create but hard to remove. Each developer should have a mindset that your code is your debt, not asset. The more you produce, the more you owe.

To write less, we have to think much much more. Of course, thinking takes more time for now, but this is only a one-time investment. In the long run, you or your organization will be debtless.

Whenever we get any code working, we should not stop there. Believing the working code is best one prevents us from finding a better solution. Instead, we need to read through our working code many times and think hard about places to improve. Often, StackOverflow is the place to find a more elegant solution. In the beginning, we may not know exactly what better solutions are. But at least, we should develop a sense of code complexity, and that’s our motivation to find a simple solution. Remember, never stopping at your first working code, any working solution is just a start point for a better one.

If possible, use annotation

This tip is a followup on the previous one. Many of our crosscutting concern could be handled by annotation, like logging or error handling.
For Java developers, Lombok is an excellent library for spicing up your java, we should use it as much as possible.

For example, instead of doing null checks by ourselves, we can use @NonNull annotation. Instead of writing our own get/setter or constructor, we can use @Getter, @Setter and @AllArgsConstructor. Instead of construct log on our own, we can use @Log to construct it.

All of those seem not a big deal by themselves. However, complexity is accumulated over time, and it’s always better to write a little bit less code.
Also, annotation is more declarative than code. The maintenance cost is smaller in the long run.

Use control only when unavoidable

Of course, no matter how hard we try, we still have to write some code. Fluent API calls is an elegant way to avoid decision point like if/else. Let’s consider following code snippet about deep null checks:

if (a != null && 
a.getItem() != null &&
a.getItem().getValue() != null) {
return process(a.getItem().getValue());
} else {
return false;
}

With Java 8’s Optional, we can write something like the following:

Optional.ofNullable(a)
.map(A::getItem)
.map(Item:getValue)
.map(this::process)
.orElse(false);

Although the second approach has more lines of code, it is still more readable. For cognitive load, the line number doesn’t matter that much, it’s the decision points that make code digestion much harder.

Considering the following example, we can put all logic inside forEach’s lambda:

list.streams().forEach(p -> {
if (p.isValid()) {
process(p);
}
})

Alternatively, we can split the logic and do the filtering in between.

list.streams()
.filter(P::isValid)
.forEach(this::process)

In general, the boring code should have only one execution path and it’s very easy to follow through.

When it comes to OOP, polymorphism is one elegant way to get rid of if/else, we can group logic into subclasses and the compiler or runtime help us pick the expected execution path. So we don’t need to do if/else explicitly in our code. In my option, this is the right use case for inheritance. Inheritance is not the right tool to model complex business domain. Instead, we can use it to replace if/else, or we can use it to share some utility functionality between subclasses.

Extract more data from your code

Notably, this post is all about getting rid of decision points in your code. Extract more data from your code is also one way to achieve it.
Let me use the following use case as an example. There are two address objects we get from the server. We need to check whether these two addresses are the same. The business rule defines that not all fields need to be compared. Some fields should be case insensitive, empty string and null should be considered the same. Instead of comparing those fields one by one, we can do it with the following approach. The code snippet is in Javascript and we use underscore.js to simplify the logic:

let caseInSensitive = (val1, val2) => {
val1 = _.capitalize(val1 || “”);
val2 = _.capitalize(val2 || “”);
return val1 === val2;
}
let normal = (val1, val2) => {
return val1 === val2;
}
let properties = [{
“path”: “addressLine1”,
“comparator”: caseInSensitive
},{
“path”: “streetNumber”,
“comparator”: normal
}
]

Of course, there could more comparators and more properties. With all those defined, the implementation of address comparator is much simpler:

let addressComprator = (addr1, addr2) => {
return _.every(properties, prop => {
let val1 = _.get(addr1, prop.path);
let val2 = _.get(addr2, prop.path),
return prop.comparator(val1, val2);
});
}

As you can see, data is more declarative than code, since it doesn’t have any decision points inside. The more data we can get from the logic, the more declarative our code is.

Separate business logic from error handling

In the ideal world, all your code should be only about happy path. Frameworks, libraries and annotation can handle all the errors for you.
But still, we are not there yet, and we have to handle error in our code. There is a long-lasting debate on the approach for error handling, whether we should use exceptions or return error code. One strong argument for exceptions is that error code has to be checked by its calling method, but exceptions can be thrown far away and can be caught in a central place, like in Spring’s filter. However, if we throw exception too far, we may lose its semantic meaning. From this point of view, Java 8’s Optional is a sweet spot between error code and exception. With Optional, the execution path of your code is mostly about your business logic. You only need handle errors at the end.

if (dto != null) {
result1 = processA(dto);
}

if (result1 != null) {
result2 = processB(result1);
}

if (result2 != null) {
result3 = processC(result2);
}

return result3 != null;

With Java 8’s Optional, we can have something like below:

Optional.ofNullable(dto)
.map(this::processA)
.map(this::processB)
.map(this::processC)
.map(r -> r != null)
.orElse(false)

Clearly, the second approach is much more readable, because there is no decision point between statements, also the intermediate
state is not needed anymore. State and decision point are two sources of complexity.

Summary

In conclusion, thinking harder is the first step and most important mindset to write less code. Whenever you see any if/else in your code, try to think of a way to eliminate this decision point. Also, annotation is a noninvasive way to modify your code’s behavior and should be used as much as you can.

--

--