Imperative programming makes it easy to write complex programs, but hard to write correct ones. One reason for this is that imperative languages refuse to properly deal with effects. The article shows why effects should treated as first-class citizens and how things can be improved by switching from imperative to functional programming.
Every useful computer program interacts with the world outside the actual source code, typically by reading and writing data from storage media and over network connections. Programmers call such an interaction an effect. Every time a program performs an effect, it leaves the safe, predictable environment of the program flow — a file can be missing, a server can be down. The inherent dangers of effects are undeniable, therefore how a program deals with effects makes all the difference.
Effects in Imperative Programming
Many software developers begin their journey with imperative programming. Usually it is immediately obvious what an imperative program is intended to do. Therefore such programs look simple, straightforward, and not intimidating.
The basis of each imperative program is a mental model of the execution flow of the program, typically derived from the “main success scenario” of a use case in the requirements specification. Since our cognitive capacity is limited, this model is — and has to be — a simplified one; nobody is capable of considering all possible execution flows of a reasonably complex real-world program at the same time.
Now, this mental model has to be expressed in the form of statements. As an inexperienced programmer, you approach this like an impatient and ignorant manager: You tell your employees (or in this case, the machine) what to do and you can’t be bothered with all the little risks and pitfalls that executing the tasks might actually entail. The fatal problem is that the programming language gives you the illusion that you have created a correct program, and you let yourself succumb to this illusion due to your lack of experience or dedication.
Consider the following example:
import scala.io.Source
def readFileImp(fileName: String): List[String] = {
val source = Source.fromFile(fileName)
val lines = source.getLines.toList
source.close
lines
}
The readFileImp
method is clearly intended to read a list of lines file. But the fact that I'm using the verb intended indicates that this is not what the program actually does, at least not in all situations. What will happen if the file doesn't exist? In this case the program doesn't do what you intend it to do.
The problem here is that imperative languages usually don’t explicitly address the subject of effects. They don’t distinguish between safe and unsafe operations. The execution of each line could potentially result in an error. The programming language couldn’t care less — it doesn’t support you in the difficult task of writing correct software. Instead, it shifts the cognitive burden to you, the programmer. When writing or reviewing code, you have to continuously think about whether it could contain any hidden problems. This makes it incredibly hard to produce correct code — for a seasoned developer it is very stressful, for a beginner it is virtually impossible.
Most imperative programming languages provide the developer with a crutch to handle those hidden problems: Exceptions. The mere term spotlights the fact that imperative languages promote the practice of reflecting only a certain intended scenario in the source code. The developer is not supposed to clutter the code with the handling of undesirable cases. If a so-called exception occurs, it just stops the execution flow, potentially leaving the program in a hazardous state, and implicitly bubbles up the call stack, until a benevolent piece of code shows the kindness of handling it or presenting it to the user. If no such piece of code exists, the application gives up and quits. It is up to you, the programmer, to check the documentation of each and every called method for whether it might throw an exception. A programmer may think of exception handling as a safety net, but in reality it just moves the inherent risks of effects from plain view, where it belongs, to a hidden, convoluted side track outside the actual program flow.
The illusion of correctness that characterises imperative programs comes at a steep price. You can choose which price to pay: Either you live with the illusion at the expense of correctness (which typically involves continuous and extensive bugfixing down the road), or you try to maximise the correctness of your program by carefully examining each line of it and writing a ton of unit tests.
Effects in Functional Programming
The functional programming world has a term for code which contains hidden traps: impure. In contrast, a pure piece of code is innocent — it reveals everything it does in its signature, without hiding any dirty details. Every time you call it with certain parameters you can expect the same result and don’t have to worry about any unforeseeable effects. This property of functional programs is called referential transparency. Any combination of pure functions is again pure, but a single impure piece of code will compromise the purity of the whole program. In languages like Scala which support both the imperative and the functional paradigm, it is crucial to take care not to incorporate any impure code into a program. At times, this can be challenging; especially after years of programming imperatively. But in the end it is well worth it.
For comparison, here’s a typical functional program offering the same functionality as our example above:
import cats.effect.IO
import scala.io.Source
def readFileFunc(fileName: String): IO[Either[Throwable, List[String]]] =
(for {
source <- IO(Source.fromFile(fileName))
lines <- IO(source.getLines.toList)
_ <- IO(source.close)
} yield lines).attempt
Technically, this program is also not free of errors, e.g. the source will not be closed if an error occurs while the file content is read, but it should be sufficient to illustrate some basic concepts.
Let’s see how this readFileFunc
function differs from the readFileImp
"function" in the first example. readFileImp
is actually not a function: It claims to return a list of strings, but in reality it is only able to do so if the file actually exists. In contrast to that, the readFileFunc
is honest about its return type. It returns an IO
, which means that the function doesn't actually do anything, it only produces a "process" object which can be executed (e.g. using the unsafeRunSync
method, whose name implies that you're now leaving the safe territory of referential transparency). The readFileFunc
always produces the same process object, in any circumstance. Furthermore, the result type of the IO
is either a Throwable
(i.e. an error object) or a list of strings, which conveys that the execution might actually fail.
As a functional programmer, you safely compose your program by combining pure functions. Some of these functions might return unsafe processes (like the IO
objects in our example program), but this is explicitly stated in their return type, which means that the caller is always aware of it. Only at the very last moment, after the composed program has been invoked and an effectful process is produced – commonly phrased "the end of the world" – this process and its effects are executed under strict observation.
The following picture illustrates how imperative and functional programming languages handle effects differently. The explosion symbolizes the execution of an effect; the bomb symbolizes an effect object which is waiting to be executed.
The implicit handling of effects turns the whole imperative program into a minefield, whereas in a functional program effects are explicitly created, composed and executed “at the end of the world”, in a constricted and controlled environment.
Scala Libraries
To help you get started with managing effects, here’s a short list of Scala libraries dedicated to this purpose:
- eff — Extensible effects based on the “free-er” monad
- cats-effect — I/O effects for the cats ecosystem
- Scala Effect — Structuring effectful programs in a functional way
Conclusion
While imperative programming hides complexity at the expense of correctness, functional programming aims at ensuring correctness while embracing complexity. If complexity cannot be hidden, it has to be managed. This might be one of the reasons why functional programming is considered to be so hard to learn: A beginner in imperative programming will implement a complex process using a seemingly simple program on the first day, because most of the complexity is implicit and hidden. A beginner in functional programming will only be able to express complex processes after several weeks or months of learning, because he has to gradually build up his knowledge about how to represent all the different aspects of the complex processes in his program. But from the very beginning of this journey, correctness and robustness will be essential and non-negotiable characteristics of his programs.
I would be very happy to hear about your opinion in the comments section. Thanks a lot for reading!
Cover image: Danger: Hard Hat Area by Kevin Jarrett on Unsplash
Top comments (1)
I think the degree of complexity - however you meassure it - of imperative and functional codeis always the same, because complexity never vanishes into thin air. However, the aspects that are explicit or implicit vary between both paradigms. FP tends to declarative code, because functions and their compositions are ubiquitous. And it makes effects explicit by encoding them as values.