š¹Ā 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: