Identify Side Effects And Refactor Fearlessly

When we refactor code how can we be confident that we don't break anything?

3 of the most important things that allow us to refactor fearlessly are:

  • Side effect free - or pure - expressions
  • Statically typed expressions
  • Tests

In this article we will solely focus on the aspect of side effects and strictly speaking on how to identify them. Being able to identify side effects in our programs clearly is the precondition for eliminating them.

Why avoid side effects?

In the book "Functional Programming in Scala" Runar Bjarnason and Paul Chiusano state that a pure function "has no observable effect on the execution of the program other than to compute a result given its inputs".

This has significant benefits:

Reason about the behavior

We can safely replace any expression in our program with the value that it evaluates to - known as substitution model. We can interpret the code by substituting equals by equals as if the program was a mathematical expression. This is also called equational reasoning.

All relevant in- and outputs are declared in a function's type. This gives us an incredible advantage when we try to understand and reason about the code.

Testability

A side effect free program is deterministic. For the same input it will always return the same result.

This also helps reasoning as well as testability because:

  • It is much easier to verify that the output is correct because it is deterministic
  • And it is easier to write tests because all relevant inputs can be passed as arguments

Refactor without fear

We can always assign a sub-expression to a variable and replace all occurrences of the sub-expression with that variable. When we do this refactoring we can be absolutely sure that the behavior of the program remains unchanged.

How to identify side effects

The following sections will contain a compiled list of common examples of side effects that we encounter in every day programming.

The intention is to provide a compendium of typical examples of side effects that helps the reader to improve their ability to spot similar side effects in their own code.

There are multiple techniques that can be applied to eliminate side effects. A good first advice is to strictly separate side effecting code from pure code. However, this is beyond the scope of this post.

Instead will focus on identifying side effects.

Referential transparency

A function or an expression is either pure (has no side effects) or impure (has side effects). There is no in between.

If we can substitute any sub-expression of a program with the value it evaluates to without changing the behavior of the program the code is free of side effects.

This property is also known as referential transparency.

https://impurepics.com/

What is referential transparency, by Impure Pics used with kind permission

A test that reveals side effects

To proof that an expression is not referentially transparent - and therefore has side effects - we only need to show a single example where the substitution of an expression by its value changes the behavior of the program.

E.g. if the programs p1 and p2 in the following listing do not behave the same they are not referentially transparent.

val p1 = (<expr>, <expr>)

val p2 = {
  val a = <expr>
  (a, a)
}

Here is an example of a pure program, p1 and p2 produce the same result and there is no other observable effect:

val p1 = (1 + 2, 1 + 2)
// p1: (Int, Int) = (3, 3)

val p2 = {
  val a = 1 + 2
  (a, a)
}
// p2: (Int, Int) = (3, 3)

Compendium of common side effects

Let's now look at a list of typical examples of side effects. It is not exhaustive but it should contain the most common cases. If you find any other good examples I will be more than happy to include them here.

Every type of side effect is presented with a small proof according to the test from above.

Program output

println

val print1 = (println("hi"), println("hi"))
// hi
// hi
// print1: (Unit, Unit) = ((), ())

val print2 = {
  val a = println("hi")
  (a, a)
}
// hi
// print2: (Unit, Unit) = ((), ())

Program behavior:

  • print1 prints "hi" twice whereas print2 prints "hi" only once

Intuition:

  • Change something in the outside world
  • Often returns void or Unit

Similar:

  • Write to, change, or delete a file
  • Insert, update, delete in a database
  • Call external services (e.g. unsafe HTTP methods like POST, PUT or DELETE)
  • Send a message to a message broker or pub/sub system
  • etc. …

Elimination strategies:

  • Separate from pure code and only execute at the latest possible moment (at the end of the world)
  • Use a type for encoding side effects as pure values, e.g. IO
  • Use writer monad

Program input

readLine

val read1 = (scala.io.StdIn.readLine, scala.io.StdIn.readLine)
// p2a: (String, String) = ("foo", "bar")

val read2 = {
  val a = scala.io.StdIn.readLine
  (a, a)
}
// p2b: (String, String) = ("foo", "foo")

Program behavior:

  • read1 prompts for input twice whereas read2 prompts only once
  • Therefore the result of read2 will always be a tuple of the same values whereas the result of read1 may contain different values depending of the user input

Intuition:

  • Read a value from the outside world
  • Sometimes does not take parameters
  • Not deterministic

Similar:

  • Read from a file
  • Read from a database
  • Get a result from an external service (e.g. safe HTTP methods like GET,HEAD or OPTIONS)
  • Consume messages from a broker or pub/sub system
  • Read environment variables or system properties
  • etc. …

Elimination strategies:

  • Strictly separate from pure code
  • Use a type for encoding side effects as pure values, e.g. IO

Mutable State

Mutable variables

val mutable1 = {
  var counter = 0
  def inc() = {counter += 1; counter}
  (inc(), inc())
}
// mutable1: (Int, Int) = (1, 2)

val mutable2 = {
  var counter = 0
  def inc() = {counter += 1; counter}
  val a = inc()
  (a, a)
}
// mutable2: (Int, Int) = (1, 1)

Program behavior:

  • In this example the side effect is the mutation of the variable counter. Like in the previous examples the value is incremented twice in the first program and only once in the second one.

Intuition:

  • Watch out for the var key word

Similar:

  • Anything that is mutable

Elimination strategies:

  • Simply do not use mutable variables
  • And if you have to: Don't
  • And if you still have to: Contain the side effect locally within the smallest possible scope and make sure it does not leak
  • Use MVar or Ref
  • Use the state monad

Mutable objects

Similar to mutable variables.

class Thing() {
  private var position_ : Int = 0
  def move(distance: Int) = position_ += distance
  override def toString: String = s"Thing(position = ${this.position_})"
}

val obj1 = {
  val x = new Thing()
  ({x.move(1); x}, {x.move(1); x})
}
// obj1: (Thing, Thing) = (Thing(position = 2), Thing(position = 2))

val obj2 = {
  val x = new Thing()
  val a = {x.move(1); x}
  (a, a)
}
// obj2: (Thing, Thing) = (Thing(position = 1), Thing(position = 1))

Program behavior:

  • Again, similar to the previous examples the refactored version only performs the mutation once instead of twice

Similar:

  • Anything that is mutable

Elimination strategies:

  • Use object or case class instead of class
  • Instead of mutating the object return a new object

Iterator

val iter1 = {
  val xs = List(1,2,3).iterator

  (xs.next, xs.next)
}
// iter1: (Int, Int) = (1, 2)

val iter2 = {
  val xs = List(1,2,3).iterator

  val a = xs.next
  (a, a)
}
// iter2: (Int, Int) = (1, 1)

Program behavior:

  • Similar to the previous examples the refactored version only performs the mutation once instead of twice

Elimination strategies:

  • Don't use it
  • If you have to: Contain the side effect locally and make sure it doesn't leak

ListBuffer

import scala.collection.mutable.ListBuffer

val buffer1 = {
  val xs = ListBuffer(1, 2)
  (xs.append(3), xs.append(3))
}
// buffer1: (ListBuffer[Int], ListBuffer[Int]) = (
//   ListBuffer(1, 2, 3, 3),
//   ListBuffer(1, 2, 3, 3)
// )

val buffer2 = {
  val xs = ListBuffer(1, 2)
  val a = xs.append(3)
  (a, a)
}
// buffer2: (ListBuffer[Int], ListBuffer[Int]) = (
//   ListBuffer(1, 2, 3),
//   ListBuffer(1, 2, 3)
// )

Program behavior:

  • Similar to the previous examples the refactored version only performs the mutation once instead of twice

Similar:

  • StringBuilder

Elimination strategies:

  • Don't use it
  • If you have to: Contain the side effect locally and make sure it doesn't leak

Random

scala.util.Random

import scala.util._

val random1 = (Random.nextInt, Random.nextInt)
// random1: (Int, Int) = (-749222584, 1952393011)

val random2 = {
  val a = Random.nextInt
  (a, a)
}
// random2: (Int, Int) = (1459812504, 1459812504)

Program behavior:

  • The first program likely produces two different random values whereas the refactored version produces the same value twice

Intuition:

  • Does not take parameters
  • Not deterministic

Elimination strategies:

  • Use a purely functional number generator (optionally combined with the state monad)
  • Use a type for encoding side effects as pure values, e.g. IO

java.util.UUID.randomUUID

import java.util.UUID

val randomUuid1 = (UUID.randomUUID, UUID.randomUUID)
// randomUuid1: (UUID, UUID) = (
//   fa1f4167-ed59-45ad-92b8-f4dfca2fbee9,
//   3dfa47c2-78f4-406e-9c61-364ca60d646a
// )

val randomUuid2 = {
  val a = UUID.randomUUID
  (a, a)
}
// randomUuid2: (UUID, UUID) = (
//   006f53a4-b73f-4237-9e18-6e2e1ceb7291,
//   006f53a4-b73f-4237-9e18-6e2e1ceb7291
// )

Program behavior:

  • The first program produces two different UUIDs whereas the refactored version produces only one

Intuition:

  • Does not take parameters
  • Not deterministic

Elimination strategies:

  • Create a UUID from a seed (be aware that obtaining a useful seed can also be a side effect)
  • Use a type for encoding side effects as pure values, e.g. IO

Get the current time

System.currentTimeMillis

def f(): String = { Thread.sleep(1); "done" }

var time1 = {
  val start = System.currentTimeMillis
  f()
  val end = System.currentTimeMillis
  (start, end)
}  
// time1: (Long, Long) = (1580764607046L, 1580764607047L)  

var time2 = {
  val a = System.currentTimeMillis
  val start = a
  f()
  val end = a
  (start, end)
}
// time2: (Long, Long) = (1580764607049L, 1580764607049L)

Program behavior:

  • The first program produces different start and end times whereas the refactored version produces the same start and end time

Intuition:

  • Does not take parameters
  • Not deterministic

Similar:

  • Instant.now
  • ZonedDateTime.now
  • LocalDateTime.now

Elimination strategies:

  • Sometimes you can get away with passing the current time as a parameter after obtaining it at the entry point of the program (which follows the strategy of separating pure and impure code)
  • If that does not work: Use a type for encoding side effects as pure values, e.g. IO

Exceptions

val except1 = Try {
  try {
    throw new Exception
  } catch {
    case _: Throwable => 42
  }
}
// except1: Try[Int] = Success(42)

val except2 = Try {
  val a: Int = throw new Exception
  try {
    a
  } catch {
    case _: Throwable => 42
  }
}
// except2: Try[Int] = Failure(java.lang.Exception)

Program behavior:

  • The two programs produce different results

Elimination strategies:

  • Do not catch exceptions!
  • If you have to: Don't
  • If you still have to:
    • Never use exceptions for control flow!
    • And catch them either right where they might occur or at the outer most level of your program
  • Do not throw exceptions
  • If you throw exceptions: Do not catch them. Ever. Period.
  • Encode errors as pure values using Either

The end?

Well, I hope this is not the end. I would like to update and improve this list of common examples of side effects in the future.

So if you encounter any interesting situation related to side effects that is not at least briefly covered here, please send me some code.

Also, if you have any further questions about the examples from above or if you have a piece of code where you are not sure if it is pure or not, do not hesitate to contact me or leave a comment.

Thanks for reading!

The code was compiled with mdoc.