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:
- 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.
.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.
.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.
- If you call an await-able function without
.await or .induce, you MUST provide an explicit callback.
- 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.
- 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.
- 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.
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:
f(...Args, fun(T))orf(...Args, fun()). Essentially, they are functions that take any number of arguments normally, and then a single callback function as their last argument..await. This keyword can only be applied to the end of a function call, e.g.my_call().await. This is desugared tomy_call(continuation), where everything that occurs after the call is converted into a continuation function, which is then passed to the call.awaitcan be used on any await-able function, and if it is, the function should not be called with an explicit callback..induce? (exact keyword subject to change). This is the opposite, to some degree, of.await. An await-able function may be called with.induceinstead of.await, alongside no explicit callback. In this case, the function is called as-if it was called with an explicit callback offun(){}orfun(t: T){}, depending on the callback type. That is,.inducestarts 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.die(), that wants to do something like wait for a timeout and then perform a scene transition. In this case, you want to calldie()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 usedie().induce, which explicitly gives you this behavior..awaitor.induce, you MUST provide an explicit callback.fun(x: int, y: int) -> float, and you use.awaitinside the function (not .induce), it implicitly becomes an async (or await-able) function. It is implicitly converted to a function with the signaturefun(x: int, y: int, callback: fun(float))(that is, the return type becomes the argument type of the callback), and can itself be.awaitor.induce'd (or called with an explicit callback).The purpose of
.induceis 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..induceit from the actual virtual function..inducebehavior, for example:One other question to consider is what to do if an async function calls its callback more than once, for 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.