1. Overview
A function is said to be effect-free if it has no observable result besides its return type. Also known as pure functions, we can compose them easily into bigger programs. Composability is essential for the creation of correct, maintainable programs.
Few programming languages include an effect system out of the box — Haskell being the most popular language to have one. Although Scala doesn’t have an effects system, its type system is strong enough to allow us to write one as a library, with the limitation that the compiler won’t enforce its proper use.
Multiple libraries like ZIO, Monix IO, and Cats Effects attempt to provide an effects system for Scala. In this tutorial, we’ll learn how to use Cats Effects 3 to control effects and maximize composability in our code.
2. Why Do We Need to Control Effects?
Functional programming favors pure functions and composition, but useful programs must eventually have an effect, like printing to the console or writing to a file. Since most programs will contain a mix of pure and effectful code, we need to control the boundary between both types of code. This is the job of an effects system.
Let’s look at a canonical example that demonstrates why effects make our code non-composable:
val tuple = (println("Launch missiles"), println("Launch missiles"))
println("--------------")
val print = println("Launch missiles")
val newTuple = (print, print)
The output for the code above demonstrates that we can’t simply replace a call to println with the value it returns. Scala evaluates the first expression eagerly and outputs two lines to the console. The fourth expression prints only once:
scala> Substitution.effectful()
Launch missiles
Launch missiles
--------------
Launch missiles
What if we delay the effect by wrapping the code with effects inside a Future? Let’s look at an example:
def effectfulWithFuture(): Unit = {
implicit val ec: ExecutionContextExecutor = ExecutionContext.global
Future(println("Launch missiles")).map(_ => Future(println("Launch missiles")))
}
def effectfulWithFutureRefactored(): Unit = {
implicit val ec: ExecutionContextExecutor = ExecutionContext.global
val lauch = Future(println("Launch missiles"))
lauch.map(_ => lauch)
}
The second method tries to capture the effect inside the future and then use it twice. But as we see, it still runs only once:
scala> Substitution.effectfulWithFutureRefactored()
Launch missiles
We can conclude that we can’t simply replace an effectful function with its return value, and as the examples above show, effects make our code harder to refactor.
We also found out that delaying execution isn’t enough to regain the substitutability of code.
3. Writing a Minimal Effect System
As we noted above, Haskell is the most popular language with an effects system. If we want to write non-pure code, we must declare it in the type by returning our data inside the IO type constructor. Haskell uses lazy evaluation, hence the IO construction doesn’t run anything — it only declares the steps to be executed, and nothing is actually evaluated until we explicitly force its execution.
We know that the approach of delaying effects isn’t enough from the previous section, and Scala, unlike Haskell, is eagerly evaluated. But thanks to Scala’s call-by-name feature, we can introduce lazy evaluation manually.
Writing a class that makes the execution of effects lazy is trivial:
class LazyIO[T](val runEffect: () => T)
But since Scala has tools that make our life easier, we should use them. For example, case classes and implementing map and flatMap can make our class usable in for-comprehensions:
case class LazyIO[A](runEffect: () => A) {
def map[B](fn: A => B): LazyIO[B] = LazyIO.io(fn(runEffect()))
def flatMap[B](fn: A => LazyIO[B]): LazyIO[B] = LazyIO.io(fn(runEffect()).runEffect())
}
object LazyIO {
def io[A](effect: => A): LazyIO[A] = new LazyIO[A](() => effect)
}
Then we can use them in monadic code:
val io = LazyIO.io(println("Launch missiles"))
val twoRuns = for {
one <- io
two <- io
} yield (one, two)
twoRuns.runEffect()
Using this approach, we’ve made our console-printing code composable. But printing to the console isn’t the only effect performed by a program, nor is it the most important.
4. Enter Cats Effects
Almost all interesting programs have more effects than simply printing to the console. For instance, a program may obtain resources, handle errors, and execute tasks in parallel.
An effects system must provide ways to handle many kinds of effects and declare bigger programs by transforming and combining pure and effectful code. And finally, it should give us a way to execute the program running all the declared effects. The methods used to execute effects are known as eliminators.
Cats Effects provide us with all that and more.
4.1. Adding Cats Effects to the Project Dependencies
We must start by adding the dependencies to the project. In SBT, we just need to add a line in the build.sbt:
libraryDependencies ++= Seq(
"org.typelevel" %% "cats-effect" % "3.5.0"
)
Note that it includes a replacement for the Scala App, which lets us write our entire application as a single effect:
import cats.effect.{ExitCode, IO, IOApp}
object MissileLaucher extends IOApp {
def putStr(str: String): IO[Unit] = IO.delay(println(str))
val launch = for {
_ <- putStr("Lauch missiles")
_ <- putStr("Lauch missiles")
} yield ()
override def run(args: List[String]): IO[ExitCode] = launch.as(ExitCode.Success)
}
We can execute an IOApp in the same way that we execute any Java application.
4.2. Basic Combinators
In the previous example, we used IO objects using a monadic syntax, which tells us IO implements both map and flatMap. But combining it with Cats, we get access to combinators like traverse and sequence:
import cats.effect.{ExitCode, IO, IOApp}
import cats.implicits._
object TraverseApp extends IOApp {
def putStr(str: String): IO[Unit] = IO.delay(println(str))
val tasks: List[Int] = (1 to 1000).toList
def taskExecutor(i: Int): String = s"Executing task $i"
val runAllTasks: IO[List[Unit]] = tasks.traverse(i => putStr(taskExecutor(i)))
override def run(args: List[String]): IO[ExitCode] = runAllTasks.as(ExitCode.Success)
}
object SequenceApp extends IOApp {
def putStr(str: String): IO[Unit] = IO.delay(println(str))
val tasks: List[IO[Int]] = (1 to 1000).map(IO.pure).toList
val sequenceAllTasks: IO[List[Int]] = tasks.sequence
val printTaskSequence = sequenceAllTasks.map(_.mkString(", ")).flatMap(putStr)
override def run(args: List[String]): IO[ExitCode] = sequenceAllTasks.as(ExitCode.Success)
}
In the example above, we used two different constructors. IO.delay is the one most commonly used, and it builds an IO by delaying the effect. The other is IO.pure, which we use when we need to wrap a pure value into an IO effect.
At first sight, traverse and sequence look a little trivial because they seem similar to map and flatMap. But they’re defined on generic terms that will work with any object that has an instance of the Traverse type class.
4.3. Parallel Execution
In the Java Virtual Machine, parallel execution means the code is executing in different threads. Ideally, execution speed will be the only difference between running our code in parallel or sequentially, but this makes it difficult to know if the code is executing in parallel or not.
We can write an implicit class extending any IO to show the thread in which it is executing:
object Utils {
implicit class ShowThread[T](io: IO[T]) {
def showThread: IO[T] = for {
thunk <- io
thread = Thread.currentThread.getName
_ = println(s"[$thread] $thunk")
} yield thunk
}
}
Using this utility, we can show that the default traverse does not execute in parallel:
[ioapp-compute-0] 1
[ioapp-compute-0] 2
[ioapp-compute-0] 3
[ioapp-compute-0] 4
[ioapp-compute-0] 5
[ioapp-compute-0] 6
[ioapp-compute-0] 7
[ioapp-compute-0] 8
[ioapp-compute-0] 9
[ioapp-compute-0] 10
[ioapp-compute-0] List(2, 3, 4, 5, 6, 7, 8, 9, 10, 11)
But Cats Effects allow us to run in parallel by changing a single line. Using parTraverse, we ask IO to run the effects in parallel:
object ParallelApp extends IOApp {
val tasks: List[IO[Int]] = (1 to 10).map(IO.pure).map(_.showThread).toList
val incremented: IO[List[Int]] = tasks.parTraverse {
ioi => for (i <- ioi) yield i + 1
}
val parallelOrNot = incremented.showThread
override def run(args: List[String]): IO[ExitCode] = parallelOrNot.as(ExitCode.Success)
}
And the utility we wrote shows it indeed runs in different threads:
[ioapp-compute-2] 3
[ioapp-compute-1] 2
[ioapp-compute-6] 7
[ioapp-compute-3] 4
[ioapp-compute-8] 9
[ioapp-compute-4] 5
[ioapp-compute-0] 1
[ioapp-compute-7] 8
[ioapp-compute-9] 10
[ioapp-compute-5] 6
[ioapp-compute-9] List(2, 3, 4, 5, 6, 7, 8, 9, 10, 11)
5. Conclusion
In this tutorial, we learned what an effects system is and why it is useful. We also reviewed the basics of how to write our application as an IOApp.
We also learned about modeling parallelism as an effect, allowing us to write programs that execute in parallel but still understand by using high-level combinators like parMap.
As always, the full source code for the examples is available over on GitHub.