Effects
← Previous in this series: Type Parrameters
It’s common for people who are not very familiar with functional programming to say that in functional programming you don’t do side effects. But actually, performing side effects, is one of strengths of the functional approach to programming, and it’s when you have a complex enough system with lots of side effects happening, that FP shines.
What Are Side Effects?
Before we go any further, we need to define what are side effects. Let’s define them in terms of desirable properties of our code. In chapter three, we talked about pure functions. One emergent property of a pure function is something called referential transparency. Being referentially transparent, means that an expression can be replaced by its result value without changing the meaning of the programme. This is a highly desirable property to have in a codebase, as it allows for local reasoning, I.E. you don’t need to leave the scope of the current function to understand what it is doing.
Knowing this, it becomes very easy to define side effects. A side effect is anything that breaks referential transparency. This means that anything that a function does which causes an observable consequence outside of it (other than, of course, that which is caused by the run-time in order to run the code), or that causes the function to depend on observing external state, breaks referential transparency, and so that function performs side effects. For example, in languages with the concept of class instances, or variables with a scope that escapes the closed function, changing or reading one such variable/instance property, is a side effect. Furthermore, reading or writing from a file on disk, accessing a database, generating a random number (unless the seed is provided to the function, and the result depends exclusively on the seed), or fetching the current date/time, all are side effects.
Effects
The definition of effects, is much less formalised that the one for side effects. Usually in the context of Haskell and languages where a similar approach to functional programming is taken, such as Scala, effects refer to an abstraction that allows us to perform side effects while preserving referential transparency throughout our codebase.
As we mentioned previously, there’s a lot of value to be gained from making sure our functions are pure and total. If we just perform ad-hoc effects then we have neither of these properties. So how can we perform those effects then?
If you’ve done any Haskell, you’ve noticed how the main entry function will
have a type IO ()
(IO of Unit). In Scala similar types are provided by
libraries, amongst them are cats.effect.IO
, scalaz.zio.ZIO
, and
monix.eval.Task
. These are called Effect types, because they are types that
represent, and allow us to abstract at the level of effects, or as per our definition
above, allows us to perform side effects in a pure way. This might sound like a
contradiction in terms, but we’ll explain in a moment.
In this article, I’ll be using cats.effect.IO
, which going forward, I’ll be
referring to as IO
. This is because it’s the one I’m most familiar with.
Also, it is part of an ecosystem, that provides the type classes and type
class instances that allow us to abstract over it.
Referential Transparency
Let’s imagine the following simple programme:
import scala.util.Random
def addCharTo(s: String): String = s"${s}${Random.nextPrintableChar}"
When we call addCharTo
, here’s one possible result:
addCharTo("a")
// String = aF
We said that referential transparency means we can replace a call to a function by
it’s return value without altering the meaning of the programme (I.E. we get the same result).
Lets see if this is true for this programme. Our call to addCharTo
, returned
"aF")
. If our programme is referentially transparent, then we should be able
to replace addChar("a")
with s"a${Random.nextPrintableChar}"
and still get
"aF"
as a result.
Let’s see what happens:
s"a${Random.nextPrintableChar}"
// String = aJ
s"a${Random.nextPrintableChar}"
// String = aY
s"a${Random.nextPrintableChar}"
// String = a^
s"a${Random.nextPrintableChar}"
// String = aT
Not only we didn’t get "aF"
but each time we called s"a${Random.nextPrintableChar}"
we got a different result. So we can say that addChar
is not referentially
transparent, nor pure, as the reason we’re losing referential Transparency
here is because the result doesn’t depend exclusively on the input of the
function, but instead it depends on side effects.
Lets re-write this code using IO:
import scala.util.Random
import cats.effect.IO
def addCharTo(s: String): IO[String] = IO.delay(s"${s}${Random.nextPrintableChar}")
Now, when we call addCharTo
, this is what we get:
addCharTo("a")
// cats.effect.IO[String]
No matter how many times we call it, we’ll always get a new value of type
IO[String]
. So now, addCharTo
is not performing a side effect anymore, and
the return result is referentially transparent. addChar
now returns a description
of how to perform the side effect, instead of the performing the side effect. Now we
can trust it to always return the same description for the same parameters. Meaning,
that we can replace the function call with its return value without changing
the meaning of the programme:
import scala.util.Random
import cats.effect.IO
def addCharTo(s: String): IO[String] = IO.delay(s"${s}${Random.nextPrintableChar}")
addCharTo("a")
// cats.effect.IO[String]
IO.delay(s"a${Random.nextPrintableChar}")
// cats.effect.IO[String]
Now addCharTo
is pure, and referentially transparent.
At this point, you might be thinking that this doesn’t look that useful, since
we got purity and referential transparency, at the cost of getting back
something that is not the result of the side effect we want to perform.
This section focused purely on the mechanics of how IO works, so let’s take
a look at how and why this is useful, and the reasoning behind this approach.
Structure and Interpretation
So we made our addCharTo
function pure and referentially transparent, but
now, it also doesn’t seem to be performing the side effect we wanted. Also, while
the impure version of it returned a string that we could use, the pure version
returns an IO
, how is this helpful then?
When we moved our side effect into a call to IO.delay
, we suspended our side effect.
What this means is that we provided a description of how the side effect will be
performed, but we didn’t actually perform it. A less than perfect analogy
could be that now the function returns the recipe to perform the side effect,
while the implementation without IO
was cooking the dish.
The effect is in “suspension” until we call one of the “unsafe” functions
on the IO
, at which point, the description we provided is interpreted and
the side effect runs. This little trick allows us to push the execution of
our side effects to the top of our application and deal exclusively with pure
code, up to the point where we run one of the so called unsafe operations.
Now, for this to be of any use, we have to have ways to act on our pure value
of IO. That’s where a few functions you might have heard of before come into
play. In order for IO
to be useful, we must be able to map
and flatMap
(called bind in Haskell and represented by the >>=
operator) over IO
(there’s a few more that are required but for now let’s focus on these two).
These functions exist and have some well defined behaviour because IO
has a
Monad
and a Functor
instance. We won’t go into details on that but if you
read the Learning Scala article, there’s a few
resources there that focus on more details about those.
What map
allows you to do, is to append a transformation to your
computation. For example, if you have a computation represented as
IO[String]
, you can map over it to append a transformation that turns the
String
into h4ck3r wr1t1ng.
On the other hand, flatMap
binds two IO
s together by sequencing them. This
has a vital part in making sure that your side effects run in order, and that
the programme ends in a single IO
, so that at the end you only need to run a
single unsafe operation.
Better yet, in Haskell
, or if you use cats.effect.IOApp
, you just need to
return the result of sequencing all IO
operations from your application
entry point, meaning that nowhere in your code there’s need for impure
functions.
Both Haskell and Scala provide syntactic sugar for chaining map
and
flatMap
calls, respectively called do notation and for comprehensions.
These allow us to more easily sequence and transform effects:
import scala.io.StdIn.readInt
def readNumber: IO[Int] = IO(readInt)
def print(s: String): IO[Unit] = IO(println(s))
val prog: IO[Int] =
for {
_ <- print("Enter a number: ")
a <- readNumber
_ <- print("Enter another number: ")
b <- readNumber
} yield (a * b)
You may have noticed that we’re using IO(<code>)
instead of IO.delay(<code>)
here.
This is because the apply
implementation for IO
simply calls delay
, so both do
the same thing. This is copy/paste from the actual IO
implementation:
object IO extends IOInstances {
/**
* Suspends a synchronous side effect in `IO`.
*
* Alias for `IO.delay(body)`.
*/
def apply[A](body: => A): IO[A] =
delay(body)
(...)
}
As per the example above, we’re creating prog
by sequencing IO
s, then applying a
transformation. We can then in turn do the same with prog
and any other
IO
s existing in our programme, ad infinitum. This makes it very easy and
convenient to compose these mini-programmes into larger ones up to the point
where we have a top level IO
that represents our whole programme.
This kind of structure also makes it easy to restart parts of the programme when failures occur, since now we deal with our programme as values that we can handle as we see fit. This approach, allows you to think about your programme locally, because all the operations on the results of your effects are defined in terms of appending transformations or sequencing it to another effect.
Conclusion
All of this might be a bit to digest. The best way to get a feeling of how
this all works, is to play with it, an build some simple application using
IOApp
, making sure that you never call any of the unsafe operations on IO.
In the wild, in Scala, you’ll rarely see applications that use IO directly, except at the very entry point of the application, and everything else will be abstracted over a higher kinded type parameter. We’ll look further into that on the next chapter.
If you'd like to keep up to date with new posts, follow me on twitter @mpmlopes ,
or subscribe to the feed.