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:
plusOne
is a method that takes anInt
argument and produces anInt
value. We often talk about the type signature, or just signature, of a method.plusOne
has the type signatureInt => Int
, pronounced "Int
toInt
" or "plusOne
is a function fromInt
toInt
".x
is a value. It is defined as the result of evaluating the expressionplusOne(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 +
.
-
Replace the inner
plusOne(12)
with its definition:- val x = plusOne(plusOne(12))
+ val x = plusOne(12 + 1)) -
Replace
12 + 1
with13
:- val x = plusOne(12 + 1))
+ val x = plusOne(13)) -
Replace
plusOne(13)
with its definition:- val x = plusOne(13))
+ val x = 13 + 1 -
Replace
13 + 1
with14
:- val x = 13 + 1
+ val x = 14
It is important to notice some particular properties of this example:
- To understand what
plusOne
does, you don’t have to look anywhere except the (literal) definition ofplusOne
. There are no references to anything outside of it. This is sometimes referred to as local reasoning. - Under substitution, programs mean the same thing if they evaluate to the same value.
13 + 1
means exactly the same thing as14
. So doesplusOne(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:
-
When printing to the console.
The
println
function prints a string to the console, and has the return typeUnit
. 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.
-
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 expressionreadLine
. The expressionreadLine
could evaluate to something else. -
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 ofx
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>
- 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. - 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:
- 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. - 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:
- 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 typeOption[A]
, we know the effect is optionality from the nameOption
, and we know it may produce a value of typeA
from the type parameterA
. - Are externally-visible side-effects required?
Not really. TheOption
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
:
- Replace the first reference to
print
with its definition:val print = Future(println("Hello World!"))
val twice =
- print
+ Future(println("Hello World!"))
.flatMap(_ => print) - Replace the second reference to
print
with its definition, and remove the definition ofprint
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>
}
- 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 callunsafeRun
. - We can compose our
MyIO
value to produce newMyIO
values usingmap
andflatMap
. But notice we always create a newMyIO
value, with its own delayedunsafeRun
side-effect, to ensure no side-effects are executed until the outerunsafeRun
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:
- Does the type of the program tell us…
a. What kind of effects the program will perform?
AnMyIO
represents a (possibly) side-effecting computation.
b. What type of value will it will produce?
A value of typeA
, if the computation is successful. - When externally-visible side-effects are required, is the effect description separate from the execution?
Externally-visible side-effects are required: theunsafeRun
method of anMyIO
can do anything, including side-effects.
We describeMyIO
values by constructing them and by composing with methods likemap
andflatMap
. The execution of the effect only happens when theunsafeRun
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
- The substitution model of evaluation gives us local reasoning and fearless refactoring.
- Interacting with the environment can break substitution. We can localize these side-effects so they don’t affect evaluation.
- 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.
- We created the
MyIO[A]
effect, which delayed the side-effect until theunsafeRun
method is called. We produced newMyIO
values with themap
andflatMap
combinators.cats-effect
and other implementations of theIO
monad allow us to safely program and refactor our programs, even in the presence of side-effects.