Handling Side Effects in Unison
How to separate pure functions from functions with side effects
In the world of pure functional programming, the big bogeyman is functions with side effects. What exactly do we mean by side effects? A pure function works like functions in mathematics. The output of the function is defined exclusively by its input arguments.
However, in programming, some functions are sneaky and alter some internal state or produce output by reading some mutable state. Mutating state simply means to alter some data. If this sounds abstract, let me give a simple example: When you add or remove values from a collection such as a dictionary or array, you are mutating state.
Functions which read or alter shared state are bad because they make programs harder to test and debug. They also make running code in parallel very difficult. If two functions running at the same time try to alter and read from a shared state simultaneously, we obviously get into trouble.
The problem is that we cannot avoid functions with side effects. We need to be able to read input from users or write to files or network sockets. Pure functional programming languages have tried to minimize this issue by cleanly separating functions which don't alter state, so-called pure functions, from those that do. Haskell infamously uses Monads to achieve this. Monads are a concept which has left many developers scratching their heads over the years.
Thus, I was intrigued when I read that Unison is a Haskell-ish language which takes a different approach called Algebraic Effects, which is supposed to make writing code with side effects much easier. The questions is: Does Unison deliver on that promise?
Down the Rabbit Hole of Algebraic Effects
I don't know if I can give a straight answer to that question. Over the last few days, I have gone down the rabbit hole to explore this strange new world of Algebraic Effects in Unison. I am uncertain if this stuff is really hard to get or if I am just getting old.
What Unison is promising is making it easier to write code once you understand Abilities. An Ability is a Unison concept for implementing Algebraic Effects. Based on my current experience with Unison, that claim seems reasonable to me. Mainstream languages have over the last few years inherited Monad style functionality to deal with errors and optional values. My main experience is within the Swift programming language. Your milage may differ, but in my experience it can get quite clunky with wrapping and unwrapping of optional values. Let us contrast this experience with how reading and writing text to the console works in Unison:
main : '{IO, Exception} ()
main _ =
printLine "What is your name?"
name = console.getLine ()
printLine ("Hello " ++ name ++ "!")
printLine "What is your age?"
age = console.getLine ()
printLine ("You are " ++ age ++ " years old!")
The code works like regular Unison code, despite doing I/O which requires side effects. We can compare with a similar example in Haskell. I just grabbed some quick examples from the Haskell wiki on I/O for comparison. The first version of the code shown looks like the following:
main = putStrLn "Hello, what is your name?"
>> getLine
>>= \name -> putStrLn ("Hello, " ++ name ++ "!")
The use of the >>
and >>=
operators makes this code look somewhat cryptic. Haskell guys simplify this kind of code by using the do
keyword, which performs some magic which makes the >>
and >>=
operators somehow evaporate.
main = do putStrLn "Hello, what is your name?"
name <- getLine
putStrLn ("Hello, " ++ name ++ "!")
Admittedly, I have forgotten how most of this works in detail, and it may not be obvious to you how it works either, but that is not relevant. The key takeaway is that you can observe that I/O in Haskell requires using several funky operators and writing code in an unusual way. For instance, with the do
keyword you have to use <-
to assign to a variable, instead of the normal equal =
symbol.
So, Unison makes writing code with side effects easier. The catch is that understanding the machinery behind this capability is tricky, but doable with practice and dedication. The official Unison Web page already has an introduction to Abilities, which you might want to read. My aim with this article is to point out the stumbling blocks I experienced in the hope that it helps you along while trying to wrap your head around this new functional programming magic.
The Handle-With Statement
One of the things that tripped me up when reading the official description about abilities is that the role of the expression handle f with h
was buried so deep. It is at the core of how you deal with side effects. It ties a function f
with side effects together with a handler function h
. The h
function handles each instance of side effects produced by function f
.
I like to think about f
and h
as two coroutines, which the handle f with h
expression manages through some kind of coroutine scheduling (only one function runs at a time. f
is suspended while h
handles requests). f
and h
have to communicate with each other through some shared interface. If you look at two coroutines in Go or Julia, they will typically share a channel object of a specific type. The channel type says what kind of objects you can push through from one coroutine to another. Catering to the pedantic: Yes, Go doesn't use coroutines, but goroutines, but the difference is irrelevant to this example.
In Unison, there are no channels. Instead, you have an Ability
type which defines the types of requests which can be sent from coroutine f
to coroutine h
and what type of object h
can respond with.
NOTE: Unison docs do not explain this relation in terms of two coroutines, and so it may be an inaccurate description. I just think this helps in conceptualizing what is going on.
Allow me to clarify the concept with an example. The following is an ability for getting individual values; either a natural number or a character.
structural ability Getter where
getChar : {Getter} Char
getNum : {Getter} Nat
It is a stupid way of writing such an interface, but I am deliberately making it elementary to reduce the number of things you have to keep in mind when following my description.
getChar
and getNum
are the operations of the Getter
ability. You can call them as functions taking no arguments and returning a Char
and Nat
value respectively. When you call them, they each construct a different request object. The type of the request object will be dependent on what your f
function returns. If your side effects function f
returns say an array of natural numbers (type [Nat]
) then calling getNum
will create a request object of type Request {Getter} [Nat]
which gets sent to the handler h
.
The handler performs pattern matching on the received request object to figure out whether it is getting a getChar
or getNum
request. When the f
function completes, it will return a value which is passed on as a request to the handler h
. That terminates the exchange between the two functions (coroutines). I have illustrated the exchange between a side effect function called doStuff
and a handler called handler
below. In this illustration, the handler responds with the number 42 when it receives a getNum
request.
I lied a little bit when I said we connect a side effects function f
or doStuff
with a handler h
. To be more precise, I should have written handle e with h
, where e
is and expression. You can think of e
as akin to the code within a try-catch statement in a mainstream programming language such as Java. The try
clause lets you write some code which you want to evaluate and which potentially throws an exception. Similarly, handle
lets you write some code which may have side effects. Thus, my illustration of communications between doStuff
and handler
is set in motion with the statement:
handle !doStuff with handler
Which is a shorthand for:
handle (doStuff ()) with handler
In other words, we are not merely passing a doStuff
function object to handle
. Instead, we are actually executing the doStuff
function in the context of the handle-with statement.
Think of it this way: Whenever you want to run a function or code with side effects, it has to happen within the context of a handle-with statement. The handle-with statement will evaluate to the result from your side effect function doStuff
.
You can, however, run code with side effects as long as the function running this code is marked as having side effects. In Unison, that is done by attaching one or more Ability
types to the function type signature. Think of how try-catch works in Java. A method in Java cannot call another method which throws an exception unless the enclosing method is also marked as throwing the same exception. The requirement to signal that you throw an exception thus bubbles up the call stack. It is the same with side effect functions. You can only call a function with side effects from another function marked has having side effects (a function with one or more abilities in its signature).
So, how do you stop this whole bubbling up chain? In Java, you stop it by having a function enclose throwable code with try-catch. Likewise, in Unison, you stop the side effect requirement from bubbling up further by enclosing your side effect code within a handle-with statement.
In reality, you will not see a lot of handle-with statements when writing Unison code because it has been tucked away in higher-level functions handling all the details. How this work will become clearer if we look at how we actually write a handler function for the Getter
ability.
Writing a Handler Function
We are going to write a handler to deal with a simple side effect function written as follows:
doStuff : () -> {Getter} [Nat]
doStuff _ = [getNum, getNum, getNum]
Here, doStuff
is a function taking no arguments and returning a list of natural numbers obtained by calling getNum
3 times. Because doStuff
returns a list of numbers, our handler also has to do the same.
handler : Request {Getter} [Nat] -> [Nat]
handler req = match req with
{ getNum -> resume } ->
handle
(resume 42) -- send 42 back to doStuff
with
handler -- resume has side-effects
{ result } ->
result -- the list of numbers
The type of the input req
is Request {Getter} [Nat]
and the return type of handler
is [Nat]
because that is what doStuff
returns. The handler is supposed to return the result eventually for doStuff
.
The handler gets both a request such as getNum
and continuation called resume
. You could call the continuation whatever you want. resume
is just a common convention. You can think of it as getting passed a yield
function which when invoked resumes execution of the paused coroutine which sent it. The current coroutine yields (the handler) and hand over control to the doStuff
coroutine by passing it the value it requested; 42 in this case.
One thing that tripped me up at first was that handler functions tend to be recursive. However, that is not surprising given that resume
must have side effects. Keep in mind that resume 42
is the same as running doStuff
which is a function with side effects. Remember that after we process the first getNum
we will get two more getNum
requests.
For this reason, resume
must also be enclosed in a handle-with statement. All these recursive calls have to be terminated at some point, since eventually the doStuff
function completes execution and returns its result. That triggers the final request to the handler, but this time there is no continuation passed over because there is no execution to continue. This last request is represented with the {result} -> result
statement, which terminates the execution and returns result
from the handler.
Running Side Effects Code with a Handler
So, we got a handler. That means we can finally run our doStuff
function.
> handle !doStuff with handler
⧩
[42, 42, 42]
This code is pretty useless because we are always returning the same value 42. We need to have a more generic handler which can supply us with different numbers.
Writing a More Generic Handler
Functional programming tends to be a lot about higher-order functions: Functions which return other functions or take other functions as arguments. To get a more generic handler, we will write a higher-order function which takes as input a function with side effects and the value we want to supply to this function. Let us call it runNumGetter
:
runNumGetter : Nat -> '{Getter} [Nat] -> [Nat]
runNumGetter n doStuff =
handler : Request {Getter} [Nat] -> [Nat]
handler = cases
{ getNum -> resume } -> handle (resume n) with handler
{ result } -> result
handle !doStuff with handler
Here is an example of using runNumGetter
to supply the number 5 each time getNum
is called:
> runNumGetter 5 doStuff
⧩
[5, 5, 5]
What changes did we make to the previous handler? We replaced resume 42
with resume n
, where n
is the first argument to runNumberGetter
. Let us look at the function signature:
runNumGetter : Nat -> '{Getter} [Nat] -> [Nat]
runNumGetter n doStuff
What the signature tells us is that n
has type Nat
and doStuff
has type '{Getter} [Nat]
. runNumGetter
returns a value of type [Nat]
. doStuff
is a function taking no arguments and returning an array of natural numbers while producing side effects. Formally, that is expressed as () ->{Getter} [Nat]
. However, using the single quote gives us a shorthand '{Getter} [Nat]
. The reason this shorthand is useful is that a lot of functional programming deals with delayed computations. If I write 42 it will be evaluated immediately. But if I wrap it in a function () -> 42
then that value will not be evaluated until the function is called.
You will find the concept of delayed computations in many mainstream languages with functional inspiration. One example is Swift, which support lazy properties and where global variables are always lazily initialized. In Swift, one can tag a function argument as an autoclosure. It means Swift will automatically wrap an argument at the call site in closure. It makes it possible to implement things like assert
more easily.
Quoting and Delayed Computations
Delayed computations give us a way of passing around expressions with side effects until they can be run in the context of a handle-with statement. You cannot evaluate doStuff
where runNumGetter
is called. Instead, you need to delay the evaluation. Thus instead of writing runNumGetter [getNum, getNum] handler
, you write runNumGetter '[getNum, getNum] handler
because that delays the evaluation of the expression with side effects by stuffing it inside a function taking no arguments. Conceptually, it makes sense to talk about this as quoting an expression or delaying and expression.
Handling Character Requests
The handler example I have given is unrealistic because it doesn't handle every request possible. For instance, it doesn't handle getChar
. That is because it would change the return type of the handler. You have to return one type every time. Let me give an example of a getChar
handler to clarify:
runCharGetter : Char -> '{Getter} [Char] -> [Char]
runCharGetter ch doStuff =
handler : Request {Getter} [Char] -> [Char]
handler = cases
{ getChar -> resume} -> handle (resume ch) with handler
{ result } -> result
handle !doStuff with handler
Notice this handler doesn't return a list of natural numbers, but a list of characters ([Char]
). Here is an example of running it:
> runCharGetter ?A '[getChar, getChar, getChar]
⧩
[?A, ?A, ?A]
Normally, you would place these different ability operations in separate ability types. So, why did I mix them? For educational purposes, I wanted to make it clear to you how the return type of the handler is determined. You can see here that depending on the values you handle, you return a [Nat]
or a [Char]
. Once you work with more generic handlers, it becomes harder to keep track of what all the type parameters are. Allow me to clarify what I mean by type parameters. The Unison dictionary type Map
is defined as:
unique type Map k v
Here, k
and v
are the identifiers used to refer to the type parameters for the key and value type. Thus, Map Char Int
would refer to a concrete dictionary type with Char
keys and Int
values. To make more useful abilities, we would use type parameters.
Generic Handlers Using Type Parameters
To make the Getter
ability more useful, it should allow for any kind of value to be gotten, not just a Char
or a Nat
value. The handlers should also be written to deal with any of these values. Unison already has such an ability defined:
structural ability Ask a where
ask : {Ask a} a
Here, Ask
uses the type parameter a
to define the type of the value which should be returned when the ask
ability operation is executed.
The handler made to work with this ability called provide.handler
is also much more generic in that it allows you to run side effect functions with any return type:
provide.handler : a -> Request {Ask a} r -> r
provide.handler a =
h req = match req with
{ r } -> r
{ask -> resume} -> handle (resume a) with h
h
Notice how the value you will get is of type a
which means it can be anything. By providing a value to the handler, you also bind the a
type parameter used with the Request
object. That means your doStuff
function has to request values of type a
. The return type r
is also parameterized. The [Nat]
and [Char]
return types are gone. The handler r
type will match the return type r
of the doStuff
function. The Request
type binds these types together. Because doStuff
will produce requests of type Request {Ask a} r
we have a way of capturing what the return type of doStuff
is in the handler.
This explains why Request
must contain the return type of the side effect function called. If it didn't, there would be no way for us to bind this type to the r
type parameter in the handler. In our very first example, a
corresponded to the Nat
type, while the returned value type r
was the [Nat]
type.
To use the handler, Unison gives us the provide
function (I simplified the definition):
provide : a -> '{Ask a} r -> r
provide a asker =
handle !asker with (provide.handler a)
The provide
function allows us to run a quoted expression asker
of type '{Ask a} r
which has side effects. This expression evaluates to a value of type r
. Alternatively, you could phrase this as running a function of type () ->{Ask a} r
.
Thus, with provide
we can have expressions with all kinds of different return types:
> provide 12 '(ask + ask)
⧩
24
> provide 42 '[ask, ask, ask]
⧩
[42, 42, 42]
> provide 67 '(Char.fromNat ask)
⧩
Some ?C
And naturally the provided value doesn't need to be a number, as in the examples above. It could be anything. Here I am feeding provide
a string and a dictionary.
> provide "Hello" '(ask ++ ask)
⧩
"HelloHello"
> dict = Map.fromList [(1, "one"), (2, "two")]
> provide dict '(Map.lookup 2 ask)
⧩
Some "two"
A lot of the examples I have shown thus far are like toy examples without any clear usage apart from showing how abilities, side effects and handlers are used together. Let us look at something useful.
More Useful Handlers
The Store
ability is a bit more useful than Ask
because you can get back values you have previously stored. It supports two ability operations, put
and get
.
structural ability Store a where
put : a ->{Store a} ()
get : {Store a} a
The get
operation takes no arguments but returns a value of type a
. put
is the opposite. It takes a value of type a
and returns nothing. You can see a great example of how the Store
is used to create a stack machine in the Unison standard library documentation. In the example they define three functions push
, pop
and add
which all have side effects:
stack.push : a ->{Store [a]} ()
stack.push n =
use List +:
Store.put (n +: Store.get)
pop
until push
takes no argument but return a value of type a
Keep reading with a 7-day free trial
Subscribe to Erik Explores to keep reading this post and get 7 days of free access to the full post archives.