DEV Community

Cover image for Why are FP devs obsessed with Referential Transparency?
Zelenya
Zelenya

Posted on

Why are FP devs obsessed with Referential Transparency?

📹 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:

Latest comments (2)

Collapse
 
ivarkesav profile image
ivarkesav

hi @zelenya,

Great article. I am a FP newbie mostly working in Scala. I have a question about Option[A] effect in scala (alvinalexander.com/scala/what-effe...)

Can we consider Option[A] to be referentially transparent ? I tried to check it by doing some trivial substitutions and it appeared to be so, am I missing something.

Is there way to know which effects are referentially transparent and which are not ?

Ivar

Collapse
 
zelenya profile image
Zelenya

Yes, you're right, Option can be referentially transparent, but only if you use "correctly". There is no easy way to control side-effects in Scala other than discipline. Libraries like Cats and ZIO make it easier.

By "correctly", I mean not using side-effects with Options; unfortunately, Scala doesn't stop you from that.

// Referentially transparent usage
// No printing, no problems

val a = Some (3)

(a, a) match {
  case (Some(a1), Some(a2)) => println(f"Result is $a1 and $a2");
  case _ => println(f"No results");
} 

(Some (3), Some (3)) match {
  case (Some(a1), Some(a2)) => println(f"Result is $a1 and $a2");
  case _ => println(f"No results");
} 
Enter fullscreen mode Exit fullscreen mode
// Not referentially transparent usage

// Prints "the toast is done" once:
val a = Some {
  println("the toast is done");
  3
};

(a, a) match {
  case (Some(a1), Some(a2)) => println(f"Result is $a1 and $a2");
  case _ => println(f"No results");
} 

// Prints "the toast is done" twice:
(Some ({println("the toast is done");3}), Some ({println("the toast is done");3})) match {
  case (Some(a1), Some(a2)) => println(f"Result is $a1 and $a2");
  case _ => println(f"No results");
Enter fullscreen mode Exit fullscreen mode

Also, I can recommend the talk Functional Programming with Effects by Rob Norris, which is an excellent overview of different effects in Scala.