Skip to content

Instantly share code, notes, and snippets.

@kevinmeredith
Last active January 4, 2022 16:25
Show Gist options
  • Star 3 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save kevinmeredith/f46acb519137cd1a9828ab53037f7382 to your computer and use it in GitHub Desktop.
Save kevinmeredith/f46acb519137cd1a9828ab53037f7382 to your computer and use it in GitHub Desktop.
The Power of Referential Transparency

The Power of Referential Transparency

Acknowledgment

This post uses the excellent Ammonite REPL for Scala REPL.

Thank you, Li Haoyi, for building it!

About the Author

I, Kevin Meredith, have worked professionally in Scala for the past eight years. For the past four and half years I've worked professionally in pure FP in Scala. I wrote A Little Book on http4s.

Introduction

Referential Transarency delivers a significant edge. It significantly enhances the precision and speed of a Software Engineering Organization. By precision, I mean Software Engineering building software to meet a design. Consequently, greater precision leads to faster delivery.

In this post use RT interchangeably with Pure Functional Programming (FP). I am not making this claim academically. However, I believe that Software Engineers can benefit from RT and pure FP even without a precise, academic dissection of their differences.

Pure FP enables Referential Transparency (RT). In the spirit of focusing on timely shipping code to production to solve real-world business problems, I'll equate Pure FP and RT. To be clear, I respect any academic or industrial arguments on the difference between Pure FP and RT. However, having solved real-world problems in production for 3.5 years on-call, I know that businesses can enjoy the benefits of both by treating them as effectively the same.

What is Pure FP?

Let's look at a definition of Pure FP/RT, as well as examples of RT and non-RT Code.

Definition

Wikipedia explains RT:

An expression is called referentially transparent if it can be replaced with its corresponding value (and vice-versa) without changing the program's behavior.

The definition continues:

This requires that the expression be pure, that is to say the expression value must be the same for the same inputs and its evaluation must have no side effects.

Let's walk through a few terms, namely expression and program.

n8gray provides a good definition of an expression:

An expression is anything that can be evaluated to produce a value.

Expressions in Scala consist of val's and def's. So a program is the larger context in which the expressions exist.

Let's consider a concrete REPL example. Example.program is a program and each private def is an expression. Note that each expression is not RT. In the next section I'll walk through RT and non-RT code examples.

@ object Example {
    private def getName: String = scala.io.StdIn.readLine()
  
    private def printHi: Unit = println("hi - what's your name?")
  
    private def printName(name: String): Unit = println(s"hi $name!")   
  
    def program: Unit = {
      printHi
      val name: String = getName
      printName(name)
    }
  } 
defined object Example

@ Example.program 
hi - what's your name?
Kevin
hi Kevin!

Code Examples & Explanations

1.

The given function will reverse the input list.

def reverse[A](as: List[A]): List[A] = as.reverse
@ reverse( List(1,2,3) ) 
res1: List[Int] = List(3, 2, 1)

// Reverse a list twice returns the input list
@ reverse( reverse( List(1,2,3) ) ) 
res2: List[Int] = List(1, 2, 3)

// step 1  - reverse( List(3,2,1) ) 
// step 2  - List(1,2,3)

Is this function RT? Let's consult the above definition:

An expression is called referentially transparent if it can be replaced with its corresponding value (and vice-versa) without changing the program's behavior.

So, for a list, xs, can reverse(reverse(xs)), the expression, be replaced everywhere in the program with xs, i.e. the evaluated value, without changing the meaning of the program?

Yes, reverse(reverse(xs)) can be replaced everywhere with xs and the "meaning" of the program remains un-changed. Nowhere does this function print output via println, mutate state, or throw an exception. The other definition is satisfied as well, namely that the evaluated value can be replaced by the expression without changing the program's behavior. This expression has no side effects. For a given input, it produces an output - nothing more.

2.

Let's look at another example:

@ def add1(a: Int): Int = a + 1 
defined function add1
@ add1( add1(1) ) 
res4: Int = 3

// step 1  - add1( 2 )
// step 2  - 3

For an int, 1, can add1(add1(1)) be replaced by 3 everywhere in the program without impact its meaning? Also, is the reverse true?

Yes - the "meaning" remains the same regardless of whether the expression, add1(add1(1)), or the evaluate value, 3, is used. Either can be substitued for the other without changing the program's behavior. This expression has no side effects. For a given input, it produces an output - nothing more.

3.

@ import java.time.Instant 
import java.time.Instant

@ def currentTime: Instant = Instant.now() 
defined function currentTime

Can the expression, currentTime, be replaced everywhere in the program by the result from evaluation?

Let's evaluate this function to see some outputs:

@ currentTime 
res7: Instant = 2021-06-29T02:14:54.656Z

@ currentTime 
res8: Instant = 2021-06-29T02:14:57.349Z

Observe that the currentTime expression, upon evaluation, produces different results, e.g. 2021-06-29T02:14:54.656Z and 2021-06-29T02:14:57.349Z. Of course this makes sense since Instant.now returns the current time at evaluation based on the state of the clock.

Now, let's consider whether this function is RT. Can the expression, currentTime, be replaced everywhere in the program by the value returned by its evaluation? From what we've seen, clearly the answer is no. Let's see a brief example of a program, foo.

@ def foo: Int = if(currentTime.toEpochMilli % 2L == 0L) 1 else 2 
defined function foo

This function returns an Int depending on whether currentTime's Epoch Millis value is divisible by 2. Is the meaning of this program the same if currentTime is replaced by 2021-06-29T02:14:54.656Z? What about the opposite, namely substituing 2021-06-29T02:14:54.656Z with currentTime?

Clearly the answer is no to each question since the expression will, at evaluation, return the "current time." Obviously the previously evaluated time, e.g. 2021-06-29T02:14:54.656Z, cannot be substituted for currentTime as the meaning of the program changes significantly. Nor can 2021-06-29T02:14:54.656Z, the value from evaluating currentTime, be substitued for the currentTime expression - the values won't be the same!

Let's see it on the REPL to confirm.

@ foo 
res10: Int = 2

// Replace foo's expression with the previously evaluated value of currentTime
@ if( Instant.parse("2021-06-29T02:14:54.656Z").toEpochMilli % 2L == 0L) 1 else 2  
res11: Int = 1

Note that this method is side-effecting. It relies upon the state of world, namely the time, in order to produce the current time. It does not consume any inputs, but rather depends entirely upon mutable state, the time. It is not RT.

4.

The expression, greet, will print out the input name, and then return (), the only value of type Unit.

@ def greet(name: String): Unit = 
    println(s"hi $name") 
defined function greet

@ greet("Bob") 
hi Bob

The evaluated value of greet is (), i.e. that's what the expression returns.

Can the expression, greet("Bob"), be substitued with () everywhere in the program without impacting the behavior? Clearly the answer is no. The expression will print a message to standard output, and then return (). The value, (), has a different meaning than () with a side effect, a printed message.

As a result, no, this expression is not RT. Note that greet is side-effecting, namely it prints to standard output via println.

What about Input/Output?

Real-world business applications require interacting with the real world, example: making HTTP requests, persisting data in a database, etc. Can RT programs be built that deliver real-world business value if we can't side effect? Yes.

The answer lies in what's called IO.

I am not going to cover this topic in this post. However, I encourage you to read Professor Brent Yorgey's excellent lecture. When I first struggled to understand IO, Professor Yorgey's lecture helped me along on journey.

Conclusion - Who Cares about RT?

Let's look at a simple math expression 5!.

We can break it down into each evaluation step:

step 1: 5!
step 2: 5 * 4!
step 3: 5 * 4 * 3!
step 4: 5 * 4 * 3 * 2!
step 5: 5 * 4 * 3 * 2 * 1!
step 6: 5 * 4 * 3 * 2 * 1
step 7: 120

The beauty of RT is that, for our reverse and add1 examples, we were able to use the same technique, step-by-step evaluation, to understand our code.

RT delivers a superpower to Software Engineers: enhanced understanding of their code. Software Engineers deliver value by understanding business problems and domains, and then building solutions promptly to meet requirements. In addition, while on-call, they must be able to understand programs quickly when dealing with real-world incidents where $ and customer happiness is at stake. In short, RT enables Software Engineers to greatly increase their value by increasing their precision and confidence in their code's satisfaction of intent.

References

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment