Effective Kotlin Item 10: Design for readability
This is a chapter from the book Effective Kotlin. You can find it on LeanPub or Amazon.
It is a known observation in programming that developers read code much more than they write it. A common estimate is that for every minute spent writing code, ten minutes are spent reading it1. If this seems unbelievable, just think about how much time you spend reading code when you are trying to find an error. I believe that everyone has been in this situation at least once in their career when they’ve been searching for an error for days or weeks, just to fix it by changing a single line. When we learn how to use a new API, it’s often from reading code. We usually read code to understand the logic or how the implementation works. Programming is mostly about reading, not writing. Knowing this, it should be clear that we should code with readability in mind.
Reducing cognitive load
Readability means something different to everyone. However, some rules are formed on the basis of experience or have come from cognitive science. Just compare the following two implementations:
Which one is better, A or B? Using the naive reasoning that the one with fewer lines is better is not a good answer. We could remove the line breaks from the first implementation, but this wouldn’t make it more readable. Counting the number of characters would not be very useful neither. Especially since the difference is not big. The first implementation has 79 characters, and the second has 68. The second implementation is only a bit shorter, but it is much less readable.
How readable both constructs are, depends on how fast we can understand each of them. This, in turn, depends greatly on how much our brain is trained to understand each idiom (structure, function, pattern). For a beginner in Kotlin, surely implementation A is way more readable. It uses general idioms (if/else, &&
, method calls). Implementation B has idioms that are typical of Kotlin (safe call ?.
, takeIf
, let
, Elvis operator ?:
, a bounded function reference view::showPerson
). Surely, all these idioms are commonly used throughout Kotlin, so they are well known by most experienced Kotlin developers. Still, it is hard to compare them. Kotlin isn't most developers’ first language, and we have much more experience in general programming than in Kotlin programming. We don’t write code only for experienced developers. The chances are that the junior you hired (after fruitless months of searching for a senior) does not know what let
, takeIf
, and bounded references are. It is also very likely that he has never seen the Elvis operator used this way. That person might spend a whole day puzzling over this single block of code. Additionally, even for experienced Kotlin developers, Kotlin is not the only programming language they use. Many developers reading your code might be experienced with Kotlin, but certainly they will have more general-programming experience. The brain will always need to spend more time recognizing Kotlin-specific idioms, than general-programming idioms. Even after years with Kotlin, it still takes much less time for me to understand implementation A. Every less-known idiom introduces a bit of complexity. When we analyze them all together in a single statement that we need to comprehend nearly all at once, this complexity grows quickly.
Notice that implementation A is easier to modify. Let’s say we need to add an operation to the if
block. This is easy in implementation A; however, in implementation B, we can no longer use function references. Adding something to the "else" block in implementation B is even harder because we need to use some function to be able to hold more than a single expression on the right side of the Elvis operator:
Debugging implementation A is also much simpler. This should be no surprise, as debugging tools were made for such basic structures.
The general rule is that less-common “creative” structures are generally less flexible and not so well supported. Let’s say, for instance, that we need to add a third branch to show a different error when the variable person
is null
, and a different one when person
is not an adult. In implementation A, which uses if
/else
, we can easily change if
/else
to when
using IntelliJ refactorization and then easily add an additional branch. The same change to the code would be painful in implementation B. It would probably need to be totally rewritten.
Have you noticed that implementations A and B do not work the same way? Can you spot the difference? Go back and think about it now.
The difference lies in the fact that let
returns a result from the lambda expression. This means that if showPerson
returns null
, then the second implementation will call showError
as well! This is definitely not obvious; it teaches us that when we use less-familiar structures, it is easier to fall victim to unexpected code behavior.
The general rule here is that we want to reduce cognitive load. Our brains recognize patterns, on the basis of which they build an understanding of how programs work. When we think about readability, we want to shorten this distance. We prefer less code but also more common structures. We recognize familiar patterns when we see them often enough. We always prefer structures that we are familiar with in other disciplines.
Do not get extreme
Just because I showed how let
can be misused in the previous example, this does not mean it should always be avoided. It is a popular idiom that is reasonably used to make code better in various contexts. One common example is when we have a nullable mutable property, and we need to do an operation only if it is not null. Smart casting cannot be used because a mutable property can be modified by another thread, but a great way to deal with this is to use the safe call let
:
Such an idiom is popular and widely recognizable. There are many more reasonable cases for let
, for instance:
- To move an operation after its argument calculation
- To use it to wrap an object with a decorator
Here are examples of these two uses (both also use function references):
In both these cases, we pay the price: this code is harder to debug and is harder for less-experienced Kotlin developers to understand. But nothing comes for free, and this seems like a fair deal. The problem is when we introduce a lot of complexity for no good reason.
There will always be discussions about when something does or does not make sense. Balancing it is an art. It is good, though, to recognize how different structures introduce complexity or how they clarify things, especially when they are used together. The complexity of two structures used together is generally much more than the sum of their individual complexities.
Conventions
We’ve acknowledged that different people have different views of what readability means. We constantly fight over function names, discuss what should be explicit or implicit, what idioms we should use, and much more. Programming is an art of expressiveness. Still, there are some conventions that need to be understood and remembered.
When one of my workshop groups in San Francisco asked me about the worst thing one can do in Kotlin, I gave them this:
All we need to make this terrible syntax possible is the following code:
This code violates many rules that we will describe later:
It violates operator meaning -
invoke
should not be used this way, becauseString
cannot be invoked.The usage of the ‘lambda as the last argument’ convention here is confusing. It is fine to use it after functions, but we should be very careful when we use it for the
invoke
operator.and
is clearly a bad name for this infix method.append
orplus
would be much better.We already have language features for
String
concatenation, and we should use them instead of reinventing the wheel.
Behind each of these suggestions, there is a more general rule that ensures a good Kotlin style. We will cover the most important ones in this chapter, starting with the first item, which will focus on overriding operators.
This ratio was popularized by Robert C. Martin in the book Clean Code.