Open stew opened 2 years ago
This is a bit of a tangent, but if we are talking about changing up matching in handlers I can't help but bring the following up.
It throws me off that ability pattern matches don't support things like fallbacks that other pattern matching supports (see #2922) for an example.
Also if I have a handler for Scratch
and a handler for Atomic
then it seems like it should be really easy to combine them into a multi-handler for {Atomic, Scratch}
. But I haven't figured out anyway to do this other than to match on every single Atomic
and Scratch
method individually and delegate through to the respective handler. It would be nice if I could do something more like:
cases
r @ (Request {Scratch}) -> scratchHandler r
r @ (Request {Atomic}) -> atomicHandler r
I don't know what you would do about the continuation vs Result
piece of things. And maybe this doesn't matter if there's another way to horizontally compose handlers that doesn't involve pattern matching.
I'm torn on this proposal. On the one hand, I think it likely will make handler pattern matching easier for beginners to understand and maybe easier for everyone to read. On the other, I really like the economy of the current syntax for writing handlers. Maybe the old syntax could be retained, while favoring the new syntax in the pretty printer?
This old proposal is also tangentially related and shares the goal of making abilities easier to understand. The idea, in particular, of dropping handle .. with
from the surface language and allowing Request
handlers to be applied directly to delayed computations might dove-tail well with the current proposal. That is, treating h e
as if it were handle !e with h
if h : Request {e} a -> r
(or, put differently, allowing users to apply h : Request {e} a -> r
as if it were h : '{e} a -> r
). For example:
store : v -> Request {Store v} a -> a
store init = cases
Request (Store.put v) resume -> store v resume
Request Store.get resume -> store init '(resume init)
Done x -> x
> store 3 'Store.get
⧩
3
Idea: Request
pattern with one arg could be equivalent to Request arg1 resume
. Often that's what you'd call the continuation anyway.
You could still use Request (Counter.set n) myContinuationName
if you want to call it something else or you're doing something fancy with multiple continuations. But in the default case, the user can almost pretend like resume
is a keyword.
On the one hand, I think it likely will make handler pattern matching easier for beginners to understand and maybe easier for everyone to read. On the other, I really like the economy of the current syntax for writing handlers. Maybe the old syntax could be retained, while favoring the new syntax in the pretty printer?
Yeah, Request
is kinda long. OTOH, { blah x y -> resume }
is short but somewhat awkward to type with the curly braces and the ->
I think with my idea above about being able to optionally drop the continuation, you come out ahead on keystrokes in many cases
Some bike shedding:
I feel like Request
isn't special enough of a constructor name to indicate to me that this is ability stuff. I don't have a specific suggestion, but its always kinda confused me.
Alternative proposal (with similar motivations), riffing off the syntax Paul suggested in Slack: reduce the number of surface language constructs dedicated to ability handling, introducing a minimal surface level syntax that transforms to the current representation under the hood. This approach has the advantage that it minimizes the number of additional constructs that learners must integrate when going from using abilities to writing ability handlers.
Specifically:
Request
types, handle .. with
expressions, and the extraneous brace/arrow tokens from ability patterns in the surface language.handle
, handle!
, handler
, or cases!
) to start an ability pattern match.continuationVar abilityOp -> expr
, continuationVar (abilityOp arg1 arg2 ...) -> expr
, and pureVar -> expr
for ability patterns.Request {A av1 av2 ..., B bv1 bv2 ..., ...} r1 -> r2
as '{g, A av1 av2 ..., B bv1 bv2 ..., ...} r1 -> r2
at the surface level, and allow it to be applied as such (translating surface-level function application into handle .. with
expressions under the hood and optimizing away any unnecessary thunking).The examples in the description above would be written as follows, but would transform to the same internal representation:
unique ability Ask where
ask: a
nets: Nat -> '{g, Ask Nat} a -> a
nats n = handle
a -> a
resume Ask.ask -> nats (increment n) '(resume n)
unique abillity Counter where
get: Nat
set: Nat -> ()
increment: ()
decrement: ()
counter: Nat -> '{g, Counter} a -> a
counter n = handle
resume Counter.get ->
counter n '(resume n)
resume (Counter.set n) ->
counter n '(resume ())
resume Counter.increment ->
counter (increment n) '(resume ())
resume Counter.decrement ->
counter (decrement n) '(resume ())
a -> a
unique ability Throw e where
throw: e -> a
-- NOTE: there is no separate Throw.toEither.handler
Throw.toEither: '{Throw e} a -> Either e a
Throw.toEither = handle
_ (Throw.throw e) -> Left e
a -> Right a
unique ability Stream where
emit: a
Stream.pipe: '{g, Stream a} r -> {g, Ask a, Stream b} r -> {g, Stream b} r
Stream.pipe input filter =
handler: '{g, Stream a} r -> '{g, Ask a, Stream b} r -> r
handler input = handle
a -> a
resumeAsk Ask.ask -> (handle
a -> a
tail (Stream.emit head) -> handler tail '(resumeAsk head)) input
resumePipe (Stream.emit head) ->
Stream.emit head
handler input '(resumePipe ())
handler input 'filter
While we are throwing ideas around: what if the Result
/{ x } -> x
case were added by default? 95% of the time this seems to be what you want, and it's easy to forget. You'd still be able to handle it explicitly if you need to do something fancy with multiple continuations.
It would be really nice to get rid of the Request
type and the handle .. with ..
syntax!
Switching back and forth between delayed computations and Request
s tripped up my head when experimenting with Unison. Didn't had these problems when creating handlers in Koka, where they are indeed functions on delayed computations, which is quite easy to explain.
I very much like the ideas sketched above by @anovstrup! It's a good iteration on your proposal nr.1 in #1251 and takes Unison handler syntax and usage more on par with Koka's and Frank's. Takes out two constructs to learn when using abilities in Unison :-)
PS Maybe we can get rid of the pipe
<> pipe!
functions in the code base as well? As currently writing pipe s t
is the same as '(pipe! s t)
, but pipe!
is used more:
.> dependents pipe
#hctk483t0t doesn't have any named dependents.
.> dependents pipe!
Dependents of #upb11euf6s:
Name Reference
1. base.data.Stream.drop! #futgjgrdoq
2. base.data.Stream.filter! #didi55js8s
3. base.data.Stream.flatMap! #0mlvmp6mln
4. unison.internal.deps.base.data.Stream.flatMap! #s8iv3iuruk
5. base.data.Stream.map! #vbkae5cdmm
6. base.data.Stream.pipe #hctk483t0t
7. base.data.Stream.take! #0oehq3f82c
Why not call pipe!
pipe
and get rid of pipe!
? (Very strange sentence to type... 😆)
Just for future reference. You can define a handling
function in Unison which aids in writing more "direct style" handlers as in Frank:
handling : (Request {h} r -> r) -> '{g, h} r ->{g} r
handling handler action = handle !action with handler
unique ability Counter where
get : Nat
set : Nat -> ()
increment : ()
decrement : ()
counter: Nat -> '{g, Counter} a ->{g} a
counter n = handling cases
{Counter.get -> resume } ->
counter n '(resume n)
{Counter.set n -> resume } ->
counter n resume
{Counter.increment -> resume } ->
counter (n + 1) resume
{Counter.decrement -> resume } ->
counter (n - 1) resume
{a} -> a
The right hand sides of the pattern matches are now the same as in @anovstrup's example (modulo typos to make it compile and changing '(resume ())
into resume
).
This way of writing really helps me to express my mental picture of handlers more directly into Unison code! (Albeit it is less efficient as there are more thunks laying around I think...)
Motivation: Newcomers are confused by the syntax for ability handlers. Often they cannot remember the syntax because it is different than other pattern match syntaxes.
Proposal: Change the ability handler pattern matching to look just like you are pattern matching on a constructor.
We'll introduce two special constructors which one can pattern match on:
Request (method arg1 arg2) continuation corresponding roughly to our current {method arg1 arg2 -> continuation} syntax Done a corresponding to our current { a }
so today where we write:
we would instead write:
Examples: