Converting a Functional Hangman game from Scala (ZIO) to Kotlin (with Arrow) was a nice exercise. I enjoyed working on it and I learned a lot. When I asked for feedback on the #arrow channel, one of the maintainers, Leandro had an interesting suggestion. Instead of hard-coding the data type IO
I should try and make the program polymorphic and use Kind
instead. That means writing the code focusing on the domain logic, using abstractions, and deferring the decision for the concrete data type like IO
or Single
(from RxJava) until the main function.
The journey
I was not familiar with that style of programming so I used this example from the excellent Arrow documentation as a guide.
Writing to the the console
In the previous article I used IO<A>
to interact with the console. IO<A>
represents an operation that can be executed lazily, fail with an exception (the exception is captured inside IO
), run forever or return a single A
. Let’s take a look at the original implementation:
putStrLn
is a function that take a String
and return a IO<Unit>
. IO
takes a lambda that is lazily evaluated at the end of the world, when we call unsafeRunSync()
. If we want to achieve the same thing with Single
we could use Single.fromCallable
wrap our lambda and evaluate it in the main function when we call subscribe()
.
Here bothIO
and Single
have something in common. A set of capabilities like: lazy evaluation, exception handling, and running forever or completing with a result of type A
. IO
and Single
do a lot more, but for this use case, we want something as simple as possible that has the same capabilities. There is a type-class in Arrow that can do just that and it’s called MonadDefer
(more info). After a few iterations, and feedback from the Arrow team, this is the code I came up with for printing to the console.
The putStrLn
function is generic with type F
, but not any F
. This F
, whatever it is, need to do certain things like lazy evaluation and error handling. This F
thing needs the capabilities of MonadDefer
. The return value also needs a type parameter, and in Arrow we can do that by returning Kind<F, SOMETHING>
. In the case of printing to command line, that SOMETHING
is Unit
(no return value). MonadDefer
comes with an delay
function that we can use to construct the value of Kind<F, Unit>
. We pass a lambda inside which will be lazily evaluated.
Compared to the original implementation we have a few key differences:
- a type parameter
F
- one more parameter of type
MonadDefer<F>
- the return type is
Kind<F, Unit>
instead ofIO<Unit>
Now we can use this function to print something to the console. In order to do that, we need a data type that has the capabilities of MonadDefer
. IO
can do that so we can use it. Arrow also ships with SingleK
, a wrapper for Single
that has the MonadDefer
capabilities.
Arrow also ships with ObservableK
for Observable
, DeferredK
for coroutines etc.
Reading from the console
Reading from the console is similar to writing to it. We still need a type parameter F
, we still need to return Kind<F, SOMETHING>
and we need to perform the operation lazily with success or an error. readLine()
returns a nullable String?
and if that happens we need to signal an error.
Reading from the console #2
I am throwing an IOException
to indicate failure. IO
would wrap that exception so it isn’t that bad. But there is a better way (thanks Leandro).
I convert the nullable String?
to an Option<String>
. Then I use fold
to check if the Option
has a value. If it’s empty I use raiseError
to create MonadDefer
which when evaluated returns an error. If it’s not empty I create a MonadDefer
that returns a String
using just
.
The key difference here is I am NOT throwing the IOException
. Using exceptions can be expensive.
M.defer
here means the readLine()
happens lazily.
The Hangman class
The next step is to make the Hangman
class polymorphic. To do that I added a type parameter F
and property of the type MonadDefer<F>
.
Choosing a letter
To make the getChoice()
function work in a polymorphic way we need a few changes. The return type changes from IO<Char>
to Kind<F, Char>
. IO.binding
becomes M.binding
(where M
is the property of type MonadDefer<F>
).
The getStrLn()
and putStrLn()
also need M
as the parameter.
Wrapping up
Updating all other functions follows the same pattern. Replace IO<SOMETHING>
with Kind<F, SOMETHING>
, replace IO.binding
with M.binding
and pass M
as parameter for reading/writing to the console.
You can find the full code here.
The main program
The main program, run with Single
, looks like this:
or with IO
The decision in which type constructor to run is made at the point of execution. Switching from Single
to IO
or Observable
requires only updating the main function.
Incremental improvements
I am still learning FP and I don’t know most of the type-classes in Arrow and what they can do. In my first attempt I used Async
instead of MonadDefer
. Async
extend MonadDefer
and adds additional capabilities to it. I asked for feedback on the #arrow channel and they pointed me towards MonadDefer
. Together with the Arrow maintainers we made a few more improvements improvements.
To avoid passing M
to printStrLn
every time I can convert it to an extension function on MonadDefer
and I can use Implementation by delegation and have the Hangman
class implement MonadDefer<F>
.
This means everywhere inside the Hangman
class I can use the methods of MonadDefer
like binding
and delay
and extension methods like putStrLn
.
You can find the full implementation here.
Note: the code samples here use the function delay
which doesn’t exist in the latest published version (0.8.1). In arrow 0.8.2 invoke
(used in the code on Github) will be deprecated and replaced by delay
.
Conclusion
Working with abstractions like MonadDefer
frees the business logic from implementation details like IO
or Single
. It can also enable easier composition of different modules because the decision for the concrete data type is delayed until the main program.