Why are FP devs obsessed with Referential Transparency?
Zelenya
Posted on February 28, 2023
📹 Hate reading articles? Check out the complementary video, which covers the same content: https://youtu.be/UsaduCPLiKc
I want to clarify referential transparency (RT), why it is so cool, what it has to do with side-effects, and what common misconceptions exist.
For instance, how can the code have no “side-effects”, but the program can print to the console?
💡 Note that this concept goes beyond functional programming and even programming in general.
What is referential transparency?
Let’s start on a bit of a tangent. Let’s talk about pure functions. A pure function computes a result given its inputs and has no other observable (after)effects on the execution of the program.
Let’s look at a pseudo-code example, pure function:
Function's type: Take an Int, return an Int
Function's body: Take an Int, use it, return another Int
It doesn’t do anything else.
On the contrary, impure function:
Function's type: Take an Int, return an Int
Function's body: Go to the database, call another service, return a random Int
In this case, the function signature is basically a lie.
So, pure functions allow us to know precisely what a function does by looking at its signature. The cool thing about it is not that it’s so “mathematical”; the cool thing is this property it has. And there is even more to this property!
It’s called referential transparency, and it’s not tied to functions: we can apply it to programs, expressions, etc.
For instance, a referentially transparent expression can be replaced with its value without changing the program's behavior and vice versa. So, the following snippets should be the same:
let a = <expr>
(a, a)
(<expr>, <expr>)
If we have some variable a
, we can substitute it with the actual expression, and the program should stay the same.
Imagine that we want to calculate a simple mathematical expression:
let a = 3
(a + 1) + (a * 4) // 16
We can replace a
with 3
:
(3 + 1) + (3 * 4) // 16
And the result stays the same. We can do the same exercise with strings:
let a = "Three"
a ++ a // "ThreeThree"
Where the ++
operator is a string concatenation.
"Three" ++ "Three" // "ThreeThree"
Before we move to more complex examples, which include printing text to the standard output, let’s see why we should even bother.
Why should I care?
Referential transparency improves the developer's quality of life and allows program optimizations.
The property guarantees that the code is context-independent, which enables local reasoning and code reusability. It means that you can take a piece of code and understand it better – you can actually reason about it without worrying about the rest of the program or the state of the world.
Suppose we have an equals
function that compares two URLs for equality:
equals: take two URLs, return a boolean
Looks quite innocent. But! The problem is that this is a Java function, and there is no such thing as referential transparency in Java. So when you use this function with no internet connection, it doesn’t work.
I repeat, a function (technically, a method) that should simply compare two objects and return a boolean either works or doesn’t, depending on your network status.
💡 If you're wondering. From the docs: "Two hosts are considered equivalent if both hostnames can be resolved into the same IP addresses". So the equals
 performs DNS resolution.
I love this example, but we should move on.
If the code is context-independent – it’s deterministic: we can refactor it without pain and write tests for it because all the inputs can be passed as arguments – we don’t need to mock or integrate anything. The behavior is explicit and expected.
And because it’s deterministic, it can be optimized: computations can be cached and parallelized because the outputs are defined by the inputs and don’t implicitly interact. Also, the compiler or runtime can execute the program in whichever order.
We get a more maintainable code with additional optimization opportunities thanks to referential transparency.
What are side-effects?
Since you’re still here, I’ll let you in on a secret. Functional programmers are not some kind of monks writing programs on paper: we print stuff, talk to databases, etc. We just use a different definition of the term “side-effect”.
In “pure” functional programming, side-effects are things that break referential transparency. When we talk about a program without side-effects, we mean programs without breaking or violating referential transparency.
And if we want to refer to things like I/O (input/output) or talking to a database, we use the term “computational effects” or “effects”.
Well, sometimes we also say “side-effects” because natural languages are also complicated, so it can all be confusing and ambiguous without context and experience.
Referential Transparency in the wild
Referential Transparency and Rust
Okay, let’s make some chaos and print some nonsense!
We’ll start with a Rust example, but it applies to any language without referential transparency guarantees: Java, JavaScript, Python, and what have you.
You must keep an eye on your code – the code might not behave as expected, and refactoring might break the logic! Let’s test it:
let a: i32 = {
println!("the toast is done");
3
};
let result = (a + 1) + (a * 4);
println!("Result is {result}");
// Prints:
// the toast is done
// Result is 16
If we inline a
, first of all, it looks a bit ugly, but more importantly, we get different results:
let result = ({
println!("the toast is done"); 3} + 1)
+ ({ println!("the toast is done"); 3} * 4);
println!("Result is {result}");
// Prints:
// the toast is done
// the toast is done
// Result is 16
The second version prints that the toast is done twice.
In most languages, we can perform arbitrary side effects anywhere and anytime, so don’t expect any referential transparency.
Referential Transparency and Scala
But some languages, such as Scala, give us more control over referential transparency.
We can do the same exercise we did with Rust, but we can also go one step further and write some async code.
Imagine we have two functions with some arbitrary side-effects:
def computeA(): Int = { println("Open up a package of My Bologna"); 1 }
def computeB(): Int = { println("Ooh, I think the toast is done"); 2 }
đź“ą The first function does some printing and returns 1
.
The second one does some other printing and returns 2
.
Future
 represents a result of an asynchronous computation, which may become available at some point. When we create a new Future
, Scala starts an asynchronous computation and returns a future holding the result of that computation.
So let’s spawn our computations:
import scala.concurrent.Future
val fa = Future(computeA())
val fb = Future(computeB())
for {
a <- fa
b <- fb
} yield a + b
// Probably prints:
// Open up a package of My Bologna
// Ooh, I think the toast is done
// or prints:
// Ooh, I think the toast is done
// Open up a package of My Bologna
💡 Note that we must provide an implicit ExecutionContext
to run this code.
For example, by importing the global one:
import concurrent.ExecutionContext.Implicits.global
Async execution is unpredictable. By looking at this snippet, we, as developers, shouldn’t expect any guarantees about execution order: we can see the effects of the computeA
first or from the computeB
. It is up to the thread pools and compilers to decide. And it’s okay; that’s the whole premise of asynchronous programming.
What is not okay is that if we try refactoring this code by inlining the variables, we get a different program:
for {
a <- Future(computeA())
b <- Future(computeB())
} yield a + b
// Will print:
// Open up a package of My Bologna
// Ooh, I think the toast is done
This one is sequential. Why? Because it’s not referentially transparent.
Luckily, multiple alternatives guarantee RT; one of them is cats-effect
IO
.
💡 Note that IO
isn’t the same as I/O.
A value of typeÂ
IO[A]
 is a computation that, when evaluated, can perform effects before returning a value of typeÂA
.
IO
data structure is similar to Future
, but its values are pure, immutable, and preserve referential transparency.
The following programs are equivalent:
import cats.effect.IO
val fa = IO(computeA())
val fb = IO(computeB())
for {
a <- fa
b <- fb
} yield a + b
for {
a <- IO(computeA())
b <- IO(computeB())
} yield a + b
The computations will run sequentially – first, a
and then b
.
💡 If we want to run them in parallel, we have to be explicit:
import cats.implicits._
(IO(computeA()), IO(computeB())).parMapN(_ + _)
So what is happening here? How is IO
referentially transparent? Let’s switch to Haskell and debunk this datatype.
Referential Transparency and Haskell
Haskell is pure – invoking any function with the same arguments always returns the same result.
Haskell separates ”expression evaluation” from “action execution”.
Expression evaluation is the world where pure functions live and which is always referentially transparent.
Action execution is not referentially transparent.
Haskell also has an IO
datatype. As I’ve mentioned before, IO a
 is a computation: when executed, it can perform arbitrary effects before returning a value of type a
. But here comes the essential point. Executing IO
 is not the same as evaluating it. Evaluating an IO
 expression is pure.
For instance, here is the type signature of print
:
print :: Show a => a -> IO ()
This function returns a pure value of type IO ()
(unit) – a value like any other: we can pass them around, store them in collections, etc.
Okay, we have an IO
function. How do we run it? We need to define the main
IO
function of the program – the program entry point – which will be executed by the Haskell runtime. Let’s look at the executable module example: it asks the user for the name, reads it, and prints the greeting.
module Main where
greet :: String -> IO ()
greet name = print $ "Hello, " <> name
main :: IO ()
main = do
print "What's your name?"
name <- getLine
greet name
📹 If we run the program and pass it the name Ali, it will print back the greeting “Hello, Ali.”
This differs from all the languages in which we can perform side effects anywhere and anytime. We can’t print outside of IO
. We get a compilation error if we try otherwise:
noGreet :: String -> String
noGreet name = print $ "Hello, " <> name
-- ^^^^^^^^^^^^^^^^^^^^^^^^^
-- Couldn't match type: IO ()
-- with: String
Referential Transparency and Analytical Philosophy
Some trivia before we wrap up: referential transparency has its roots in analytical philosophy.
The "referent", the thing that an expression refers to, can substitute the "referrer" without changing the meaning of the expression.
For example, let’s take the statement:
"The author of My Bologna is best known for creating comedy songs."
"The author of My Bologna" references "Weird Al Yankovic". This statement is referentially transparent because "The author of My Bologna" can be replaced with "Weird Al Yankovic". The message will have the same meaning.
But the following statement is not referentially transparent:
"My Bologna is Weird Al Yankovic's first song.”
We can't do such a replacement because it produces a sentence with an entirely different meaning: "My Bologna is the author of My Bologna's first song".
Conclusion
In conclusion, a side effect is a lack of referential transparency. Referential transparency allows us to trust the functions and reason about the code. It gives us the following:
- local reasoning;
- smaller debugging overload;
- maintainable code;
- explicit and expected behavior;
- less painful refactoring.
This property is a crucial advantage and source of many good things about “pure” functional programming.
🖼️ If you want some recaps or cheat sheets, checkout these pictures:
Posted on February 28, 2023
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.