A Prelude of Purity: Scaling Back ZIO
ZIO World is the leading annual global ZIO-based event created by Ziverge. The event aims to inspire new trends, promote innovation, and reveal significant developments across the ZIO ecosystem. ZIO World invites developers to share their expertise in using ZIO. This year, ZIO World hosted Scalac developer and ZIO contributor Jorge Vásquez. His presentation focused on one of the data styles from ZIO Prelude, ZPure.
ZPure offers the unique benefit of scaling back ZIO 2’s power without compromising on performance or losing useful features like type inference or high-quality ergonomics. If you’d like to learn more about the updates and news in the ZIO library from the ZIO World conference, go to Jaro’s presentation: ZIO SQL: Type-safe SQL for ZIO applications. Now, let’s jump to Jorge’s talk.
The Focus of The Presentation
Jorge began by offering some background on ZIO Prelude, a Scala-first take on functional abstractions. ZIO Prelude offers the following features:
- Type Classes: Type classes describe how different types are similar.
- Smart Types: Smart types facilitate more precise data modeling.
- Data Types: Data types complement the standard Scala library. ZPure is one such data type.
The Problem With Testing and Debugging Purely Functional Code
How can you create computations that have the following attributes using a purely functional style?
- Stateful: The state stores accessible information.
- Context: A context comprises specific data about the request.
- Value: A function should return a value irrespective of whether it succeeds or fails.
- Logs: Logs store information about the actions of functions.
These attributes enable you to create clear code with more straightforward testing and debugging.
The Scenario: A Reverse Polish Notation (RPN) Calculator
The algorithm for an RPN calculator
Jorge chose a simple example to demonstrate the advantages of employing ZIO: a reverse polish notation calculator.
The calculator uses an arithmetic expression in which each number is placed sequentially into the available boxes. The following arithmetic operation is then applied to the stacked numbers, taking the bottom number first. The expression returns a result when all the operations are complete.
Jorge wanted to find the most effective way to solve the equation with highly performant and concise code.
The First Attempt: Cats State Monad
Solution using the Cats state monad.
A Cats state monad uses the Cats’ library monad. Monads are data types that perform calculations in succession while following set laws.
The solution above breaks down as follows:
- Stack type: The stack type is a list of integers.
- Effect type: The effect is the state monad in which the state is the stack type.
- Methods: There is a method to push elements into, and pop elements from, the stack.
- Helper function: This processes the original expression in the form of a string and splits it into separate elements.
The core Cats state monad solution.
The core solution above includes the following elements:
- evalRPNExpression: This function takes a list of elements and checks whether each element is an operator or a number.
- processTopElements: This function is called within the “evalRPNExpression” function. It uses methods to pop two elements from the stack and push the result back into the stack after the operator has been applied to them.
- The traverse method: Finally, Jorge applies the traverse method to the list of elements along with the processElement function and pops the result back into the stack.
Using the Cats state monad is effective. However, it is only successful when the correct values are given. If the expression is miswritten or an unexpected value, such as a letter, is contained within the stack, it will produce incorrect results. Therefore, this solution handles errors poorly.
The Second Attempt: Cats State + Either
In an attempt to solve the problem of poor error handling, Jorge introduced the “Either” data type alongside the Cats state method.
Solution featuring the Cats state monad and the effect type Either.
In the above example, note the following additions:
- Effect type: The effect type now includes state and Either.
- Pop method: Jorge modified the pop method to check whether the stack is empty or not. If the stack is empty, it returns the Left value “No operands left”. Because Jorge added the value within the state monad, the value must be lifted into state using State.pure. The pure method then pushes the result to the state. Developers must manually write this part of the code to ensure the operation compiles.
The most significant downside to this solution is a bloated codebase. The code snippet below shows the extensive boilerplate needed to improve error handling using Either. However, it does produce better errors than the previous solution.
Solution using Cats State and Either
The Third Attempt: Monad Transformers
To reduce codebase bloating, Jorge suggested another implementation: monad transformers. Monad transformers are types that combine two monads into one unit that shares the functionality of both. For example, EitherT and OptionT are both monad transformers.
Attempt at solving the RPN calculator using monad transformers.
The main advantage of using monad transformers is that they aren’t as lengthy as the Cats state solution. Even if doing so still requires a lot of boilerplate to lift State into EitherT.
However, this solution is flawed. There are several key disadvantages to using monad transformers:
- Pop method: The pop method now employs poor type annotations and frequently calls the “liftF” method.
- Confusing code: Monad transformers are harder to understand and require more type annotation than previous solutions.
Third attempt at the solution using monad transformers.
- Poor type inference: The type inference lacks ergonomy and affects performance.
- Diminished performance: Using monad transformers decreases performance.
- Impacted discoverability: It’s unclear which monad transformers best handle additional effects.
Poor discoverability leads to the following problems:
- Effects: If additional effects, such as context or logging, need handling, it is challenging to identify which monad transformers are required.
- Multiple Domains: Good discoverability relies upon knowledge of various domains. First, you must consider which data types to use, such as State, Reader, or Writer. Second, it’s essential to identify which monad transformers to use and in which order. Finally, you must have an adequate understanding of type classes.
These severe problems led Jorge to search for a better answer.
The Fourth Attempt: ZIO 2
After cycling through the various unsuccessful iterations outlined above, Jorge attempted to find solutions by using ZIO 2.
Fourth attempt at coding the RPN calculator using ZIO 2.
Jorge employed the following changes in the example above to create a solution using ZIO 2:
- Stack type: The stack type is a list of integers inside a ZIO ref. ZIO ref offers a mutable reference capable of storing immutable data.
- Effect type: The effect type is simply ZIO. The environment is the stack type that requires updating. The effect type fails with a string or succeeds with an A value.
- Pop method: The pop method no longer requires poor type annotations. Instead, Jorge uses ZIO.fail.
Fourth attempt using ZIO 2.
This solution has notable benefits:
- Succinct: It doesn’t require a large amount of boilerplate.
- Type inference: allElseFail can be employed if a data type that’s not a number is called, thus removing any reliance on odd type annotations.
- Monads: ZIO 2 only requires one monad.
- Discoverable functionality: Using a single data type with concrete methods enables discoverable functionality. Therefore there is no need for monad transformers or type classes.
This technique is much clearer than solutions built with monad transformers. However, Jorge emphasized that using ZIO 2 for the calculator is similar to “killing a fly with a bazooka.” That’s because the RPN calculator doesn’t interact with the outside world or require powerful ZIO 2 features like asynchronous programming. These downsides combine to reduce performance.
The Final Attempt: ZPure
ZPure from ZIO Prelude is a data type that allows the scaling back of ZIO 2’s power while maintaining high performance, type inference, and ergonomics.
ZPure is a description of a pure computation—[+W, -S1, +s2, -R, +E, _A]—that has the following elements within its functionality:
- It requires an environment, R.
- The initial state is S1.
- It can fail with an error type of E.
- It can succeed with an updated state of type S2 and value type of A, which also produces a log type, W.
The mental model of ZPure is similar to a function that is written as follows: (R, S1) => (Chunk[W], Either[E, (S2, A)]).
ZPure models four effects that computation can employ besides producing a value type of A:
- Errors, which is similar to Either.
- Context, which is similar to Reader.
- State, which is similar to State.
- Logging, which is similar to Writer.
The type aliases of ZPure.
ZPure offers many types(?) parameters. However, you don’t have to use them simultaneously. Instead, employ type aliases for a single effect. In addition, each parameter has an errorful version.
Final attempt at coding an RPN calculator using ZPure.
Above is Jorge’s final attempt at coding the calculator. This solution uses ZPure and has the following features:
- Stack: The stack is a list of integers.
- Effect: The effect type is an EState where the state is the stack type that needs updating, and the error type is a string.
- Pop: The pop method simply uses EState.fail.
The ZPure solution is compact. And developers comfortable working with ZIO will transition to ZPure relatively easily because much of the code is similar to ZIO.
FInal attempt using ZPure.
This example is also easier to understand than the monad transformers solution. In addition, there are even more benefits to employing ZPure:
- A single monad: There’s one monad to rule all others, except for IO.
- Type inference: The type inference is excellent.
- Discoverable functionality: There is discoverable functionality with just one data type.
- Similar to ZIO: It is ZIO idiomatic, using familiar and accessible method names.
- Gradual Adoption: ZPure can be adopted incrementally.
- Performant: ZIO is much more performant than other solutions.
A graph comparing the performance of each solution.
As shown above, ZPure significantly outperforms other solutions. Furthermore, the results are even more differentiated between ZPure and monad transformers when Jorge added other effects like logging and context.
The Final Word on ZPure
Handling context, state, failure, and logging in a purely functional way is essential to coding an RPN calculator. However, while there are many ways to solve this challenge, each solution has unique limitations. ZPure is the stand-out solution because it’s highly ergonomic, ZIO-idiomatic, and performant.
For anyone requiring more information, chat with us or reach out to Jorge:
- Twitter: @jorvasquez2301
- Instagram: jorge-vasquez-2301