Skip to content

Design for async/await #66

@HoneyPony

Description

@HoneyPony

After using GDScript a bit again recently, I've found that I almost certainly want to have a similar async/await concept in PonieScript.

It is very useful for building various kinds of state machines and similar code.

The design I'm spinning around in my head right now is based entirely on a desugaring to closures/callbacks, similar to JavaScript. The main reason to me, for such a design, is because it is fundamentally "simple" and flexible: it lets users implement all sorts of async behaviors on top of the desugaring without needing any explicit runtime support (because it all boils down to ".await is sugar for a closure").

The main concepts are:

  1. await-able functions. These are any functions of the form f(...Args, fun(T)) or f(...Args, fun()). Essentially, they are functions that take any number of arguments normally, and then a single callback function as their last argument.
    • These functions can simply be called with an explicit callback if desired. Similarly, custom asynchronous functions can be implemented by simply implementing a function with this signature.
  2. .await. This keyword can only be applied to the end of a function call, e.g. my_call().await. This is desugared to my_call(continuation), where everything that occurs after the call is converted into a continuation function, which is then passed to the call.
    • await can be used on any await-able function, and if it is, the function should not be called with an explicit callback.
  3. .induce? (exact keyword subject to change). This is the opposite, to some degree, of .await. An await-able function may be called with .induce instead of .await, alongside no explicit callback. In this case, the function is called as-if it was called with an explicit callback of fun(){} or fun(t: T){}, depending on the callback type. That is, .induce starts the await-able function "going" but does not explicitly await for it; so long as the application is still alive, however, whatever logic is driving the resolution of the function will eventually resolve it.
    • This is the equivalent of calling an async function in Godot without await'ing it. This often comes up, when, for example, you want a function such as die(), that wants to do something like wait for a timeout and then perform a scene transition. In this case, you want to call die() once, for example, from your main update loop, but not await it (you want it to just keep running in the background). This is when you would use die().induce, which explicitly gives you this behavior.
  4. If you call an await-able function without .await or .induce, you MUST provide an explicit callback.
  5. If you have a function with a signature like fun(x: int, y: int) -> float, and you use .await inside the function (not .induce), it implicitly becomes an async (or await-able) function. It is implicitly converted to a function with the signature fun(x: int, y: int, callback: fun(float)) (that is, the return type becomes the argument type of the callback), and can itself be .await or .induce'd (or called with an explicit callback).

The purpose of .induce is to make it syntactically experience when we are spawning a behavior that we believe will keep going in the background, but note that there is no reason we can't just do this implicitly. This is to make the behavior slightly more explicit and predictable.

However. In the case of virtual functions, just like Godot, we probably want a non-async base virtual function (such as _ready) to become async in a subclass. There are a couple ways we could handle this.

  1. We could allow you to "just do it," and implicitly synthesize an async function for the virtual function, then .induce it from the actual virtual function.
  2. We could make it so virtual functions cannot be async (or at least cannot have their async-ness changed), and require you to manually implement this .induce behavior, for example:
class Node { @virtual fun _ready() { ... } )

class Player {
    @override
    fun _ready() {
        fun animation() {
            timeout(2.5).await;
            spawn_particles_or_something();
        }

        animation().induce;
    }
}

One other question to consider is what to do if an async function calls its callback more than once, for example:

fun call_twice(callback: fun()) {
    callback();
    callback();
}

fun example() {
    var x = 30;
    call_twice().await;
    x += 5;
    print(x);
}

fun init() {
    //! 35
    //! 40
    example();
}

As usual, the main concern will likely be to ensure that this behavior is memory safe. I'm not sure I necessarily care beyond that. It's true that there will be some subtlety to manually implementing async functions t hat are correct, but I think I'm OK with that, at least for now.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions