The following post is an excerpt of the first chapter of the companion book to our new course Essential Effects.

We often use the term effect when talking about the behavior of our code, like “What is the effect of that operation?” or, when debugging, “Doing that shouldn’t have an effect, what’s going on?”, where “what’s going on?” is most likely replaced with an expletive. But what is an effect? Can we talk about effects in precise ways, in order to write better programs that we can better understand?

To explore what effects are, and how we can leverage them, we’ll distinguish two aspects of code: computing values and interacting with the environment. At the same time, we’ll talk about how transparent, or not, our code can be in describing these aspects, and what we as programmers can do about it.

The Substitution Model of Evaluation

Let’s start with the first aspect, computing values. As a programmer, we write some code, say a method, and it computes a value that gets returned to the caller of that method:

def plusOne(i: Int): Int = // <1>
i + 1

val x = plusOne(plusOne(12)) // <2>

Here are some of the things we can say about this code:

  1. plusOne is a method that takes an Int argument and produces an Int value. We often talk about the type signature, or just signature, of a method. plusOne has the type signature Int => Int, pronounced "Int to Int" or "plusOne is a function from Int to Int".
  2. x is a value. It is defined as the result of evaluating the expression plusOne(plusOne(12)).

Let’s use substitution to evaluate this code. We start with the expression plusOne(plusOne(12)) and substituting each (sub-)expression with its definition, recursively repeating until there are no more sub-expressions:

TIP: We’re displaying the substitution process as a “diff” you might see in a code review. The original expression is prefixed with -, and the result of substitution is prefixed with +.

  1. Replace the inner plusOne(12) with its definition:

    - val x = plusOne(plusOne(12))
    + val x = plusOne(12 + 1))
  2. Replace 12 + 1 with 13:

    - val x = plusOne(12 + 1))
    + val x = plusOne(13))
  3. Replace plusOne(13) with its definition:

    - val x = plusOne(13))
    + val x = 13 + 1
  4. Replace 13 + 1 with 14:

    - val x = 13 + 1
    + val x = 14

It is important to notice some particular properties of this example:

  1. To understand what plusOne does, you don’t have to look anywhere except the (literal) definition of plusOne. There are no references to anything outside of it. This is sometimes referred to as local reasoning.
  2. Under substitution, programs mean the same thing if they evaluate to the same value. 13 + 1 means exactly the same thing as 14. So does plusOne(12 + 1), or even (12 + 1) + 1.

To quote myself while teaching an introductory course on functional programming, “[substitution] is so stupid, even a computer can do it!”. It would be fantastic if all programs were as self-contained as plusOne, so we humans could use substitution, just like the machine, to evaluate code. But not all code is compatible with substitution.

When does substitution break down?

Take a minute to think of some examples of expressions where substitution doesn’t work. What about them breaks substitution?

Here are a few examples you might have thought of:

  1. When printing to the console.

    The println function prints a string to the console, and has the return type Unit. If we apply substitution,

    - val x = println("Hello world!")
    + val x = ()

    the meaning–the effect–of the first expression is very different from the second expression. Nothing is printed in the latter. Using substitution doesn’t do what we intend.

  2. When reading values from the outside world.

    If we apply substitution,

    - val name = readLine
    + val name = <whatever you typed in the console>

    name evaluates to whatever particular string was read from the console, but that particular string is not the same as the evaluation of the expression readLine. The expression readLine could evaluate to something else.

  3. When expressions refer to mutable variables.

    If we interact with mutable variables, the value of an expression depends any possible change to the variable. In the following example, if any code changes the value of i, then that would change the evaluation of x as well.

    var i = 12

    - val x = { i += 1; i }
    + val x = 13

    (This example is very similar to the previous one.)

Dealing with Side-effects

The second aspect of effects, after computing values, is interacting with the environment. And as we’ve seen, this can break substitution. Environments can change, they are non-deterministic, so expressions involving them do not necessarily evaluate to the same value. If we use mutable state, if we perform hidden side-effects–if we break substitution–is all lost? Not at all.

One way we can maintain the ability to reason about code is to localize the “impure” code that breaks substitution. To the outside world, the code will look–and evaluate–as if substitution is taking place. But inside the boundary, there be dragons:

def sum(ints: List[Int]): Int = {
var sum = 0 // <1>

ints.foreach(i => sum += i)

sum
}

sum(List(1, 2, 3)) // <2>
  1. We’ve used a mutable variable. The horrors! But nothing outside of sum can ever affect it. Its existence is localized to a single invocation.
  2. When we evaluate the expression that uses sum, we get a deterministic answer. Substitution works at this level.

We’ve optimized, in a debatable way, code to compute the sum of a list, so instead of using an immutable fold over the list we’re updating a local variable. From the caller’s point to view, substitution is maintained. Within the impure code, we can’t leverage the reasoning that substitution gives us, so to prove to ourselves the code behaved we’d have to use other techniques that are outside the scope of this book.

Localization is a nice trick, but won’t work for everything that breaks substitution. We need side-effects to actually do something in our programs, but side-effects are unsafe! What can we do?

The Effect Pattern

If we impose some conditions, we can tame the side-effects into something safer; we’ll call these effects. There are two parts:

  1. The type of the program should tell us what kind of effects the program will perform, in addition to the type of the value it will produce.
    One problem with impure code is we can’t see that it is impure! From the outside it looks like a method or block of code. By giving the effect a type we can distinguish it from other code. At the same time, we continue to track the type of the result of the computation.
  2. If the behavior we want relies upon some externally-visible side-effect, we separate describing the effects we want to happen from actually making them happen. We can freely substitute the description of effects up until the point we run them.
    This idea is exactly the same as the localization idea, except that instead of performing the side-effect at the innermost layer of code and hiding it from the outer layers, we delay the the side-effect so it executes outside of any evaluation, ensuring substitution still holds within.

We’ll call these conditions the Effect Pattern, and apply it to studying and describing the effects we use every day, and to new kinds of effects.

Example: Is Option an Effect?

The Option type represents the optionality of a value: we have some value, or we have none. In Scala it is encoded as an algebraic data type:

sealed trait Option[+A]

case class Some[A](value: A) extends Option[A]
case object None extends Option[Nothing]

Is Option[A] an effect? Let’s check the criteria:

  1. Does Option[A] tell us what kind of effects the program will perform, in addition to the type of the value it will produce?
    Yes: if we have a value of type Option[A], we know the effect is optionality from the name Option, and we know it may produce a value of type A from the type parameter A.
  2. Are externally-visible side-effects required?
    Not really. The Option algebraic data type is an interface representing optionality that maintains substitution. We can replace a method call with its implementation and the meaning of the program won’t change.

There is one exception–pun intended–where an externally-visible side-effect might occur:

def get(): A =
this match {
case Some(a) => a
case None => throw new NoSuchElementException("None.get")
}

Calling get on a None is a programmer error, and raises an exception which in turn may result in a stack trace being printed. However this side-effect is not core to the concept of exceptions, it is just the implementation of the default exception handler. The essence of exceptions is non-local control flow: a jump to an exception handler in the dynamic scope. This is not an externally-visible side effect.

With these two criteria satisfied, we can say yes, Option[A] is an effect!

It may seem strange to call Option an effect since it doesn’t perform any side-effects. The point of the first condition of the Effect Pattern is that the type should make the presence of an effect visible. A traditional alternative to Option would be to use a null value, but then how could you tell that a value of type A could be null or not? Some types which could have a null value are not intended to have the concept of a missing value. Option makes this distinction apparent.

Example: Is Future an Effect?

Future is known to have issues that aren’t easily seen. For example, look at this code, where we reference the same Future to run it twice:

val print = Future(println("Hello World!"))
val twice =
print
.flatMap(_ => print)

What output is produced?

Hello World!

It is only printed once! Why is that?

The reason is that the Future is scheduled to be run immediately upon construction. So the side-effect will happen (almost) immediately, even when other “descriptive” operations–the subsequent print in the flatMap—happen later. That is, we describe performing print twice, but the side-effect is only executed once!

Compare this to what happens when we substitute the definition of print into twice:

  1. Replace the first reference to print with its definition:
      val print = Future(println("Hello World!"))
    val twice =
    - print
    + Future(println("Hello World!"))
    .flatMap(_ => print)
  2. Replace the second reference to print with its definition, and remove the definition of print since it has been inlined.
    - val print = Future(println("Hello World!"))
    val twice =
    Future(println("Hello World!"))
    - .flatMap(_ => print)
    + .flatMap(_ => Future(println("Hello World!")))

We now have:

val twice =
Future(println("Hello World!"))
.flatMap(_ => Future(println("Hello World!")))

Running it, we then see:

Hello World!
Hello World!

This is why we say Future is not an effect: when we do not separate effect description from execution, as per our Effect Pattern, substitution of expressions with their definitions doesn’t have the same meaning,

Capturing Arbitrary Side-Effects as an Effect

We’ve seen the Option effect type, which doesn’t involve side-effects, and we’ve examined why Future isn’t an effect. So what about an effect that does involve side-effects, but safely?

This is the purpose of the IO effect type in cats.effect. It is a data type that allows us to capture any side-effect, but in a safe way, following our Effect Pattern. We’ll first build our own version of IO to understand how it works.

A simple MyIO data type

case class MyIO[A](unsafeRun: () => A) { // <1>
def map[B](f: A => B): MyIO[B] =
MyIO(() => f(unsafeRun())) // <2>

def flatMap[B](f: A => MyIO[B]): MyIO[B] =
MyIO(() => f(unsafeRun()).unsafeRun()) // <2>
}
  1. When we construct an MyIO value, we give it a function which computes the result and may perform side-effects, but don’t invoke it yet. When we want to execute the side-effect, we call unsafeRun.
  2. We can compose our MyIO value to produce new MyIO values using map and flatMap. But notice we always create a new MyIO value, with its own delayed unsafeRun side-effect, to ensure no side-effects are executed until the outer unsafeRun is invoked.

For example, we might define printing to the console as an MyIO value:

def putStr(s: => String): MyIO[Unit] =
MyIO(() => println(s))

val hello = putStr("hello!")
val world = putStr("world!")
val helloWorld =
for {
_ <- hello
_ <- world
} yield ()

helloWorld.unsafeRun()

which outputs

hello!
world!

MyIO as an Effect

Let’s check MyIO against our Effect Pattern:

  1. Does the type of the program tell us…
    a. What kind of effects the program will perform?
    An MyIO represents a (possibly) side-effecting computation.
    b. What type of value will it will produce?
    A value of type A, if the computation is successful.
  2. When externally-visible side-effects are required, is the effect description separate from the execution?
    Externally-visible side-effects are required: the unsafeRun method of an MyIO can do anything, including side-effects.
    We describe MyIO values by constructing them and by composing with methods like map and flatMap. The execution of the effect only happens when the unsafeRun method is called.

Therefore, MyIO is an effect!

By satisfying the Effect Pattern we know the MyIO effect type is safe to use, even when programming with side-effects. At any point before we invoke unsafeRun we can rely on substitution, and therefore we can replace any expression with its value–and vice-versa–to safely refactor our code.

The cats.effect.IO data type uses very similar techniques to isolate the captured side-effect from any methods used for composition.

Summary

  1. The substitution model of evaluation gives us local reasoning and fearless refactoring.
  2. Interacting with the environment can break substitution. We can localize these side-effects so they don’t affect evaluation.
  3. The Effect Pattern is a set of conditions that makes the presence of effects more visible while ensuring substitution is maintained. An effect’s type tells us what kind of effects the program will perform, in addition to the type of the value it will produce. Effects separate describing what we want to happen from actually making them happen. We can freely substitute the description of effects up until the point we run them.
  4. We created the MyIO[A] effect, which delayed the side-effect until the unsafeRun method is called. We produced new MyIO values with the map and flatMap combinators. cats-effect and other implementations of the IO monad allow us to safely program and refactor our programs, even in the presence of side-effects.