Why are FP devs obsessed with Referential Transparency?

Zelenya - Feb 28 '23 - - Dev Community

šŸ“¹Ā 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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)
Enter fullscreen mode Exit fullscreen mode
(<expr>, <expr>)
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

We can replace a with 3:

(3 + 1) + (3 * 4) // 16
Enter fullscreen mode Exit fullscreen mode

And the result stays the same. We can do the same exercise with strings:

let a = "Three"
a ++ a // "ThreeThree"
Enter fullscreen mode Exit fullscreen mode

Where the ++ operator is a string concatenation.

"Three" ++ "Three" // "ThreeThree"
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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.

impure function works depending on your network status

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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 }
Enter fullscreen mode Exit fullscreen mode

šŸ“¹ 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
Enter fullscreen mode Exit fullscreen mode

šŸ’”Ā Note that we must provide an implicit ExecutionContext to run this code.

For example, by importing the global one:

import concurrent.ExecutionContext.Implicits.global
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode
for {
  a <- IO(computeA())
  b <- IO(computeB())
} yield a + b
Enter fullscreen mode Exit fullscreen mode

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(_ + _)
Enter fullscreen mode Exit fullscreen mode

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 ()
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

šŸ“¹ 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
Enter fullscreen mode Exit fullscreen mode

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:

. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .
Terabox Video Player