In the art of programming, style matters.
If you’ve been programming for longer than five minutes, it’s a sure bet that at some point your eyes have glazed over while skimming source code longer than a “Hello, world” function. Reviewing someone else’s code, or even your own from some distant point in the past, often equals a long night and a large pot of coffee.
Ask a room full of techies what the top three priorities of writing code are, and you'll probably get as many answers as there are people in the room. Personally, I was taught that above all else, programming should be guided by:
- Correctness: does the code execute properly and as expected?
- Design: is the code a reasonably efficient solution to the problem?
- Style: does the code adhere to some standard of writing and organization?
In practice, I think most people adhere to the first principle as a high priority, and to the second as a natural result of experience. After all, incorrect code is useless code and might as well not exist. And programmers with any amount of decent experience probably value design at least for the sake of simplicity, if not for efficiency. Once you’ve discovered a way to solve the same problem in 5 lines of code that you used to solve in 20, why would you ever go back?
Style, on the other hand, requires much more intentionality. Freshly minted coders want to write code, and lots of it, and certainly some sense of style is absorbed in the process of learning one’s first language, but following standards for formatting, organization, naming conventions…well, it doesn’t help me get code out any quicker and it’s all just a bit much, don’t you think?
Which is certainly the mindset I used to have, as evidenced by looking back at my earliest work. Lack of comments, no consistency in naming variables and functions, variables that have no purpose because they’re never called…it’s a mess.
These days I consider myself to be a much more style-aware programmer, but one of the disciplines I learned much later than I’d like to admit is refactoring. While it’s typically thought of as a method to improve code, I think people tend to lean on it mostly to correct bad coding habits.
One such habit that I developed from the moment I learned about conditionals and loops is the overuse of nesting. Stacking conditions like matryoshka dolls made my simple attempts at coding suddenly feel so much more complex, and it was. I went from this:
function printOddNumbers(range) {
for (let i = 1; i < range; i = i + 2) {
console.log(i);
}
}
printOddNumbers(10);
to this:
function printOddAndEven(range) {
for (let i = 0; i < range; i++) {
if (i % 2 === 1) {
console.log(i + "is odd");
} else {
console.log(i + "is even");
}
}
}
printOddAndEven(10);
While this isn’t the most complex example of nested functions, it is unnecessary and is written at the cost of readability and cognitive load. But to my untrained eyes, this was amazing. I could make things do X or Y depending on some condition, and even add more layers of branching paths, and suddenly my code looked (to me, anyway) less like a child’s drawing and more like an art student’s latest portfolio piece.
But our brains tend to dislike complex code. It often lacks recognizable patterns, and our thought processes get bogged down when we encounter needless complexity. Nested code is particularly difficult, because every new layer increases our cognitive load with more information to actively remember and consider.
Enter extraction and inversion. These techniques are key in refactoring nested code, and with enough practice, they help reshape bad coding habits into good ones, so that you write code in ways that requires much less refactoring later.
- Extraction involves taking a block of code out of its parent function and making it into a separate function.
- Inversion involves reversing the logic of a block of code so that the expected result follows a check for error cases.
For example, with extraction the above code might become:
function checkParity(num) {
if (num % 2 === 1) {
console.log(num + "is odd");
} else {
console.log(num + "is even");
}
}
function printOddAndEven(range) {
for (let i = 0; i < 10; i++) {
checkParity(i);
}
}
printOddAndEven(10);
Now the code within the for
loop has become its own function, and not only is the code altogether more readable, it also has the benefit of making that function reusable. Additionally, it follows the Single Responsibility Principle, the rule that “a component should only do one thing.”
There is more that can be done here, though. We can also invert the checkParity()
function:
function checkParity(num) {
if (num % 2 !== 1) {
console.log(num + "is even");
return;
}
console.log(num + "is odd");
}
function printOddAndEven(range) {
for (let i = 0; i < 10; i++) {
checkParity(i);
}
}
printOddAndEven(10);
At first glance, it doesn’t seem like a big difference. After all, it doesn’t get rid of the if
statement or an extra nesting level. And while this is a simple example, and the effect is much more pronounced in more complex functions, the way our brains process the two examples is different.
In the previous example, we read through the if-else
statement with the cognitive load of its condition on our minds from start to finish. In this example, however, as soon as our eyes reach the end of the if
statement, we “close the loop” and discard the cognitive load that the if
statement placed on our minds, and continue on to the next statement.
Again, the effect is minimal for such a simple example. But with a more complex function that has multiple levels of nesting, being able to invert the logic and reduce the levels to more simple statements lets us read and understand code much more quickly.
But equally as important, inverting code can help to make code more robust.
Consider this more complex function:
function makePeanutButterJellySandwich(ingredients) {
if (ingredients.bread >= 2) {
if (ingredients.peanutButter) {
if (ingredients.jelly) {
spread(ingredients.peanutButter);
spread(ingredients.jelly);
assembleSandwich();
return "You made a peanut butter and jelly sandwich!";
} else {
return "You don't have jelly!";
}
} else {
return "You don't have peanut butter!";
}
} else {
return "You don't have enough bread!";
}
}
Refactored to:
function makePeanutButterJellySandwich(ingredients) {
if (ingredients.bread < 2) {
return "You don't have enough bread!";
}
if (!ingredients.peanutButter) {
return "You don't have enough peanut butter!";
}
if (!ingredients.jelly) {
return "You don't have enough jelly!";
}
spread(ingredients.peanutButter);
spread(ingredients.jelly);
assembleSandwich();
return "You made a peanut butter and jelly sandwich!";
}
Here we have three levels of nesting that make it fairly difficult to keep track of what conditions are involved and which else
statement corresponds to which if
. When we reduce nesting to one level, and each if
statement is now on an equal level, the code becomes more readable. It also places the function’s expected result (the “happy path”) at the end of the function, with the if
statements terminating the function when something undesired occurs.
In practice, inverting a function to place the terminating conditions at the beginning is known as returning early, and while it isn’t a perfect design pattern for every situation, it does help keep functions clean and easily readable. It’s a valuable tool for new and experienced programmers alike to clarify and refine code, as well as to promote maintainability, and ultimately a project’s longevity.