Writing simple code has many benefits. By reducing complexity, the code is therefore easier to understand, which in turn makes it easier to read, modify and weed out any bugs that may be lurking within. I think a major indicator of complexity is often related to scope. What I mean is, how much context do I need to have in my brain in order to understand a chunk of code.
There are a lot of common rules we abide by that I believe stem from this principle. For example, we are often told to keep files small, which came from a time when they often looked like this:
#include <stdio.h>
int SOME_STATE;
// ...
int do_something() {
return SOME_STATE++;
}
Another principle originating during this period was to avoid global
state. Both relate to how much context had to be known and understood to
also understand a subset of code. In the above case, because a global
variable is used in do_something
, to understand what that function
does we also have to understand every other place in the file that uses
SOME_STATE
. In this case, the size of the file directly relates to how
difficult do_something
is to understand.
In a talk titled “Keep It Local”, Chris Krycho talks about this concept of reducing complexity by keeping the required context small. This really clicked for me, as so many of the principles I advocate for on a regular basis stem from this. It’s why this code bothers me:
function validateRequestA(request) {
request.errors = [];
if (request.protocol !== "https") {
request.errors.append("something bad");
}
// ...
}
and this code doesn’t:
function validateRequestB(request) {
const errors = [];
if (request.protocol !== "https") {
errors.append("something bad");
}
// ...
return errors;
}
Both functions are performing mutations. The difference?
validateRequestA
has side effects, meaning that it will affect code
outside itself, whereas validateRequestB
does not have side effects.
Instead, validateRequestB
keeps these mutations local to itself.
The problems with validateRequestA
are outlined with the following
usage:
const request = buildRequest();
validateRequestA(request);
if (request.errors.length === 0) {
sendRequest(request);
}
To understand what sendRequest
will do, we also have to understand how
buildRequest
and validateRequestA
work. Compare this to the
following:
const request = buildRequest();
const errors = validateRequestB(request);
if (errors.length === 0) {
sendRequest(request);
}
Now we just need to understand how buildRequest
works to understand
what sendRequest
will do! We have reduced the context required to
understand sendRequest
, and as such have reduced complexity!
The unfortunate thing about languages like javascript are that such
things are not guaranteed. Even though validateRequestB
does not
mutate the request, there is nothing stopping it from doing so. Compare
this to a language like rust, where the two function calls would look
this:
validateRequestA(&mut request);
validateRequestB(&request);
In rust we have mechanisms to communicate whether or not an argument can be mutated, and the compiler will ensure you don’t violate this contract.
As Chris Krycho mentions in his talk, many of the patterns and best practices we learn relate to this principle. Keep files small. Avoid mutations. Avoid goto. Avoid global state. Smaller arguments. Smaller functions. The list goes on. I’d argue that by understanding this underlying principle, we are better equipped to evaluate and improve our own code. We are also better equipped to determine which best practices are actually helping us, and which are potentially redundant1.
-
For example, I’ve already mentioned that reducing file length has often been recommended to reduce complexity. This holds true in OO languages, where mutations to the instance often take place throughout the file, but in pure functional languages this relation breaks down. The context of a pure function is itself, the functions it calls, and the arguments. The length of the file no longer contributes to complexity. ↩︎