article banner

Item 25: Each function should be written in terms of a single level of abstraction

This is a chapter from the book Effective Kotlin. You can find it on LeanPub or Amazon.

A computer is an extremely complex device, but we can work with it thanks to the fact that its complexity is split into different elements in distinct layers.

From a programmer’s perspective, the lowest abstraction layer of a computer is hardware. Going up from there, since we generally write code for processors, the next interesting layer is the processor control commands (machine code instructions). For readability, they are expressed in a very simple language that is one-to-one translated into those commands. This language is called Assembly. Programming in Assembly language is difficult, so building modern applications in this way is absolutely unthinkable. To simplify programming, software engineers introduced compilers: software that translates one language into another (generally a lower-level one). The first compilers were written in Assembly language, and they translated code written as text into Assembly instructions. This is how the first higher-level languages were created. They were in turn used to write compilers for better languages,thus introducing C, C++ and other high-level languages that are used to write programs and applications. Later, the concepts of abstract machines and interpreted languages were invented; it is hard to place languages like Java or JavaScript in this pyramid, but the general notion of abstraction layers remained as an idea.

The big advantage of having well-separated layers is that operating on a specific layer means that lower levels can be relied upon to work as expected, thus removing the need to fully understand the details. We can program without knowing anything about assembler or JVM bytecode. This is very convenient. Similarly, when assembler or JVM bytecode needs to change, programmers don't need to worry about making changes to applications as long as they only adjust the upper layer, which is what native languages or Java are compiled to. Programmers operate on a single layer, often building for upper layers. This is all developers need to know and it is very convenient.

Level of abstraction

As you can see, layers have been built upon layers in computer science. This is why computer scientists started distinguishing how high-level something is. The higher the level, the further it is from physics. In programming, we say that the higher the level, the further the code is from the processor. The higher the level, the fewer details we need to worry about, but you’re trading this simplicity for a lack of control. In C, memory management is an important part of your job. In Java, the Garbage Collector handles this automatically for you, but optimizing memory usage is much harder.

The Single Level of Abstraction principle

Just as computer science problems are extracted into separate layers, we can create abstractions in our code as well. The most basic tool we use for that is a function. Also, the same as in computers, we prefer to operate on a single level of abstraction at a time. This is why the programming community developed the "Single Level of Abstraction" principle, which states that Each function should be written in terms of a single level of abstraction.

Imagine that you need to create a class to represent a coffee machine with a single button. Making coffee is a complex operation that needs many different parts of a coffee machine. We’ll represent it by a class with a single function named makeCoffee. We can definitely implement all the necessary logic inside this unique function:

class CoffeeMachine { fun makeCoffee() { // Declarations of hundreds of variables // Complex logic to coordinate everything // with many low-level optimizations } }

This function could have hundreds of lines. Believe me, I’ve seen such things, especially in old programs. Such functions are absolutely unreadable. It would be really hard to understand the general behavior of the function because, when we read it, we would constantly focus on the details. It would also be hard to find anything. Just imagine that you are asked to make a small modification, such as modify the temperature of the water; to do this, you would probably need to understand the whole function, which would be absurdly hard. Our memory capacity is limited and we do not want a programmer to waste time on unnecessary details. This is why it is better to extract high-level steps as separate functions:

class CoffeeMachine { fun makeCoffee() { boilWater() brewCoffee() pourCoffee() pourMilk() } private fun boilWater() { // ... } private fun brewCoffee() { // ... } private fun pourCoffee() { // ... } private fun pourMilk() { // ... } }

Now you can clearly see what the general flow of this function is. These private functions are just like chapters in a book. Thanks to that, if you need to change something, you can jump directly to where it is implemented. We have extracted the higher-level procedures, which has greatly simplified the comprehension of the first procedure. We have made it readable; if someone wants to understand it at a lower level, they can just jump there and read it. By extracting very simple abstractions, we have improved readability.

Following this rule, all these new functions should be just as simple. This is a general rule: functions should be small and have a minimal number of responsibilitiesmartin1. If one of these functions is too complex, we should extract intermediary abstractionsfootnote410_note. As a result, we should end up with many small and readable functions, all located at a single level of abstraction. At every level of abstraction, we operate on abstract terms (methods and classes); if you want to clarify them, you can always jump into their definition1. This way, we lose nothing from extracting these functions, and our code is more readable.

An additional bonus is that functions extracted this way are easier to reuse and test. Say that we now need to make a separate function to produce espresso coffee, which does not contain milk. When the parts of the process are extracted, we can now reuse them easily:

fun makeEspressoCoffee() { boilWater() brewCoffee() pourCoffee() }

Abstraction levels in program architecture

The notion of layers of abstractions is also applicable to levels that are higher than functions. We separate abstraction to hide the details of a subsystem, thus allowing the separation of concerns (SoC) to facilitate interoperability and platform independence. This means defining higher levels in problem-domain termsmcconnell.

This notion is also important when we design modular systems. Separate modules can hide layer-specific elements. When we write applications, the general understanding is that modules that represent inputs or outputs (views in the frontend, HTTP request handlers on the backend) are lower-layer modules. On the other hand, those representing use cases and business logic are higher-level layersmartin2.

We say that projects with well-separated layers are stratified. In a well-stratified project, one can view the system at any single level and get a consistent viewmcconnell2. Stratification is generally desired in programs.

Summary

Making separate abstraction layers is a popular concept in programming. It helps us organize knowledge and hide the details of the subsystem, thus allowing the separation of concerns in order to facilitate interoperability and platform independence. We separate abstractions in many ways, like functions, classes, and modules. We should try not to make any of these layers too big. Smaller abstractions operating on a single layer are easier to understand. The general notion of abstraction level is that the closer it is to concrete actions, processor or input/output, the lower level it is. In a lower abstraction layers, we define a language of terms (API) for a higher layer or layers.

1:

In IntelliJ or Android Studio, we jump to element definition by holding the Ctrl key (Command on Mac) and clicking on the element name.

martin1:

Clean Code by Robert Cecil Martin.

martin2:

Clean Architecture: A Craftsman's Guide to Software Structure and Design by Robert C. Martin, 1st Edition.

mcconnell:

Code Complete by Steve McConnell, 2nd Edition, Section 34.6.

mcconnell2:

Code Complete by Steve McConnell, 2nd Edition, Section 5.2.

footnote410_note:

These might be functions, as well as classes or other kinds of abstractions. The differences will be shown in the next item, Item 26: Use abstraction to protect code against changes.