Back

A Tale of Two Paradigms

24 Feb 2024 (functional-programming, procedural-programming)

Recently I’ve been thinking about the handling of “side effects” in programming languages. Most (basically all) of the programming languages we use in industry don’t include a notion of side effects in their semantics. In procedural and object-oriented languages, a program’s behaviour is broken down into procedures or methods, respectively, which consist of statements to be executed. Each of these statements may or may not perform an operation that would be considered a side effect, like logging a message to the console, requesting input from the user, interacting with a web API—basically anything that affects or depends upon something in the world outside the program.

Advocates of functional programming often point to this (correctly, I think) as a fundamental weakness of the most widely used languages, and one of the biggest strengths of functional programming languages like Haskell. In the latter, a program consists entirely of functions, and these functions simply map their arguments to some output and do nothing more. Detractors are then quick to point out (also correctly, I think) that side effects are not a problem, they are the reason we write code. If a language were to disallow I/O altogether, we would have no way of interacting with it, and it would be rendered completely useless. This is true, and this is why I think “side effects” are really more accurately termed, simply “effects”.

However, Haskell does have support for effects like I/O, but these kinds of operations are encoded by specific types like the IO type. The function signature foo :: IO a lets the compiler know that when executed, the function foo will perform some effect involving I/O which, if successful, will return a value of type a. That is, foo does not return an a, it returns an instruction for performing an effect whose “happy path” produces an a. This kind of explicit identification of an effect allows the compiler to ensure that each of its possible outcomes are handled safely.

The problem with procedural languages

While Haskell can’t guarantee that effects won’t produce errors (the real world is unpredictable), it can guarantee that functions are referentially transparent, meaning any function call can be replaced by the output it produces, without affecting the program’s behaviour. That is, if a function returns an Int, each instance where that function is called can be treated as if it is an Int. This principle means that functions can always be composed with other functions, and the result will always be predictable; as long as the types match up, the functions compose.

Even in procedural languages that have strong type systems, like Rust, we don’t get these same guarantees. A function might say that it returns i32, but that result might be dependent on an API call that could fail or result in different values at different times. Continuing with the example of Rust, we hope that if the function could fail then it would have a return type of Result<i32> (Rust offers the Result type to encapsulate operations that could fail), but that is down to the discretion of the programmer who wrote it—they could just as easily return an erroneous value that is still of type i32, like 0.

The problem with functional languages

All of this to say, I like Haskell and functional programming in general. But the problem is that for the most part, nobody uses them, and I can’t really blame them. I’ve been building side projects and generally playing around with Haskell for a few years now, and I still don’t feel that I have as strong an understanding of it as I do of languages that I’ve used for a fraction of the time. Haskell is steeped in esoteric mathematics, and while it is relevant and interesting, it is also dense and difficult. And furthermore, while it is possible to write working Haskell programs without being able to properly explain why a monad is “just a monoid in the category of endofunctors”, trying to convince software engineers to pick up a language so foreign and unintuitive is difficult. The problem is that it is because of its rigorous mathematical backbone that Haskell is so safe.

Procedural semantics result in code that expresses what the computer should do, while functional semantics result in code that describes what things are; the former is more imperative while the latter is more declarative. Neither of these approaches is necessarily superior, but as an engineer, when you know what you want your program to do, having to deconstruct the problem into a kind of library of abstractions which you then recompose into a working program, seems tortuously indirect. From the perspective of practicality, I think this is a point in favour procedural languages (or imperative languages more broadly).

We only become more dependent on software, not less, and I think that the lack of a way of handling effects as what they truly are—dependencies on the fuzzy outside world that are highly likely to fail—is a pressing deficiency that modern languages need to tackle. While Haskell does this, it realistically isn’t going to be adopted broadly any time soon, nor is anything like it. However, there doesn’t seem to be any reason why correctly representing effects in a type system would be incompatible with an imperative style, and I wonder if there isn’t a way for us to reap the benefits of both paradigms.