Say you couldn't find an ability inbase
for your particular use-case; Unison also allows you to write your own abilities to model a particular effect.
๐This doc assumes some familiarity with the generalmental modelfor what an ability is, and a working understanding ofusingabilities and handlers. If not, check outparts oneandtwo,of this guide and head right back.
Writing ability declarations
Here's an example ability declaration for an effect that models a key value store. You might choose to make this ability as a way to implement caching for a function without having to add a specific key value store as an argument to functions that require access to it. We'll put it to use in a bit.
ability KVStore a b
ability KVStore a b where
fundamentals.abilities.writingAbilities.KVStore.get :
a ->{KVStore a b} Optional b
fundamentals.abilities.writingAbilities.KVStore.put :
a -> b ->{KVStore a b} ()
Ability declarations start with theunique
orstructural
keyword, followed by the name of the ability and any lowercase type parameters that the ability might require. The type parameters here, lowercasea
andb
,mean that this store can contain keys and values of a specific type.
TheKVStore.get
andKVStore.put
signatures are the operations of the ability, which we callrequest constructors.In general, an ability can contain any number of request constructors. When naming your operations, ask yourself: independent of a particular implementation, what are the operations of the effect that I'm trying to model? For theKVStore
ability, we wouldn't want to call the request operation "redisGet", and for this simple key value store ability a request constructor called "putWithCreateTime" arguably leaks implementation details into the ability definition that might best be relegated to a specific handler.
In the signature forKVStore.get
,KVStore.get : a ->{KVStore a b} Optional b
,theability requirementappears in the return type. On it's face, this makes sense, when you call theKVStore.get
orKVStore.put
operations, you will need to handle the ability it performs!
Writing handlers
We've defined the interface for our ability, but to supply the desired behavior to the ability when it's called, we need to write a special Unison function called a handler.
The following function will be the example we'd like to run with our handler. It writes two values into storage and gets one of them back, callingNat.toText
on it.
myProgram : '{KVStore Nat Nat} Text
myProgram _ =
use KVStore put
put 3 4
put 5 6
maybeFour = KVStore.get 3
Optional.map Nat.toText maybeFour
|> Optional.getOrElse "nothing here"
We'll deep dive into the syntax of handlers by writing a handler for ourKVStore
ability next.
The parts of a handler
Let's look at a handler that enables interactions with a key value store backed by an in-memorydata.Map
.Don't worry, we'll be breaking this down:
inMemory : '{g, KVStore a b} r ->{g} r
inMemory keyValueInteractions =
impl : data.Map a b -> Request {KVStore a b} r -> r
impl map = cases
{ pure } -> pure
{ KVStore.get key -> resume } ->
handle resume (Map.get key map) with impl map
{ KVStore.put key value -> resume } ->
updatedMap = Map.insert key value map
handle !resume with impl updatedMap
handle !keyValueInteractions with impl data.Map.empty
Each section below provides an overview of what the handler is doing, with a link to optionally read more in-depth information on a particular handler concept.
Line by line overview
inMemory : '{g, KVStore a b} r ->{g} r
inMemory keyValueInteractions =
- In type signature we see that the
KVStore
ability is a delayed argument to the handler - It's polymorphic, meaning it can handle
KVStore
interactions of anya
orb
type which return any valuer
- The argument
keyValueInteractions
is the function we're trying to run. In our example, it stands in formyProgram
Read more about the type signatures of handlers
โฆ
handle !keyValueInteractions with impl Map.empty
- This is the next line that Unison would execute once the handler is provided.
- The
handleโฆ with
keywords tell Unison that this function is not just another functionusingthe ability, it is going to becapturing the requeststo the ability - Each time the
handleโฆ with
block is repeatedit sets up the handler for the next request;that's why its important to handle theresume
variable
Read more about thehandleโฆ with
keywords
โฆ
impl : Map a b -> abilities.Request {KVStore a b} r -> r
impl map = cases
โฆ
impl
is a helper function which holds state between requests- The
cases
syntaxused here is a way of pattern matching on theRequest
argument. - The
Request
type represents interactions withKVStore.get
andKVStore.put
Read more about theRequest
type
โฆ
impl map = cases
{ pure } -> pure
โฆ
- The
{pure} -> pure
line reflects the fact that a given program may never use the ability and determines the value returned by the handler in that situation - In our handler signature, the type of
pure
isr
,and when applied tomyProgram
,the valuer
is of typeText
Read more about returning pure values in handlers
โฆ
{KVStore.get key -> resume} ->
handle resume (Map.get key map) with impl map
{KVStore.put key value -> resume} ->
updatedMap = Map.insert key value map
handle !resume with impl updatedMap
โฆ
- The handler "gets" values from the map when a program calls the
KVStore.get
function - It returns the expected value to the "rest of the program", represented by the variable
resume
- It returns the expected value to the "rest of the program", represented by the variable
- The handler "gets" values from the map when a program calls the
- The handler "puts" values into the map when a program calls the
KVStore.put
function - It updates the internal state of the handler by calling
impl
with an updated map
- It updates the internal state of the handler by calling
- The handler "puts" values into the map when a program calls the
- resuming the "rest of the program" with another call to the
impl
function ensures subsequent interactions with theKVStore
ability are passed the appropriate state
Deep-dive: Writing handler concepts
The type signatures of handlers
One way to read the type signatureinMemory : '{g, KVStore a b} r ->{g} r
is, "inMemory handles a function that uses a KVStore in the process of returning some value, r, and eliminates the ability, returning that r value."
You'll notice a lowercase variable in the type signature,{g}
being passed through the ability requirements for the handler as well. This means that this handler allows other abilities to be called in the function being handled and will pass along the ability requirement for its respective enclosing handler function to manage.
TheinMemory
handler contains a helper function with the signatureimpl : Map a b -> Request (KVStore a b) r -> r
.The helper function's first argument is thedata.Map
that we'll be updating to contain state internal to the handler. The second argument starting withRequest (KVStore a b) r
is Unison's type which represents requests to perform the ability's operations (here those requests are things likeput 3 4
orget 3
).
Resuming computations
If you look at the cases in our pattern match,{KVStore.get k -> resume}
,you'll notice there's a variable representing the argument toKVStore.get
and also a variable calledresume
.This is because a handler allows you to access a snapshot of the of thearguments to the requestand the program stateas it is running.You might hear the variable for "resuming computations" called acontinuation.resume
is a function whose argument is always the return type of the request operation in question, for example,KVStore.get : a ->{KVStore a b} Optional b
returns anOptional
,so that's the value provided toresume
after looking up the key in thedata.Map
.
The fact that thecontinuationis reflected as a variable in the handler opens up possibilities for, say, terminating the computation, rerunning the computation, or even storing it!
The handle ... with keywords
It's important to initialize orresume
acontinuationwith thehandle... with
keywords. By wrapping the call toresume
with a recursive call to the handler implementation, we're telling the program, "this is how you should handle the next request." In this way, we're ensuring that the next call to the ability is being managed appropriately. The handler given here can contain state between requests as an argument, like themap
in ourKVStore
example.
Handling theRequest
type
Thehandle
keyword is followed by a function which requires the ability in question and thewith
keyword expects a function which includes theRequest
type as an argument. When a function includes theRequest
type in its signature, it tells Unison that the function will be pattern matching on the operations of the ability.
It's common for top-level handlers to accept a delayed computation, but remember, theRequest
type represents actual requests to your ability, so don't forget the bang operator,!
.
The return type of a handler
Handlers contain a "done" or "pass-through" or "pure" case for when functions which require an ability don't end up executing the ability operations, or when function is done calling the ability. This is represented by the pattern match case which does not reference the request constructors of the ability:{pure} -> pure
.In our example handler,inMemory : '{g, KVStore a b} r ->{g} r
the type of this "pure" case is represented by ther
.
Handlers can even change this value as part of the behavior they supply to the program. Say we wanted to return both the value,r
andthe final state of thedata.Map
after running the effect. We can do that by changing thepure
case and associated type signature:
inMemoryWithMap : '{g, KVStore a b} r ->{g} (r, data.Map a b)
inMemoryWithMap : '{g, KVStore a b} r ->{g} (r, data.Map a b)
inMemoryWithMap keyValueInteractions =
use data Map
impl : Map a b -> Request {KVStore a b} r -> (r, Map a b)
impl map = cases
{ pure } -> (pure, map)
{ KVStore.get key -> resume } ->
handle resume (Map.get key map) with impl map
{ KVStore.put key value -> resume } ->
updatedMap = Map.insert key value map
handle !resume with impl updatedMap
handle !keyValueInteractions with impl data.Map.empty
Now, without changing anything about ourmyProgram
function, we have access to thedata.Map
!
inMemoryWithMap myProgramโงจ( "4"
, internal.Bin
2 3 4 internal.Tip (internal.Bin 1 5 6 internal.Tip internal.Tip)
)
Summary
- Writing a handler involves pattern matching on therequest constructorsof the ability
- You can explicitly control what value the program receives next throughcontinuations
- The second argument in the pattern match case represents the continuation:
{op -> resume}
- The type of the value provided to the continuation must be the return type of the ability operation being called
- The second argument in the pattern match case represents the continuation:
- Handlers contain a "pure" case,
{pure} -> โฆ
,which represents when the function is done calling the ability or when the ability is not called - The
handle
keyword expects a function which requires the ability and thewith
keyword expects a function withRequest
as an argument