🤖 Introduction to Abilities: A Mental Model

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 describehow 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 isone 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 callDatabase.getAllUsersis performing aneffect.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 aDatabaseException.

We also know that there's some degree of safety in this code because of thetry/catchmechanism. Thetryblock let's us know we're executing code that may throw an exception, and thecatchblock is a handler for the exceptions that might surface.

At its most general, an ability is a pairing of some kind of effect with a handler which responds to that effect and says what to do when it happens.

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 outand 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 thelogblock 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 thestartParty friends userline of code.

Just like atryblock is followed by acatch,imagine thelogblock is followed by a generichandleblock, whose job is to pattern match on the possible actions thatlogcan 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 toLogger.warnorLogger.debugwe can call theremainderOfProgramafter our desired log statement and resume the snapshot of the state of the program when the call to the Logger is made.remainderOfProgramin this example is just the representation of continuing on tostartParty friends user.

At this point we know that an ability combines some notion of an effect with a handler that manages the effect, where the handler canresumethe computation that produced the effect.

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:

  1. the operations of the ability that it's handling (i.e. Logger.debug or raised exceptions)
  2. 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 bykvStorelooks pretty uncontroversial, buthandlelooks 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 memoryMap.🤔 Abilities allow us to choose how we'd like to perform the effect in question from via swappable handler functions. We might decide to use amapBasedStoragefunction or afancyDistributedKVStorefunction 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 ofarguments to the handler function.

You might see this pattern repeated in many functional programming contexts: abilities allow us to establish a separation of concerns between the code which creates the effect and the code which manages the behavior of that effect.

Let's think about what a handler should do to respond to a call toKVStore.put- we want to:

  1. grab thekeyandvaluearguments fromKVStore.putwhen we're pattern matching on it.
  2. use them to update the map which is functioning as our data storage
  3. 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 ourmapBasedStoragefunction with an updated map after theputrequest 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 toKVStore.get.

  1. grab thekeyargument fromKVStore.getin a pattern match.
  2. get it from our in-memory map based storage
  3. pass that value backto the program being executed
  4. ensure future calls to the key value store can be managed by themapBasedStoragehandler

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 namedresume.Think ofresumeas afunctionwhich 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.

An ability can resume the computation which performed it with a value tracked in the handler. Handlers allow us to pass information back to the program when an ability operation is called.

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.