We now have our console statements comfortably composable, but they’re quite noisy and unpleasant to declare. Take, for example, our ask
and greet
commands:
val ask: Chain[String] =
Next(Print("What is your name?", () =>
Next(Read(name => Done(name)))))
val greet: String => Chain[Unit] =
name =>
Next(Print(s"Hello, $name!", () =>
Done(())))
That is a lot of code to not say much. Our first task will be to extract the boilerplate to helper functions, which always helps.
The general idea here is to identify the smallest possible statements we can make - atomic statements - and make them easy to declare. Since they’re also easy to compose into more complex, molecular ones, this should make the whole experience a lot more pleasant.
First, let’s take a look at printing in greet
:
val greet: String => Chain[Unit] =
name =>
Next(Print(s"Hello, $name!", () =>
Done(())))
The entire body of the function is just “given a string, print it and return unit”. This is the smallest possible print statement we can write, and as such needs its helper function. We’ll basically copy / paste its content:
def print(msg: String): Chain[Unit] =
Next(Print(msg, () => Done(())))
This allows us to rewrite greet
to something quite a bit more pleasant:
val greet: String => Chain[Unit] =
name =>
print(s"Hello, $name")
Let’s then take a look at the last line of ask
:
val ask: Chain[String] =
Next(Print("What is your name?", () =>
Next(Read(name => Done(name)))))
That is the smallest read statement we can write: read a String
from stdin and return it. We will again extract that to a helper function:
def read: Chain[String] =
Next(Read(str => Done(str)))
This allows us to rewrite ask
in a slightly nicer way:
val ask: Chain[String] =
Next(Print("What is your name?", () =>
read))
One thing you might have noticed with print
and read
is that they follow a very similar pattern. Don’t worry if you haven’t, pattern recognition takes some practice and it frankly took me a while to spot this particular one.
Look at them side by side:
def print(msg: String): Chain[Unit] =
Next(Print(msg, () => Done(())))
def read: Chain[String] =
Next(Read(str => Done(str)))
In both cases, the innermost bit of code is a function that given a value of type A
(either Unit
or String
), wraps it in a Done
. So, really, an A => Chain[A]
.
That function is then wrapped in a Console[A]
(either Print
or Read
) to produce a Console[Chain[A]]
.
Or, put another way, they’re both represented by the following diagram:
Remember what I said about rectangular patterns in diagrams? There’s something functorial going on in there. And since Console
is in fact a Functor
, we can rewrite our two functions as calls to map
:
def print(msg: String): Chain[Unit] =
Next(Print(msg, () => ()).map(Done.apply))
def read: Chain[String] =
Next(Read(str => str).map(Done.apply))
We’re not quite done though. Both print
and read
still follow a common pattern: they both take a Console
(either a Print
or a Read
), map
into it to apply Done
, and wrap the result in a Next
. They’re basically the same function with a different input.
Let’s extract this function:
def liftChain[A](console: Console[A]): Chain[A] =
Next(console.map(Done.apply))
This allows us to simplify print
and read
quite a bit:
def print(msg: String): Chain[Unit] =
liftChain(Print(msg, () => ()))
def read: Chain[String] =
liftChain(Read(str => str))
You might think this is a bit overkill - after all, print
and read
still work exactly the same from an external perspective, we’ve just shuffled code around. But doing this has shown us that really, our atomic statements are just taking a simple Console
statement and moving it inside of Chain
. It’s a pretty good hint of things to come: maybe Chain
isn’t tied to Console
and is a more general construct?
And now for our finishing touch, take a look at ask
:
val ask: Chain[String] =
Next(Print("What is your name?", () =>
read))
There’s another pattern to spot here, and one that is, again, easier to spot with a diagram. The innermost part is () => read
- a function from Unit
to Chain[String]
.
This is applied inside of a Next(Print, ...)
, which is a Chain[Unit]
, and produces a Chain[String]
.
Which gives us the following diagram:
That triangular shape is typical of something monadic going on. ask
’s current implementation is merely a convoluted way of calling flatMap
:
val ask: Chain[String] =
Next(Print("What is your name?", () => ())
.flatMap(_ => read))
You might not think this is much of an improvement - it’s actually more code than before! But that’s because we’re not quite done yet. That first line, Next(Print...
, should be familiar. It’s exactly a call to print
.
We can rewrite ask
in a much more pleasant fashion:
val ask: Chain[String] =
print("What is your name?")
.flatMap(_ => read))
Which is really nice, because, well. Look at our entire code now:
val ask: Chain[String] =
print("What is your name?")
.flatMap(_ => read))
val greet: String => Chain[Unit] =
name =>
print(s"Hello, $name")
val program: Chain[Unit] =
ask.flatMap(greet)
This is strictly calls to flatMap
on atomic statements. Which means we can rewrite the entire thing as a for-comprehension:
val program: Chain[Unit] = for
_ <- print("What is your name?")
name <- read
_ <- print(s"Hello, $name!")
yield ()
And that is lovely. Our program is now quite clear and easy to understand, and feels like very idiomatic Scala.
We can now solve our initial problem quite comfortably, by creating atomic statements and composing them into larger, more complex ones using the language’s standard syntax.
We could stop here, as we have most definitely fulfilled our assignment. But we also had a hint, while factorising read
and print
, that we might have stumbled on something a little more generic than merely a way to chain console statements.
Wouldn’t it be a shame to stop now when, rather than solving a specific problem, we might have found a way to solve all such problems?