One of Unison's more exciting language features is its support for abilities. They represent one of the ways in which a Unison program can manage computational effects in a program. Abilities are Unison's implementation of algebraic effects, as they're known in the literature 📚 - but don't worry, you don't need to have heard of them to use Unison abilities. In this section of the docs we'll build an intuition around the problems that algebraic effects are solving and form a mental model of what a program is doing when an ability is being called.
👋 As a heads up: we're not going to talk about the syntax specifics of Unison's abilities in this doc, if you're looking for that head to the section where we describe how to use an ability, this doc is just about creating an internal computer so that you have an understanding of how Unison executes code which uses Abilities.
What do we mean by effects
A computational effect or an "effectful" computation is one which relies on or changes elements that are outside of its immediate environment. Some examples of effectful actions that a function might take are:
- writing to a database
- throwing an exception
- making a network call
- getting a random number
- altering a global variable
Loosely, you might think of an effectful computation as one which performs an action outside of its local scope compared to one which simply returns a calculable value. Sound like a large bucket? Yup, effects are important and common.
So when functional programmers talk about managing effects, they're talking about expressing the basic logic of their programs within some guard rails provided by data structures or programming language constructs. We'll put off a full discussion of functional effect management, but in general, managing effects in functional programming is done by representing the side effecting behavior in a more explicit way, therefore rendering it easier to react to it in a program. Unison's support for abilities is one such guard rail.
Abilities in pseudocode
Abilities are one way to express computations that perform effects, but comparatively few programming languages make use of them, so one of the challenges for learning about abilities is gaining an intuition of "how does this work 🧐" or more specifically, "does this look like anything I already know?" If you've encountered or written code that makes use of try and catch blocks, then you've seen a concept that offers a good starting point for an understanding of abilities.
Let's imagine the following code in a fictional language. 🪄
try {
users = Database.getAllUsers
} catch {
case DatabaseException message -> print message
}
We know from looking at this code that the function call Database.getAllUsers
is performing an effect. It relies on the external resource of a database to gather the relevant entities and that resource might be unavailable or fail for some internal reason — represented by a DatabaseException
.
We also know that there's some degree of safety in this code because of the try/catch
mechanism. The try
block let's us know we're executing code that may throw an exception, and the catch
block is a handler for the exceptions that might surface.
Let's build on that understanding. Imagine we want to write something akin to a try/catch block, but rather than managing exceptions, we want to manage logging. Unlike exceptions, which mean that the program execution has stopped for some reason and can't continue with subsequent steps in the program, when a function creates the effect of writing a log line, it's typically in the process of doing something which should continue after the call to write a log.
In our fictional language, we might couple the ability to write logs with something that handles the log statement by printing them out and then resuming the program.
log {
friends = getFriends user
if (length friends) == 0
then Logger.warn "user has no friends ☹️"
else Logger.debug "user has friends"
startParty friends user
} handle {
…
}
Again, this language syntax doesn't exist, but we can assume the log
block provides access to some kind of effectful logger. The code in the body of the block should log the user's number of friends and continue to execute the startParty friends user
line of code.
Just like a try
block is followed by a catch,
imagine the log
block is followed by a generic handle
block, whose job is to pattern match on the possible actions that log
can do. That might look like this:
log {
…
} handle {
case (Logger.debug message -> remainderOfProgram ) ->
print ("Debug: " ++ message)
remainderOfProgram
case (Logger.warn message -> remainderOfProgram ) ->
print ("Warn: " ++ message)
remainderOfProgram
}
Our handler needs some way to represent the rest of the program or the "continuation" of the program after the Logger call is made. We might represent this by adding it to the pattern match, that way, when the handler receives a call to Logger.warn
or Logger.debug
we can call the remainderOfProgram
after our desired log statement and resume the snapshot of the state of the program when the call to the Logger is made. remainderOfProgram
in this example is just the representation of continuing on to startParty friends user
.
So far, the effects that we've been exploring, Exceptions and Logging, don't actually change the behavior of the business logic that follows. But what if the effect we'd like to run is something like saving and accessing a global variable, or getting input from a user? The remainder of the program is likely to be impacted by that operation. In our mental model of abilities, we need a way for the effect to alter the state of the program.
This changes our idea of a handler a little bit. Previously, the handler only needed to capture two pieces of information to be effective:
- the operations of the ability that it's handling (i.e. Logger.debug or raised exceptions)
- some notion of finishing the computation that was in flight when an effect was performed.
Let's say we want to write a very simple program that puts and then gets a value from a key value store. In our fake language we might express the core logic of our program something like this:
kvStore {
KVStore.put "id" 5
KVStore.get "id"
} handle with mapBasedStorage Map.empty
The block started by kvStore
looks pretty uncontroversial, but handle
looks different from a standard try/catch block. It looks like we're handing off the management of the effect to a function call which takes in some kind of in memory Map
. 🤔 Abilities allow us to choose how we'd like to perform the effect in question via swappable handler functions. We might decide to use a mapBasedStorage
function or a fancyDistributedKVStore
function to run our key value store operations. The handler of an ability is where you'll find the implementation details for performing the effect, and the state that the ability might rely on is often passed along in the form of arguments to the handler function.
Let's think about what a handler should do to respond to a call to KVStore.put
- we want to:
- grab the
key
andvalue
arguments fromKVStore.put
when we're pattern matching on it. - use them to update the map which is functioning as our data storage
- resume the remainder of the program with our chosen handler,
mapBasedStorage,
passing in the updated map
It might look something like this:
mapBasedStorage map =
case (KVStore.put key value -> resume) ->
updated = put key value map
resume () with mapBasedStorage updated
…
Calling our mapBasedStorage
function with an updated map after the put
request effectively "sets" the handler's state for subsequent interactions with the key value store effect.
Now let's think about what a handler should do to respond to a call to KVStore.get.
- grab the
key
argument fromKVStore.get
in a pattern match. - get it from our in-memory map based storage
- pass that value back to the program being executed
- ensure future calls to the key value store can be managed by the
mapBasedStorage
handler
It might look something like:
mapBasedStorage map =
case (KVStore.get key -> resume) ->
desiredValue = get key map
resume desiredValue with mapBasedStorage map
…
Notice how the desired value from the map can be managed by a handler and then passed back to the continuation named resume.
Think of resume
as a function which is expecting the result of the ability operation being called as its argument. When the desired value is given to the resume function, it continues the main program.
Summary
Roughly, an ability can be broken down into two things, an interface which specifies some operations, and handlers which provide behavior to those operations. When a program uses an ability, the program halts its execution, hops over to the responsible handle block, finds the matching operation, performs the behavior specified there, and then resumes the program.
Before going into the specificities of Unison's syntax for abilities, we wanted you to have an understanding of abilities on an operational level. In the next section about abilities, we'll talk more about how we use and represent abilities in actual Unison programs. We'll discuss how abilities are represented in function type signatures, what it means to handle an ability, and look at how they're defined.