This week I’m exploring Effects and their role in functional programming as preparation for the ZIO course | Rock the JVM. Understanding effects is crucial for writing pure functional programs while still dealing with side effects in a controlled way.
Effects in Functional Programming #
What are Effects? #
In functional programming, pure functions are functions that always return the same output for the same input and have no side effects. However, real-world applications need to perform side effects like printing to console, making network requests, querying a database.
Effects provide a way to describe computations that may have side effects while maintaining the principles of functional programming.
Effect Properties #
An effect must satisfy three key properties:
- Type signature describes the KIND of computation it will perform
- Type signature describes the TYPE of VALUE it will produce
- If side effects are required, construction must be separate from EXECUTION
Pure Functional Programming #
// A pure functional program is a big expression computing a value
// Expressions
def combine(a: Int, b: Int): Int = a + b
// Local reasoning: type signature describes the computation
// Referential transparency: can replace
// expression with its value without changing behaviour
val five = combine(2,3)
val five_v2 = 2 + 3 // Same as above
val five_v3 = 5 // Same resultReferential Transparency #
Referential transparency means we can replace any expression with the value it evaluates to without changing the program’s behaviour.
However, not all expressions are referentially transparent:
// Example 1: printing (NOT referentially transparent)
val resultOfPrinting: Unit = println("learning ZIO")
val resultOfPrinting_v2: Unit = () // NOT the same behaviour!
// Example 2: changing a variable (NOT referentially transparent)
val anInt = 0
val changingInt: Unit = (anInt = 42) // side effect
val changingInt_v2: Unit = () // NOT the same programExamples: What Makes a Proper Effect? #
Example 1: Option - A Pure Effect #
Option represents computations that may or may not produce a value:
val anOption: Option[Int] = Option(42)Option satisfies all effect properties:
- ✅ Kind of computation: Possibly absent values
- ✅ Type of value:
Aif present - ✅ No side effects: Pure computation
Example 2: Future - NOT a Pure Effect #
val aFuture: Future[Int] = Future(42)
// ❌ When Future is constructed, execution is immediately scheduled!Future fails the effect test:
- ✅ Kind of computation: Asynchronous computation
- ✅ Type of value:
Aif successful - ❌ Construction separate from execution: NO! Execution starts immediately
Example 3: MyIO - A Proper Effect #
Let’s design our own effect type that properly separates construction from execution:
case class MyIO[A](unsafeRun: () => A) {
def map[B](f: A => B): MyIO[B] =
MyIO(() => f(unsafeRun()))
def flatMap[B](f: A => MyIO[B]): MyIO[B] =
MyIO(() => f(unsafeRun()).unsafeRun())
}Here’s how MyIO provides proper effect handling:
// Description of an effect - does NOT execute immediately!
val anIOWithSideEffects = MyIO(() => {
println("producing effect")
42
})
// Only executes when explicitly called
anIOWithSideEffects.unsafeRun()MyIO passes all effect tests:
- ✅ Kind of computation: Might perform a side effect
- ✅ Type of value:
Aif successful - ✅ Construction separate from execution: YES! Only runs when
unsafeRun()is called
IO Monad #
What is MyIO?
MyIO is a wrapper for side effects that delays their execution. Instead of running code immediately, it stores the computation to be run later
Why “IO”?
IO stands for Input/Output operations that interact with the outside world (printing, reading files, network calls, etc.). These are “impure” because they have side effects. MyIO makes them manageable by treating them as values.
Methods #
map[B](f: A => B): MyIO[B]
#
Transform the result of a computation.
val io = MyIO(() => 5)
val doubled = io.map(_ * 2) // MyIO that will produce 10...
doubled.unsafeRun() //once you run itUse when you want to transform the output without chaining another effect.
flatMap[B](f: A => MyIO[B]): MyIO[B]
#
Chain dependent computations together.
val program = MyIO(() => readLine())
.flatMap(name => MyIO(() => println(s"Hello, $name")))
program.unsafeRun() // will read console input and print itUse when the next computation depends on the previous result and also produces an effect.
For-Comprehension #
For-comprehensions provide cleaner syntax for chaining multiple flatMap operations. (For more see my earlier post on map, flatMap, and for-comprehensions).
val currentTime: MyIO[Long] =
MyIO(() => System.currentTimeMillis())
val slowComputation: MyIO[String] = MyIO(() => {
Thread.sleep(10000) // Wait 10 seconds
println("Computation finished!")
"result"
})
// Using for-comprehension (clean syntax)
// measures time take for the computation
def measure[A](computation: MyIO[A]): MyIO[(Long, A)] = for {
startTime <- currentTime
result <- computation
endTime <- currentTime
} yield (endTime - startTime, result)
measure(slowComputation).unsafeRun()This is equivalent to nested flatMap calls:
// Same as above, but using flatMap directly
def measure[A](computation: MyIO[A]): MyIO[(Long, A)] =
currentTime.flatMap { startTime =>
computation.flatMap { result =>
currentTime.map { endTime =>
(endTime - startTime, result)
}
}
}Pattern #
// 1. Define effects (nothing executes yet)
val effect1 = MyIO(() => println("Step 1"))
val effect2 = MyIO(() => println("Step 2"))
// 2. Compose them
val program = effect1.flatMap(_ => effect2)
// 3. Execute only when ready
program.unsafeRun() // NOW both printlns happenKey Takeaways #
- Effects allow us to describe computations while maintaining functional purity
- Referential transparency is crucial for reasoning about code
- Construction must be separate from execution for proper effect handling
- Future is not a pure effect because it starts executing immediately
- Proper effect types like
MyIO(or ZIO) give us control over when side effects occur