Demand for Effectful systems and FP concepts
1. How to write good code
One may think functional programming is an abstract concept, not very useful in projects, “I use Java, and it works”. However, this is just like saying, why bother with Software craftmanship. Everything could work even if we boil down our code to one class with thousands of lines. We all know how projects like that end.
Functional programming, especially in the early Haskel days, was complex and had a major flaw. Our code was completely unclear for any developer without a background in the Theory Category, full of abstract, academic terminology all over the place. Many years later, FP is like SOLID or design patterns. Mandatory for people who care about code quality.
2. FP helps in writing good code quickly
It can be a philosophical question of what a good code is. Although such an approach may suit pub talks, it doesn’t get us closer to a satisfactory answer.
Therefore, we will try to define some universal attributes which are welcome in good code in any programming language.
When we define those requirements, we notice that functional programming provides them.
I have chosen ZIO because it doesn’t use theoretical naming like Semigroup, Monads etc., so our codebase is more like a Software project and less like an Algebra assignment.
Moreover, ZIO seems to propose a solution to a typical developer’s problem, which is fantastic:
- Modularization
- Eliminate the need for DI
- focus on interfaces, not an implementation
- Testability
- Provide an easy-to-use effectful framework to build our app in a very composability-friendly way
2.1 From imperative to purely functional Hello World (ZIO)
Let’s start with simple vanilla Scala app (imperative scala).
import scala.io.StdIn
object DemoVanillaApp extends App {
println("What's your name?")
val name = StdIn.readLine()
println(s"Hi $name")
}
And a better version with ZIO
import zio._
object BetterApp extends ZIOAppDefault { // (1)
def run: ZIO[Any with ZIOAppArgs with Scope, Any, Any] = { // (2)
for {
_ <- Console.printLine("What's your name") // (3)
name <- Console.readLine
_ <- Console.printLine(s"Hi $name")
} yield ExitCode.success
}
}
(1) We need to extend a particular type to execute the ZIO value. Compare what we did in the vanilla version. We returned the ZIO value, whereas, in the previous version, we eagerly evaluated the program and returned the Unit type.
(2) Now, everything is a value, so we don’t return Unit (side effect) but ZIO type.
(3) We use ZIO’s Console module to interact with the input/output. However, the difference is that none of the instructions is run until we explicitly say. We can think of our program as a blueprint which will be called at some point together. We compose functions, and ZIOAppDefault will call them when we finish building our code.
Under the hood, ZIO calls: Runtime.default.unsafeRun(program)
The problem with imperative style, present in the vanilla Scala example, is that it quickly gets pretty complex.
To write complex projects better, we could use the FP style. To do this, we need to treat everything as a value - to be precise immutable value. We will be calling such immutable value an effect. What’s the difference between an effect and side effects? A side effect changes something. An effect is more general. For instance, Option
is an effect of modelling optionality. Future
is an effect of modelling concurrent executions, and so on. You get the gist.
Hint 1
To be clear. If you need a less complex application (e.g. printing a hello world), using vanilla Scala or even vanilla Bash is enough.
Hint 2
If you don’t know your requirements, assume refactoring will be needed sooner or later, so it’s better to keep the code easy to change, modularized and somehow tested end to end. Consider using an effectful style from the beginning that would make easier to reason and refactor everything later.
2.2 ZIO type contains all the information
ZIO is a type: ZIO[-R, +E, +A]
. Let’s not focus on (in)variances this time (why -R, not +R), but on
the pragmatic aspects and consequences of the code:
Let’s assign the result of for-comprehension to a value and see what the type is.
val program: ZIO[Any, IOException, ExitCode] = for {
_ <- Console.printLine("What's your name")
name <- Console.readLine
_ <- Console.printLine(s"Hi $name")
} yield ExitCode.success
ZIO type can be looked at as a function:
R => (E, A)
. We read it as a function of Environment to an error or a successful value. The Environment type is nothing more than what DI gave us - a graph of objects. Lastly, an error can be an exception, String, or any other type we want.
We have Any
in the Environment position because it is a simple example. Typical projects have something like this:
val program: ZIO[Database.Service & Kafka.Service, DomainError, Person] = ???
We require two services to be provided: one from the Database module and one from Kafka.
We will map any errors to a common DomainError and return as an E
side (like Left in the Either).
For the happy path, we will return a Person (fetched from the database and converted to our domain model).
There are a few useful aliases:
type IO[+E, +A] = ZIO[Any, E, A] // error and value
type Task[+A] = ZIO[Any, Throwable, A] // error (exception) and value
type UIO[+A] = ZIO[Any, Nothing, A] // cannot fail
All of them are very often used. Instead of writing full ZIO[...]
we can use them. The standard part of them
is that they don’t require an environment. If we need an Environment, we need to use two different aliases with R in the name:
RIO or URIO.
Example of two equivalent snippets:
val y: ZIO[Any, IOException, String] = Console.readLine
val x: IO[IOException, String] = Console.readLine
Writing IO
in this case is better: shorter and does not need to use Any
explicitly.
2.3 Why non-FP code is hard to reason
What’s wrong with these three functions?
def div(x: Double, y: Double): Double = ??? // 1
def say(thing: String): Unit = println(thing) // 2
def greet(name: String): String =
name + scala.util.Random.nextString(10) // 3
The above code may be considered as hard to reason because it violates three main functional concepts:
-
div
- The function is partial. For some input, there is no output, and an exception must be thrown. Here it is a runtime exception, unchecked, so it is not possible to know it without reading the implementation or documentation. -
say
- Impure, it is not a blueprint. It executes a side effect immediately. We don’t want to interact with the external world in random places in our project. We prefer to do it “at the end of the world”, using explicit calls, likeunsafeRun
.Why unsafeRun is a good name? It is a good name because only when we run our blueprint surprises are possible (unchecked exceptions, crashes etc.)
-
greet
- Non-deterministic. For the same input, there is different output. It will be hard to test nasty, unpredictable functions like this. Therefore, we would have to compose untested code.
If you violate these three things in your even more complex project in multiple places, you’ll require a debugger to read what the code does. It happened to me a lot when I was a Java developer.
Instead, we prefer functions to be:
- Total
- Pure
- Deterministic
For example, this is one way to refactor the above functions:
def div2(x: Double, y: Double): Option[Double] = ??? // the type says everything now, no surprises
def say2(thing: String): Task[Unit] = Console.printLine(thing) // blueprint, prescription what we will do at the end of the world
def greet2(name: String, seed: Long): (String, Long) = {
val newSeed = new scala.util.Random(seed).nextInt()
val value = new scala.util.Random(seed).nextString(10) + name
(value, newSeed)
}
In greet2
, we return both a value and seed instead of returning a random number, so the method always returns the same result for the same seed value.
By doing that, our code will be self documented by the return types and by being total functions, easy to test as all the functions are deterministic,
easy to refactor and to use, at least that’s the goal.
2.4 Effects and composability
Let’s move to composability. If we design our logic for comprehension, it will be natural, easy to follow SRP (Single Responsibility), as developers will build the program with smaller blocks (SRP friendly), also it will have a better chance of having good separation of concern, as for-comprehension will have a high-level description of what is happening, and it will call each service to get the result.
There is one challenge with for-comprehension. It requires only one type of effect. If your first line returns an Option
, everything below must be an Option.
If it is ZIO, everything else needs to be ZIO too. To support this limitation, we need to be able to convert effect types.
val r2: ZIO[Any, Option[Nothing], Int] = ZIO.fromOption(Some(2)) // Option to ZIO
val r3: ZIO[Any, String, Int] = ZIO.fromOption(Some(2)).mapError(_ => "cannot get value from None") // Option to ZIO with a better error
// ZIO[Any, A, B] === IO[A, B]
val r4: IO[IllegalArgumentException, Int] =
ZIO.fromOption(Some(42)).mapError(_ => new IllegalArgumentException("no value")) // Option to ZIO with Throwable
val r5: IO[String, Nothing] = ZIO.fromEither(Left("no value")) // Either => ZIO
// IO[Throwable, A] === Task[A]
val r6: Task[Int] = ZIO.fromTry(Try(42 / 0)) // Try => ZIO
// Important (!) Future are not very functional friendly (violating referential transparency by being eagerly and only-once evaluated).
// Here is the way to fix them by converting to ZIO effect
val fut: Future[Int] = Future.successful(42)
// Future.failed(exception: Throwable) so the result of fromFuture must be Task (Throwable in error side)
val r7: Task[Int] = ZIO.fromFuture(implicit ec => fut)
2.5 Taming side effects
And let’s convert side effects too. We have two types of side effects:
- Asynchronous (callbacks). In general, it is mostly used for legacy libraries which use them.
- Synchronous (Request -> Response)
Example of synchronous side effects compositions
val r8: Task[String] = ZIO.attempt(StdIn.readLine())
// readLine can throw an exception therefore it's a Task. What about println, it cannot throw an exception?
val r9: UIO[Unit] = ZIO.succeed(println(42)) // UIO[A] = ZIO[Any, Nothing, A]
// what if the side effect throw an exception even though we used succeed?
val r10: UIO[Int] = ZIO.succeed(42 / 0)
// runtime.unsafeRun(r10)
def throwMe(x: Int): Unit =
x match {
case 1 => throw new IOException("some io exception")
case _ => throw new Exception("some exception")
}
val r11: IO[IOException, Unit] = ZIO.attempt(throwMe(0)).refineToOrDie[IOException]
// runtime.unsafeRun(r11)
3. Recap
Writing good software by a team of people leaving and joining at various intervals, having unprecise requirements, may be very hard to implement. We need to use formalization to enable us to define what is the problem, determine what is the sound code looks like, and try to find an implementation which help to tick most of the boxes.
Scala with ZIO allows using functional programming style in a very straightforward way,
without overwhelming slang like Tagless Final
, Monad
, etc.
Nevertheless, it is not an end game. It is just a starting point. By following a functional style, we may still build ugly code. However, it will be more difficult because we know the rules now.
4. Bullet-points takeaways
- SOLID, GoF, DRY, KISS still apply.
- Use effects to prepare blueprints instead of eager evaluation.
- Define what a good code is, don’t treat this as a question without a good answer.
- Implement functions as total, pure and deterministic.
- Add functional programming to your toolbox to become an even better developer.
- ZIO is great, but not the only one there. It is essential to focus on concepts, not the implementation.