Trying out Unison, part 3: effects through abilities
In the previous articles on Unison, we've introduced Unison's "big idea": content-addressed code and explored how this impacts code organisation and dependency management.
But that's not everything that Unison has to offer. Its effect system is equally interesting and worth exploring in detail.
Effect systems aim to add some structure to the way side-effects are represented in code. This includes any I/O operations (both local and over the network), interactions with the system clock, generating random numbers, or spawning new computational threads.
If you find the following interesting, take a look at the remaining part of the series: part 4: from the edge, to the cloud.
Abilities, not monads
There are various approaches as to how effects can be handled, ranging from no constraints at all in imperative, dynamic languages, to always representing any kind of side effects in the type signatures, as is the case in Haskell. As Unison is a functional, statically typed language, it naturally falls near the latter side of the spectrum.
However, instead of monads, which are a popular choice for managing side effects, Unison offers abilities, which are an implementation of algebraic effects. An ability is a property of a function (it's not part of the value's type!). For example:
effectful: Nat ->{IO} String
effectful n = ...
tells us that during the evaluation of the effectful
function, an IO
effect might happen (which basically means—anything).
There's a number of built-in abilities, but we can also write our own. Each ability comes with a number of constructors, which introduce the ability. For example:
printLine : Text ->{IO, Exception} ()
is a function using two abilities (IO
for printing to the console, Exception
for any errors that might occur in this process).
Symmetrically, we have ability handlers, which eliminate an ability, possibly interpreting it in terms of another one. As an example:
catch : '{g, Exception} a ->{g} Either Failure a
reifies the error that might occur during the computation as an Either
datatype. We'll dive into quoting (the '
), as well as write our own ability constructors and handlers shortly.
catch
is also an example of an ability-polymorphic function. Each time you see a lower-case ability name, it means "any set of abilities". A special case is {}
—meaning no abilities are used—in other words, the function is pure.
In fact, each function has a set of abilities in Unison—the {}
is not rendered if not necessary. But when defining higher-order functions, you'll often see Unison adding an ability wildcard argument to your code.
Quoting
As we already mentioned, the abilities that are used are a property of a function, not a property of the value's type. That's also why abilities are written "glued" to the function arrow, not to the return type.
However, it's often useful to represent a value computing that might involve side effects. That's why Unison has special syntactic support for delayed, or lazy computations (being otherwise an eagerly-evaluated language). Each time you see '{IO} Nat
, this is in fact syntactic sugar for: () ->{IO} Nat
, which is a function that takes a dummy parameter and returns a Nat
, with some side-effects as described by the IO
ability.
Whenever you encounter code that somehow manipulates effectful code (such as catch
above), it will use quoted computations. That's because any higher-order function, which manipulates effectful functions, must do so lazily. If instead, it requested the already computed value, the effects would have already happened!
Built-in abilities
IO
is a special ability, as it is provided by Unison's runtime. There's no other way to eliminate this ability other than run
ning the code through ucm
(Unison Codebase Manger), or by compiling to an executable.
There are also other built-in abilities, mainly around error handling: Abort
, Exception
, Failure
, and a couple of others. Exception
is especially useful, as it forms the basis of Unison's error-handling mechanisms. In fact, we can run
any piece of code that has the type '{IO, Exception} Unit
. Notice the quote: this must be a lazily-evaluated expression. For example:
helloWorld: Text ->{IO, Exception} Unit
helloWorld name = printLine ("Hello, " ++ name)
helloAdam = '(helloWorld "Adam")
-- in UCM:
.> run helloAdam
Hello, Adam
However, unlike IO
, the other built-in abilities can be eliminated using functions such as catch
.
Custom abilities
To see how an ability works, it's best to write one of your own. As an example, let's take a look at the httpclient library. It defines an Http
ability with a single constructor:
unique ability Http where tryRequest: Request ->{Http} Either Failure Response
When writing our program, we can apply tryRequest
to a data structure describing the request to be made. As a result, we'll either get a failure (e.g. a connection error), or a response. But this might of course involve some side-effects—which are so far left abstract, described by the Http
ability.
Here's a sample Unison program, which runs a GET https://httpbin.org/get
. It assumes we've run pull stew.public.projects.httpclient.releases.v2 lib.v2
before:
use lib httpclient
httpGet: Text ->{Exception} httpclient.Request
httpGet uri =
parsed = Uri.parse uri
parsedOrRaise = getOrElse' '(Exception.raise (failure ("Cannot parse uri: " ++ uri) uri)) parsed
httpclient.Request.get parsedOrRaise
testHttpProgram1: '{httpclient.Http, Exception} httpclient.Response
testHttpProgram1 = '(request <| httpGet "https://httpbin.org/get")
We're using the request : Request ->{Http, Exception} Response
helper function from httpclient
which delegates to tryRequest
, but represents errors as exceptions, not an Either
value.
Now we can use the built-in handler, to interpret the Http
ability in terms of IO
, and run it:
handleHttpProgram1: '{IO, Exception} Response
handleHttpProgram1 _ =
handle !testHttpProgram1 with httpclient.Http.handler
The
!
is a way to force a lazily-evaluated value. It's equivalent to applying the value to()
. The operator is most useful when writing higher-order functions, which manipulate effectful code.
Alternatively, we can write a custom handler that we can use for testing. Such a handler is a special function, which should match the ability constructor being used. Each case in this match has access to the constructor's parameters, and a continuation of the program, parametrised with the result of the constructor.
We also need to include a case for "pure" inputs, which do not use the ability:
fakeHttp: '{g, httpclient.Http} r ->{g} r
fakeHttp httpProgram =
impl: abilities.Request {httpclient.Http} r -> r
impl = cases
{ pure } -> pure
{ httpclient.Http.tryRequest req -> resume } ->
response = match toText <| uri req with
"https://httpbin.org//get" ->
Right (Response (Status 200 "OK") Version.http11 Headers.empty Body.empty)
_ -> Left (failure "Connection error" req)
handle (resume response) with impl
handle !httpProgram with impl
Note that abilities.Request
is distinct from httpclient.Request
—the similarities in the names are completely coincidental. The abilities.Request
is a special type that allows us to pattern-match on an ability's constructors.
Here, we've got the pure
case (in which we just return the value), and the tryRequest
case. We can access the httpclient.Request
parameter (req
) and inspect it. If it's a request to a domain that our fake http implementation covers, we return 200 OK
. Otherwise, we simulate a connection exception.
The interesting part is resume
: the continuation of the program. The parameter represents what should happen when this request is computed.
Each case in our ability-matching will have a resume
of a different type. Here, it's Either Failure Response -> r
, as that's the result of the tryRequest
constructor. r
is the (unknown and arbitrary) result of the whole program.
Note that our handler eliminated the Http
ability: '{g, httpclient.Http} r ->{g} r
, without performing any actual side-effects.
Dependency injection
In object-oriented languages, dependency injection (DI) is a popular pattern, allowing substituting parts of our program's logic with alternative implementations. This is usually done with the help of an interface.
Sometimes DI is taken to the extreme, by over-parametrising every piece of logic, and creating interface-implementation pairs even when it is not necessary. On the other hand, pure FP languages often don't offer much in this area, except for hard-coding dependencies or using function parameters.
However, there are cases when you do want to have the flexibility of providing an alternate implementation. This is the case for any dependencies whose role is to communicate with the outside world: database interface, http clients, file system access, etc. Unison seems to strike a good balance here: it's precisely these things for which you'd want to create abilities.
If you have a program of type '{Http, DB} Unit
, you have to "inject dependencies" which "implement" the Http
and DB
"interfaces". Everything is in quotes, as in Unison's terminology we apply ability handlers. But the idea isn't that much different. As we've seen above, we can quite easily provide fake implementations for testing—something that is often called out as one of the main benefits of DI.
Error handling and monads
How do abilities relate to monads, and does Unison plan to include monads in the future? Not yet, but even if, it doesn't seem that monads will play a central role in the way Unison programs are written.
This is exemplified in the recommended way to represent errors. As stated in Unison's documentation, errors should preferably be represented using the Exception
ability, instead of being reified as a value using e.g. Either
. This is justified by the fact that Unison doesn't have any special syntactic constructs, which would make it easier to work with monadic code, such as for
-comprehensions as known from Scala, or the do
-notation as known from Haskell.
How functional are abilities?
Abilities are a very interesting approach to representing effects in a functional language. On one hand, using them you don't need to work with lazily-evaluated program descriptions, as is the case when manipulating IO
values. We can write side-effecting code in an imperative way, simply by enumerating the steps that need to be taken one by one:
registerUser: Email -> Text ->{IO, Exception} Unit
registerUser email name =
if (db.tryInsert email name)
then do
sendWelcomeEmail email name
createInitialData name
registerNotifications email
else
Exception.raise (failure ("Username taken: " ++ name) ())
On the other hand, an FP-purist might say that such code is not "referentially transparent". Still, I doubt anybody would classify Unison any differently than a functional language. Maybe that's a good data point for the ongoing discussions e.g. in Scala-land. Speaking of, Scala is working on an idea that looks quite similar (though the naming is different, of course).
While code that uses abilities is easier to read, and is more approachable to newcomers, there are some traps that await, especially when working with quoted and unquoted code. Unlike in Haskell's or Scala's IO
, where effects are always lazy, here we have to decide on our own, when to make things lazy, and when eager.
For example, it's not immediately clear if a function that takes quoted code as a parameter should return quoted code as well, or if it should return the value directly. The standard library isn't very consistent in this area as well, so I would guess that it's still up for debate. My hunch is that combinators should always return quoted code, so that it's possible to easily compose a number of them. For example, I would imagine a function that races two computations to have the following signature (note the quotes everywhere):
race: '{e} t -> '{e} t -> '{e, IO} t
Then, we can easily compose this with e.g. a timeout function:
timeout: Nat -> '{e} t -> '{e} Optional t
Another problem with quoted code, is if it's run only for its side effects (and not for its return value), it is quite easy to forget to force the computation (using !
), thus not getting any effects being run at all. However, this might be easy for the compiler to detect, and warn the user that there's a non-Unit
expression, whose value is being discarded.
There are some best practices to be worked out and glitches to iron out, but overall, even though the amount of Unison code I've written is rather small, so far it has been a very pleasant experience. Working with side-effecting code using the "direct" style (without monads) and at the same time, still tracking what kind of effects the code is performing is a rather unique and very refreshing mix: languages often have only one of these characteristics.
Hence, one more reason to closely watch Unison's development!