In the FP world, we like to make fun of OOP because there is no single, proper definition ("since it is some made-up thing"). But then ask 10 devs or functional devs what functional programming is, and there is no consensus either.
I've been doing functional programming – not even just that – I've been doing functional programming propaganda ("art") for a bit more than 10 years. The last couple, I've been struggling to pinpoint what it is and what draws me to it.
At this point, I'm happy with the distillation that I've done, and it was a very fun journey, so let's talk about it.
base definition
Let's start with wikipedia (of all places): https://en.wikipedia.org/wiki/Functional_programming.
In computer science, functional programming is a programming paradigm where programs are constructed by applying and composing functions. It is a declarative programming paradigm in which function definitions are trees of expressions that map values to other values, rather than a sequence of imperative statements which update the running state of the program.
In functional programming, functions are treated as first-class entities, meaning that they can be bound to names (including local identifiers), passed as arguments, and returned from other functions, just as any other data type can. This allows programs to be written in a declarative and composable style, where small functions are combined in a modular manner.
Functional programming is sometimes treated as synonymous with purely functional programming, a subset of functional programming that treats all functions as deterministic mathematical functions, or pure functions...
Note what I've highlighted. We're going to zoom in and out on those throughout the journey.
If we look at a referenced https://en.wikipedia.org/wiki/Declarative_programming, the first section contains a few relevant nuggets as well:
...
Just like in the functional programming paradigm, mutating variables does not conform to the declarative paradigm because it implies an execution order which is imperative.
...
Other definitions:
- Any programming language that lacks side effects, or more specifically, has referential transparency.
- A language with a clear correspondence to mathematical logic. ...
Not a fan of these. Something feels icky about these last two. I'm usually not a fan of using definitions for communicating ideas. And this is a good example – "lacks side effects", "has referential transparency", and "clear correspondence" – if you know, you know; and if you don't it's either harmful or confusing. Sometimes I look up words in the dictionary and it's like "accountant = one that gives an account or is accountable", thank you.
blind men and an elephant
Instead of dumping definitions, let me take you on a journey through the languages and communities I’ve interacted with through the years.
It might be a longer way to do this, but it's important. On the one hand, (I like to repeat that) languages and communities shape the way we think and go about things. So, spoiler alert, we'll end up talking about the value of FP instead of what it is. And on the other hand, I believe the overall definition and confusion soup around FP is also tied to over-generalization. We can see it in the following wiki paragraph:
... many functional languages are seeing use today in industry and education, including Common Lisp, Scheme,[3][4][5][6] Clojure, Wolfram Language,[7][8] Racket,[9] Erlang,[10][11][12] Elixir,[13] OCaml,[14][15] Haskell,[16][17] and F#.[18][19] Lean is a functional programming language commonly used for verifying mathematical theorems.[20] Functional programming is also key to some languages that have found success in specific domains, like JavaScript in the Web,[21] R in statistics,[22][23] J, K and Q in financial analysis, and XQuery/XSLT for XML.[24][25] Domain-specific declarative languages like SQL and Lex/Yacc use some elements of functional programming, such as not allowing mutable values.[26] In addition, many other programming languages support programming in a functional style or have implemented features from functional programming, such as D27, C++ (since C++11), C#,[28] Kotlin,[29] Perl,[30] PHP,[31] Python,[32] Go,[33] Rust,[34] Raku,[35] Scala,[36] and Java (since Java 8).[37]
An anecdote. I know quite a few people doing functional programming. But I only know two people who truly love Haskell and Elm (at the same time). (Fun fact: when I started on this script some months ago, it was just one person, but I've met someone else since) In general, people who love one usually can't tolerate the other. Both are functional programming languages. How come? I find it fascinating.
programming with functions
My first serious language was Java. The functional style was added in Java 8 (it wasn't there when I got started). https://en.wikipedia.org/wiki/Java_version_history#Java_SE_8_(LTS):
Supporting lambda expressions also enables functional-style operations on streams of elements, such as MapReduce-inspired transformations on collections.
It looks something like this, https://docs.oracle.com/javase/8/docs/api/java/util/stream/Stream.html:
int sum = widgets.stream()
.filter(w -> w.getColor() == RED)
.mapToInt(w -> w.getWeight())
.sum();
We can do something similar with JavaScript (and explicit reduce):
const sum = widgets
.filter(w => w.color === RED)
.map(w => w.weight)
.reduce((acc, weight) => acc + weight, 0);
And MapReduce (e.g., Hadoop). Not going to write it (imagine filter is embedded into map and a separate reduce step).
In various degrees of complexity, we have FP here – because we use functions. In a broader sense, we can summarize it as:
functional programming = programming with functions
Silly, considering what I said before about definitions (just shuffling the words), but it's a good broad start. The main point here is that it's not enough. It wasn't there when I got into Scala, but even after Java 8, I didn't switch from Scala to Java. Not many people switched. And even now... So what's there that Scala offers (or offered)?
Functional Programming in Scala Specialization (on Coursera) is one of the main entry points to Scala. Take a look at these two goals:
- Write purely functional programs using recursion, pattern matching, and higher-order functions
- Design immutable data structures
Next, let's look at the lecture 1.1, somewhere around minute 8:
In fact, there are two ways to look at functional programming, a restricted one and a more general one. In the restricted sense, functional programming just means programming without mutable variables, without assignments to those variables, without loops and the other imperative control structures. So it takes a lot of things away. In the more general sense, functional programming means focusing on the functions in the program.
I'm not exactly happy with those, but I am happy to see two definitions here, because it fits nicely with the story. The second definition is similar to what I've hand-waved before, while the first one is pretty restricted (too specific?). I also like the reference to "function composition" here, it's not explicitly in those two definitions, but it's something that I like and something we'll circle around later.
To connect the two, I think it's safe to simplify and pretend that we can't have a precise definition, but rather a range on a scale/spectrum.
Somewhere closer to the left of the middle, I'd put a middle-ground definition that I also liked at that time:
functional programming = programming with immutable data and functions
If we actually ask 10 fp people, 5 of 10 people would give this kind of a definition. For example, "immutable data structures and pure functions that work on those", "you have values and functions that transform values", and so on.
local reasoning
At that time, I was doing Scala, hanging out in a couple of Scala communities/spaces. Fabio (one of the coolest people) dropped this gem:
DO YOU KNOW THAT FEELING OF HAVING TO HOLD TOO MANY THINGS IN YOUR HEAD AT ONCE, SUCH THAT IF SOMEONE TALKS TO YOU, YOU LOSE EVERYTHING?
FP REMOVES THAT, BY DEFINITION
For me, it was mind-blowing. That's when I got hooked on for real. I wanted to tell everyone about this. And that's when the propaganda train was going full steam. Let's talk about the feeling first, and then the definition.
The major part of this feeling is tied to local reasoning. Until recently it was obvious to me that everyone wants that and what that means. Wonderful things can happen when you talk to people outside of your bubble.
We can associate local reasoning with lack of global state, immutability, composition ...
Side-tracking for a bit to talk about local reasoning and (de)composition.
I used to think that, to improve local reasoning, it's a good idea to split small functions out of bigger ones. For example, when you are looking for bugs, you can shrink and grow context to understand the given chunk. It's very tempting to break things into tiny functions. Very functional. Whatever. I'm pretty sure, early on in my career, I was poison-pilled by Robert C. Martin.
But local reasoning isn't just about fitting your function on a screen – it's about understanding the component without inspecting the whole program. In the best case, we can reason about it from the inputs and outputs (and allowed interactions). Or by reading just the code of the component. If we have to look at the dependencies, we are far from optimal.
With experience, I learned that sometimes big functions are much better. Local reasoning, in this case, means looking at one given function without jumping through hoops of smaller functions and dependencies to gather context.
A similar issue could be over-focusing on one thing (object) instead of a set of things.
These days, I think finding the right size of functions is an art in itself. Rule of thumb: if you have to read the whole smaller function to understand a bigger function that uses it, it was likely a bad choice to split it up.
... let's pop back
When I was interpreting Fabio's words, the idea was (at least in the milieu) that the main booster of local reasoning was referential transparency.
An expression can be replaced with its value without changing the behavior of the program.
Real example. Imagine we have a flock of goblins and need to pick weak one(s):
for each goblin in goblins {
weakness_threshhold = determine_weakness_threshold(some params)
if goblin.health < weakness_threshhold then return goblin
}
Note that determine_weakness_threshold doesn't depend on the current goblin; we pass some other params (like level, character stats, or whatever). A very common performance optimization is to consider extracting the computation out of the loop:
weakness_threshold = determine_weakness_threshold(some params)
for each goblin in goblins {
if goblin.health < weakness_threshold then return goblin
}
Quick win! Instead of doing it at every step, we can do it once outside of the loop, right? We can, but only if it doesn't change the behavior of the program.
One way – the strongest way – to know we can do it is if we have a referentially transparent function. But how do we know that?
If it's something straightforward like player.stat_x + config.value_y, that's easy. But what if we have a function that calls other functions? We can see how it messes with local reasoning. And some of you might think that if we had that inlined, this wouldn't be a problem. Sure, maybe in some cases. But please just wait a bit, and we'll see what tools we have in a functional belt.
Luckily for us, all pure functions are referentially transparent.
purity
Purity is one of the coolest and one of the most misunderstood concepts around functional programming.
A pure function always returns the same result for the same arguments (without externally observable side-effects).
Let's take a function that compares two numbers, for example. If we give this function the same numbers (same arguments), it is guaranteed to return the same boolean (same value) no matter what.
take a and b, return true or false
Or let's count goblins (in total or given some property). We always get the same result for the same array of goblins (same values, not talking about mutable references yet).
take array of goblins and some pure predicate, return count
In a simple world, pure functions are nice: nice to test, reason about, refactor, use in concurrent applications, and so on. Compare this to fetching and counting goblins from a database. We don’t have these guarantees. One moment, we fetch 10 goblins; the next – 100. It’s not pure.
We can compromise and split fetching from counting, for example, to at least preserve partial purity.
SELECT COUNT ...
vs.
SELECT + filter and count in code
Might be a bit slower, but who cares, right? Well, actually we do and it's important to talk about. Developer convenience is nice and all, but that's not the end goal.
One way to illustrate this is from the perspective of “Functional Core, Imperative Shell”. Most people are familiar with it; otherwise, it’s pretty self-explanatory. And outside of the performance issues we just saw, it’s pretty agreeable / widely accepted.
For details on "Functional Core, Imperative Shell", see https://www.destroyallsoftware.com/talks/boundaries.
The devil (and all the fun) is in the details. What is functional? What is imperative? And where core should end and shell begins? Broadly speaking, the bigger the core, the more functional, but it's not always the case.
In Haskell, even the imperative shell is pure (functional).
Haskell is a purely functional programming language that features referential transparency, immutability, and lazy evaluation...
Haskell school argues that purity is of absolute importance. There is no other way.
functional programming = programming with immutable data and PURE functions
A lot of people look at this as delusional or only applicable to toy apps. I hope I can quickly convince you otherwise. Before we look at the technical side, let's look at Mercury. Mercury has a $5.2 billion valuation and a couple of million lines of Haskell. It's pretty challenging to get to that state with a toy fictional paradigm.
So how is it possible to integrate real world things like I/O, mutation, state, exception, etc. with purely functional code?
A couple of smart folks discovered a powerful (elegant, depends on who you ask) solution in the form of monads. I won’t give you a monad tutorial. I want to show that full purity is possible and a path to it. I’d love people to understand their options and decide if that’s something they don’t want, rather than just reject it as wishful fp thinking.
And hot take, I don't think you need to understand monads to write Haskell or pure functional code. You don't need to understand how GC or memory works to write code. It definitely helps if you want to do it well, but you can get way further than the internet claims it to be.
Let's look at one of the fundamental papers on the topic by Philip Wadler, Monads for functional programming:
Monads provide a convenient framework for simulating effects found in other languages, such as global state, exception handling, out-put, or non-determinism.
These notes describe one, the use of monads to integrate impure effects into pure functional languages.
The concept of a monad, which arises from category theory, has been applied by Moggi to structure the denotational semantics of programming languages. The same technique can be applied to structure functional programs.
The earlier paper (IIUC), The essence of functional programming, has a good summary for us:
A key observation of Moggi’s was that values and computations should be assigned different types: the value type
ais distinct from the computation typeM a.
We can differentiate computations and model computations as data. For example, in Haskell you can write:
repeatN :: Int -> IO () -> IO ()
repeatN n computation =
if n == 0
then return ()
else do { computation; repeatN (n-1) computation }
-- Print "hello" 5 times
main = repeatN 5 (print "hello")
If you done some OOP and familiar with the Command pattern (or to some extent Interpreter), another way to approach this is to mentally separate (expression) evaluation from (action) execution. For example, when I look at print "hello", I don't think of it as an instruction to print to stdout – I think of it as a "description" of a computation. There are quite a few steps between that text and letters appearing in the terminal.
Ok, let's push it further. (Simplified to single-threaded programs) We often talk about (imperative or even procedural) programs as if they're a sequence of instructions that a computer runs one after another (X and then Y). But that's not how CPUs do things. And it's fine; we can switch between different (levels) of abstraction.
FP is another abstraction, another way to think about computations. So it's the same, but it's different. Originally, I was going to talk about turing machines and lambda calculus (Alonzo Church and Alan Turing) here, but I'll leave it as an exercise to the reader.
We covered my path from Java to Scala to Haskell in search of max purity. I wasn't the only one. Are we satisfied? Or now that we are here, what's next?
flavors of Haskell
If we go back to the blog post about Haskell in Mercury, I'd like to point out a few more things:
Our engineering organization largely hires generalists, and most of them have never written a line of Haskell before joining.
My time working at Mercury has changed how I think about the language more than any sermon about purity ever did. Elegance is pleasant, but keeping your business alive is compulsory.
You have to actively cultivate a culture of pragmatism. Haskell gives you powerful tools. Using all of them all the time is not pragmatism; it is self-indulgence...
Let's label this a pragmatic school of Haskell. That's not what usually comes to mind when people bring up Haskell, right? Usually, the associations are "less pragmatic", "more abstract", "more mathy". On wikipedia, we saw: "A language with a clear correspondence to mathematical logic". Haskell and pure fp are interlinked with math: pure functions are similar to mathematical functions, there is no mutation in math, and so on. Haskell origins (and its core members) are also tightly coupled to category theory (we saw a reference in a paper)... Long story short, a lot of fp people get into category theory, topology, and other mathematics (and other way around).
We can see a lot of this in Haskell spaces, and it scares people off. I kinda get it, but also wtf. Quick rant. When it comes to math, it's considered cool when it's in gaming and machine learning domains, but somehow it's "negative" when it's about fp. Algebraic and geometric thinking are fine, but thinking in terms of categories is elitist or something. There should be no shame in enjoying math or any field of math. Anyway.
Another relationship between computer programs and mathematical proofs is Curry–Howard correspondence. People get interested in verifying systems and mathematical theorems. People get into type theory, dependent types, logic... It's hard for me to simplify where one thing starts and another begins.
One thing I'm trying to illustrate is that Haskell or functional programming is broad. There are different schools (communities) and even branches within (sub-communities). For example, in the intro I mentioned Haskell vs. Elm (https://www.youtube.com/watch?v=co-Vg7M4yKw); or there is Scala, FP Scala, and there are multiple schools within it.
But let's get back to our definitions.
de-tour into types
For the longest time, functional programming was tied to strong/static types (for me, I don't know about normal people). For instance, after a couple of years of Haskell, I made a course I called How to think like a functional programmer. One of the sections is fully dedicated to types. So, it was a big deal. At that time:
functional programming = immutability, pure functions + algebraic data types, strong invariants, and making invalid states unrepresentable
I understand that my trajectory started with Java, and the closest I got to a dynamic world was row polymorphism in PureScript (it's still statically typed, but it similar to dynamic typing in terms of flexibility). Somehow, I didn’t really question the relationship between fp and “strong” types.
Around that time, Rust started to gain popularity; a few Haskell devs and consultancies switched their focus to Rust, and it planted some doubt seeds for me as well. The big (simplified) questions for a lot of people were: Is Rust a functional language? And if not, why do Haskell people like it anyway? I couldn’t figure it out; I enjoyed some things, hated other things, but I wasn’t sure what.
A bit later, I decided to try Elixir to have a better grasp on things. Elixir is functional and dynamic. Once again, I enjoyed some things and hated other things. But the funny thing is, at that time, I enjoyed writing Rust (not functional) more than Elixir (functional).
It's sort of related to static/dynamic typing, but it is a bit different.
data
I've boiled it down to this. Rust has great (algebraic) data types and (exhaustive) pattern matching, and I realized that's 80% of what I need. I need low-cost/ergonomic ways to express the shapes of data I care about:
- This data has X and Y (a goblin has health and a number of earrings)
- This data is A or B (a thing is a goblin or an orc)
And with the latter, I want the compiler to help me catch missing cases (exhaustive pattern matching by default, switch statement, or whatever you want to call it). I don’t want that at runtime.
We call the first one a “product” and the other one a “sum” type. I know some people obsess and argue about sum types vs enums vs tagged unions on twitter. I don’t care as much (I’m not like other guys). I quickly bring up this distinction to point out that we can model sum types with product types (with extra constraints to exclude invalid states), and some people never use sum types. That approach doesn’t always work so well for me. For example, in Elixir there is no compiler-enforced support for sum types I want; there is a convention (no guarantees) to return something like these: {:ok, value}, {:error, message}. Or we can have an entity that has a field that determines if it’s a goblin or an orc.
Note that Elixir 1.20 introduces gradual checking and my past experience is getting less and less relevant.
Made me realize that I care about data, having control over it, and I want a compiler to hold my hand here. If we go back to one of our previous definitions:
functional programming = programming with (immutable) DATA and pure functions
I want to accent the “data” part. Seems like we can push this definition even further. Let’s try to mess with the rest of the terms and focus on functions again.
effect system
We talked about how bullish Haskell is on purity (even when it comes to an “imperative shell”). What if we take a step back or to the side? What if we trade-off some purity?
Roc also positions itself as pure:
Roc is best described as a pure functional programming language. Most Roc code is written with immutable values and pure functions, while effects are kept explicit and separate.
We get:
functional programming = programming with MOSTLY immutable data and pure functions
Unlike Haskell, they don’t have monads, and they don’t enforce the “imperative shell” to be pure:
Roc makes a first-class distinction between pure and effectful functions. A pure function always returns the same answer for the same arguments and has no externally visible side effects. An effectful function may perform I/O or call another effectful function.
Note the visual differences (-> vs => and ! in the name):
format_name : Str -> Str
format_name = |name| "Hello, ${name.trim()}!"
announce! : Str => {}
announce! = |name| echo!(format_name(name))
Below we have:
The application's platform provides effectful functions and decides how each effect is implemented. A platform can use synchronous blocking I/O, asynchronous I/O, or another strategy appropriate to its domain.
This explicit separation is another reason Roc is a pure functional language: application logic can remain pure by construction, while the smaller effectful boundary is easy to identify.
I think the interesting point here is about “identification”. If we look at our loop again, we see that computation is not pure:
for each goblin in goblins {
weakness_threshold = determine_weakness_threshold!(some params)
if goblin.health < weakness_threshold then return goblin
}
It’s better than the original loop (which had no marker), and this might be enough for some people (or even more than enough already; we’ll come back to this). But we still lose something when we don’t have a pure function.
They [pure functions] are straightforward to test because their results depend only on their arguments, and they are amenable to optimizations such as memoization and compile-time evaluation.
So far, we've been talking about this as if it's only black and white (pure and impure). Let's actually talk about color (in visualization). For some use cases or some people, 2 options might be enough. For some, it might be too much (you can get away with lines or sizes of bar charts, for example). But we have so many colors! We can have so many gradients! What if we utilize those?
We'll have to be careful. Color earns its place when it carries information. I don't remember the details, but someone recently told me about a biology paper with a diagram that had a bunch of colors, like multiple shades of blue, and they couldn't parse anything out of it. Or, for example, it breaks my brain when I look at a flame graph that has green and red color but the color doesn't actually carry any meaningful information.
Another analogy, in memory safety land. You know how Rust has those "unsafe" blocks, and inside you can do various unsafe operations (so you have safe and unsafe Rust sections). I was watching the "CppCon 2015 Writing Good C++ By default" and they talk about the decision to not go black and white there, but instead go with the ability to opt-out with specific [[suppress(profile/rule)]].
What if we do something similar with impurity? What if we add another dimension (or layer) to it? Imagine we can label impure (effectful) functions with what they do (or are allowed to do): read from a file system, do http requests, touch some database, throw exceptions, whatever.
Imagine that in our example loop, we knew that the function in question was labeled as reading from some external storage (or another server):
for each goblin in goblins {
weakness_threshold = determine_weakness_threshold<makes network call>(some params)
if goblin.health < weakness_threshold then return goblin
}
Likely we can't memoize it. But what if it read from a config file and we knew that configs don't need to change mid-loop and actually wasteful, good, we can extract it.
weakness_threshhold = determine_weakness_threshold<reads from config>(some params)
for each goblin in goblins {
if goblin.health < weakness_threshhold then return goblin
}
Note that we don't need to read the code/comments of all the functions in the chain (which could be arbitrarily nested), all the impure functions would be labeled.. The precision here could vary: "reads" vs "reads from db" vs "reads from a specific place" – some more useful than others.
Imagine we can label and distinguish functions that read from a file from those that do network calls from those that launch threads, etc.
Meet effect system, related to effect tracking, related to algebraic effects, related to effect handlers, related to abilities aka ... (it's an active area of research in academia and industry, I won't try to be precise here, I won't discuss the relationship with monads, I'll focus on some common and most exciting for me bits, otherwise we'll be here all day. For example, OCaml has effect handlers but no static tracking of performed effects. For details, see relevant language material). We can talk about papers, Unison (see https://www.youtube.com/watch?v=GaAe7zGq1zM), OCaml, Scala, ... but I'll show a quick example with Verse, and then we'll focus on the "why" behind using those.
In Verse (functional logical programming language), Effects:
An effect specifier states what a function does besides returning a value: whether it reads or writes mutable state, allocates, might fail, or might suspend.
And the precision is as fine-grained as I've ever seen: https://verselang.github.io/book/13_effects/#effect-families-and-specifiers: <succeeds>, <fails>, <decides>, <reads>, <allocates>, and so on.
For example, this function can succeed or fail (fail means no result, it's not a bad thing):
GetGoblin(Goblins:[]string, I:int)<computes><decides>:string =
Goblins[I]
Get goblins takes an array of goblin names and an index, and either returns a name at the given index or fails / doesn't return anything if there is no goblin at that index. GetGoblin is a pure computation that can fail.
We can "handle" or "catch" failure with if:
GetGoblinSafe(Goblins:[]string, I:int)<computes>:string =
if (Goblin := GetGoblin[Goblins, I]):
"hello from {Goblin}"
else:
"a goblin isn't home"
This function is pure and can't fail (labeled only as <computes>), because we've handled failure that could arise from GetGoblin. This is a very simple example, but I hope the concept and the idea are clear. We can mix and match different effects or levels of purity. More interesting example:
goblin := struct:
Name: string
Health: int
# Imagine this is something cooler
var ConfigThreshold: int = 11
DetermineWeaknessThreshold()<reads>:int =
ConfigThreshold
FindWeakGoblin(Goblins:[]goblin)<reads>:?goblin =
for (Goblin : Goblins):
Threshold := DetermineWeaknessThreshold()
if (Goblin.Health < Threshold):
return option{Goblin}
return false
FindWeakGoblins(Goblins:[]goblin)<reads>:[]goblin =
Threshold := DetermineWeaknessThreshold()
for (Goblin : Goblins, Goblin.Health < Threshold):
Goblin
One function returns an optional goblin; the other one returns an array of goblins using a different approach. But in both cases, we have explicit reads – alas, a boring one.
We had some fun with effects, but why? I'll happily offload some of the explanations to the Verse docs:
Making effects explicit serves both human understanding and compiler optimization. For developers, effects act as documentation that can't lie. When you are debugging why a value changed unexpectedly, you can trace through the call chain looking only at functions with
<writes>. When you are trying to understand why a function might fail, you look for<decides>. None of this is guesswork; it is guaranteed by the type system.For the compiler, explicit effects enable powerful optimizations and safety guarantees. Pure functions marked
<computes>can be memoized, their results cached because they'll always return the same output for the same input. Functions without<writes>can be safely executed in parallel without locks. Functions without<decides>can be called without failure handling.The effect system also enforces architectural decisions. Want to ensure your math library remains pure? Mark its functions
<computes>.
I'd like to add to the debugging point. When we specify what's allowed, we also specify what is not allowed, which allows us to avoid silly things.
You know that feeling when you're debugging a program thinking that "no way this piece of logic is logging smth, doing network calls, or smth like that", and then you dig in and find a present from a colleague (or past self) in common code meant to affect only some other functionality (maybe even temporarily)? If a function can do anything, it can do anything. Funnily enough, this can happen in pure Haskell too (once a function has access to IO, it can do whatever). We can make this impossible.
When I was watching the talk about Tigerbeetle (Biodigital Jazz! - Joran Dirk Greef), they mention how tigerbeetle, sqlite, and foundationdb do simulation testing accelerated by simulated time and deterministic dependencies (instead of testing cases for hours, we can get there in seconds). This reminded me of FP and effect systems; for example, the Clock ability in Unison. We can use the Base implementation of the Clock ability that uses the system clock, but we can also make a different implementation to simulate or control time. There is an interesting relationship between determinism and purity.
Another cool things about effect systems is not about effect systems. There is a related concept that people talk about: capabilities or capability systems. The juries are still trying to figure out the exact relationship between effects and capabilities, but this shouldn't stop our curiosity. We talked about access to a file or a database from the developer (experience) perspective, but what if we talk about it from the security angle?
It's been quite a security week, right? (Doesn't matter when you watch/read this, it's probably been quite a security week)
This is getting outside of my pay grade, so I'll just leave a couple of links:
- https://h2.jaguarpaw.co.uk/posts/bluefin-capability-system/
- Capability Myths Demolished
- https://roscidus.com/blog/blog/2023/04/26/lambda-capabilities/
- Also, keep an eye on what Roc's doing with platforms and security.
There is a lot to talk about when it comes to effect systems. The last thing I want to mention (and pin it for later): those effects don’t have to be tracked and live at the effect system level; they can also exist at the value (e.g., see Bluefin) and type (e.g., see effectful) levels. We only looked at one implementation (on the effect system level).
honest functions
It's interesting to think about and try to offload work to compilers. Here is a nicely listed subset: "In Context - Rich Hickey (Clojure/Conj 2026)" (so I don't have to). In reality, we still have to think, reason, and make decisions.
So, I was obsessing over effect handlers/purity, and simultaneously decided to work on a game. Humbling experience. Many of those concerns fade into the background, when you have to think and worry about other things.
Imagine a simple game: it has a game loop and a player (character) that can move left or right. Each frame, we have to decide if we should update the position of the player and draw the player to the left or to the right, if we need to turn it, and so on... Something like this:
dtis delta time, "time elapsed since the previous frame"; it's simplified (it's not that important for this illustration).
game_loop {
if left_key_is_held { player.x -= speed * dt }
if right_key_is_held { player.x += speed * dt }
}
draw {
draw player
}
What about the initial player position? Let's start at the bottom in the middle of the screen:
game_loop {
player = { x = screen.x / 2 - char_width / 2, y = screen.y - char_height}
if left_key_is_held { player.x -= speed * dt }
if right_key_is_held { player.x += speed * dt }
}
draw {
draw player
}
Congrats, our character blinks in the middle of the screen and can't move anymore! Because we mixed up the initialization of a player with per-frame update logic. It doesn't matter if the code is pure or not.
Note: This makes more sense if you are watching the video
This is a bug in logic. In theory, we can put the compiler to work and minimize or eliminate some set of those bugs. But look: there are thing that should happen once, some that should happen on each frame, sometime we need to carry data from a prev. frame (mouse clicked on a box, animation started, etc.); sometimes we didn't have the time to fit into a frame; let's not even talk about GPUs, and things that take multiple frames. In domains where people value flexibility and iteration speed, such restrictions are a hard sell.
Another dimension to think about (if you choose not to ignore it) is memory management. We've seen the <allocates> effect in Verse. And remember how I said that an effect system doesn't have to be an actual built-in effect system (it can live on value or type level)? Check this out:
Verse:
# use your imaginations
mock_file := class<allocates>:
var Contents:string = "Jay"
MarkWeak(File:mock_file)<reads><writes><allocates>:void =
# read + allocate
Name := File.Contents
# write
set File.Contents = "{Name}: weak"
Zig:
# assuming arena, so I don't have to clean up anything here and keep it simple:
fn markWeak(io: std.Io, arena: std.mem.Allocator, path: []const u8) !void {
const dir = std.Io.Dir.cwd();
# read
const name = try dir.readFileAlloc(io, path, allocator, .limited(256));
# allocate
const note = try std.fmt.allocPrint(allocator, "{s}: weak", .{name});
# write
try dir.writeFile(io, .{ .sub_path = path, .data = note });
}
Note the io: std.Io vs. <reads><writes>, allocator: std.mem.Allocator vs. <allocates>. I'll be honest, it's not exactly equivalent, allocates means something slightly different, but the vibes are there
<allocates>allows allocation of mutable memory and indicates functions that create observably unique values: either objects marked<unique>or values containing mutable fields.
Starting with Zig 0.16.0, all input and output functionality requires being passed an Io instance. Plenty of people have already compared it to Haskell’s approach (Haskell’s IO), so I won’t go there. But this is so fucking functional to me.
Sure, we can get around
Ioin Zig through lower-level system calls, but we can also get around Haskell'sIO.
Zig's convention is to pass allocators explicitly (either directly or via another argument). That makes allocation easy to spot and control. It's not a compiler-enforced guarantee, but we are almost there. For example, there is no allocator in Zig’s Tokenizer: so I can reasonably expect it doesn’t allocate – no need to go through hundreds or thousands of lines of code to figure that out.
Years ago, Zig didn’t strike me as functional or as a language I would enjoy. But these days, I look at that interface, errors, tagged unions; it starts to add up. What about mutability?
It is generally preferable to use
constrather thanvarwhen declaring a variable.
That’s good, but it feels like this refers to local scope. What about bigger scopes and passing things around? When in Rome, or in Zig land (and neighbouring lands), we should be in charge of ownership as well as memory and performance impacts. Mutable references/pointers are unavoidable. We can’t ban mutability.
Note: I'm not talking about local mutability, that the caller can't even observe, I think that case is pretty obvious in and out of FP circles.
On the topic of fast FP, I'll refer you to Richard Feldman and Roc: https://rtfeldman.com/fp-vs-c
game_loop {
update_player(&player)
}
draw {
draw_player(player)
}
update_player: takes a pointer to player (mutates it), doesn't return a result
Details depend on the language/implementation. What I'm trying to lead to is that there is "necessary" mutability. But it can often be scoped. I know it's technically impure, but I "know" (at least in my own codebase) that it won’t mutate some random global state.
I think the concept or label of "honest" functions ties it all together for me. From Functional Programming In C#:
An honest function does exactly what the signature says.
The (honest) function doesn't have to be pure by definition of pure, to have a lot of the benefits of purity. If it only mutates the explicitly mutable parameters, it's open to testing, optimizations, and reasoning about. If. Big if. If that doesn't point to sharable (or, glob forbid, global) state.
I should have probably dedicated more real estate to talking about state, but I'll just refer you to this podcast: Software Unscripted. Season 1, Ep. 84: Pure Functional Programming in C with Ryan Fleury
With those goblin functions/computations, I've been purposefully vague about what it returns
for each goblin in goblins {
weakness_threshold = determine_weakness_threshold(some params)
if goblin.health < weakness_threshold then return goblin
}
If we try to implement this, we have a lot of pure, honest, and "neither" options here.
- We can take "const" (immutable) goblins and return an optional goblin or a new array of goblins (or a slice, or indices to original goblins).
- We can take mutable pointer goblins, mutate them, and don't return an explicit other result.
- We can take "const" goblins and a mutable pointer to some result, mutate that, and don't return an explicit other result.
- We can take "const" goblins, do some computations, do some I/O, and don't return any result.
- We can do all sorts of random shit...
With purity, we have a couple of options, but with honesty and a bit of mutability, we get a couple more.
I like this statement from Dave Abrahams in a talk about Hylo, Hylo - The Safe Systems and Generic-programming Language Built on Value Semantics:
Instead of blaming mutation, value semantics elevates it, but keeps it from propagating into locally unknowable values.
In other words, from their docs
Note: Though Hylo should not be considered a functional programming language, functions are first-class citizens, and functional programming style is well-supported. In fact, Hylo’s mutable value semantics can be freely mixed with purely functional code without eroding that code’s local reasoning properties.
Their inout parameters provide exclusive access during the call:
fun offset_inout(_ target: inout Vector2, by delta: Vector2) {
&target.x += delta.x
&target.y += delta.y
}
So, I keep going back to this definition:
functional programming == programming with (mostly immutable) data and (mostly pure) functions
Did I end up with a definition that's not very FP-specific? Should we try again? One last thing.
composition and decomposition
In a sense, all I’ve talked about so far feels like implementation details. The charming and most valuable things around functional programming for me were always about the way it makes us think about problems. We started with wikipedia:
In computer science, functional programming is a programming paradigm where programs are constructed by applying and composing functions. It is a declarative programming paradigm in which function definitions are trees of expressions that map values to other values...
We've touched a bit on "composing functions" and little on "trees of expressions", and before we wrap up, I want to give more attention to those concepts. And because I'm lazy, I'll refer you to smart people (a paper and two shorts):
- John Hughes's "Why Functional Programming Matters"
- https://youtube.com/shorts/UP5dTp9-Jt8
- https://youtube.com/shorts/Fk4PL68uLdo
So, we get:
functional programming == decompose problems into subproblems, solve those with functions, and compose those functions
Hm. I'll let you make your own conclusions and judge for yourself if this is functional programming or just programming (again).
programming
For me, all the definitions we've seen are either too restrictive or too loose. It was never about definitions. I don't think precise definitions are that important. It's more important to have shareable vocabulary and be clear about our values and principles.
At the end of the day, we are trying to talk to computers (bridging the gap between what we perceive as the real world and sand). We ended up with various (artificial) camps and schools.
- we need to model SW after the real world
- we need to model SW after the hardware
- we need to model SW after the pure math
This is a shit diagram, but it's great because I'm actually arguing against it. This illustrates the weird mental model for SWE by global twitter brain.
Relationships are important. As software engineers, we might want to talk to each other, learn from each other, and use proper tools for the job. If a developer doesn't care about lower-level details or how their problem domain works, they won't be a great software engineer. I also believe it's similar if a person completely doesn't care about FP and hasn't tried any flavor of it.
Obviously there are contradictory things and trade-offs (even within a paradigm, even within a single school). I can't be too abstract and very low level at the same time, but I have to learn to navigate the levels and know at which level to approach the given problem.
Times are tough. We should stick together. At least, we have to unite against them...
...against OOP.
Sorry, couldn't resist (I am part of the problem... I'm trying...)
Top comments (0)