unisonweb / unison

A friendly programming language from the future
https://unison-lang.org
Other
5.79k stars 270 forks source link

Proposal: Change the syntax for abiliy handlers #3356

Open stew opened 2 years ago

stew commented 2 years ago

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:

{ foo bar -> resume } -> 

we would instead write:

Request (foo bar) resume ->

Examples:

unique ability Ask where
  ask: a

nets: Nat -> Request {Ask Nat} a -> a
nats n = cases
  Done a -> a
  Request Ask.ask resume ->
    handle resume n with nats (increment n)

unique abillity Counter where
  get: Nat
  set: Nat -> ()
  increment: ()
  decrement: ()

counter: Nat -> Request Counter a -> a
counter n = cases
  Request Counter.get resume ->
    handle resume n with counter n
  Request (Counter.set n) resume ->
    handle resume () with counter n
  Request Counter.increment resume ->
    handle resume () with counter (increment n)
  Request Counter.decrement resume ->
    handle resume () with counter (decrement n)
  Done a -> a

unique ability Throw e where
  throw: e -> a

Throw.toEither.handler: Request {Throw e} a -> (Either e a)
Throw.toEither.handler = cases
  Request (Throw.throw e) _ -> Left e
  Done a -> Right a

Throw.toEither: '{Throw e} a -> Either e a
Throw.toEither a = handle !a with Throw.toEither.handler

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 -> Request {Ask a, Stream b} r -> r
  handler input = cases
    Done a -> a
    Request Ask.ask resumeAsk -> handle !input with cases
        Result a -> a
        Request (Stream.emit head) tail -> handle resumeAsk head with handler tail
    Request (Stream.emit head) resumePipe -> 
      Stream.emit head
      handle resumePipe () with handler input
  handle filter with handler input
ceedubs commented 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.

anovstrup commented 2 years ago

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
pchiusano commented 2 years ago

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.

pchiusano commented 2 years ago

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

hojberg commented 2 years ago

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.

anovstrup commented 2 years ago

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:

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
ceedubs commented 2 years ago

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.

timjs commented 1 year ago

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 Requests 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 :-)

timjs commented 1 year ago

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... 😆)

timjs commented 1 year ago

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...)