Open yoloroy opened 6 months ago
I never thought of using await
inside lambda functions like in this proposal. It sounds powerful for users aware of it. Are there concrete examples (not generic like in the proposal) used in any of your projects that make use of this?
Firstly, please note that await
does not operate on "conditions", but on signals and coroutines (and also returns other values immediately).
This way this code:
Code
```gdscript func _act(): normal_execution() await some_condition1: do_something1() await some_condition2: do_something2() normal_execution_continues() ```Will be inlined as:
Code
```gdscript func _act(): normal_execution() (func(): await some_condition1 do_something1() (func(): await some_condition2 do_something2() ).call() ).call() normal_execution_continues() ```
Lambdas capture local variables by value, not by reference (see #8558). It's quite confusing. In the case of the await
block you suggested, something similar should happen (since local variables may disappear before the start of the async code and we don't have GC to hold closures). In my opinion, in this case it is even more unobvious than with lambdas.
For example, what do you expect the following code to output if signal_1
is emitted first, then signal_2
?
func test():
var x = 1
print(x)
await signal_1:
x = 10 * x + 3
print(x)
await signal_2:
x = 10 * x + 5
print(x)
x = 10 * x + 4
print(x)
x = 10 * x + 2
print(x)
If it should be equivalent to the following:
Also why don't you just use:
func test():
var x = 1
print(x)
x = 10 * x + 2
print(x)
await signal_1
x = 10 * x + 3
print(x)
x = 10 * x + 4
print(x)
await signal_2
x = 10 * x + 5
print(x)
In this case, the output is 1, 12, 123, 1234, 12345 because there are no captures.
I never thought of using
await
inside lambda functions like in this proposal. It sounds powerful for users aware of it. Are there concrete examples (not generic like in the proposal) used in any of your projects that make use of this?
class_name AIBehaviorAttack
extends AIBehavior
@export var area: Area3D
@export var reload_seconds: float
var _reloading := false
func _ready():
area.body_entered.connect(func(__):
if not _active:
available.emit()
)
super._ready()
func finish():
if reload_seconds > 0:
_reloading = true
(func():
await get_tree().create_timer(reload_seconds).timeout
_reloading = false
if is_possible():
available.emit()
).call()
super.finish()
func is_possible():
return area.has_overlapping_bodies() and not _reloading
Plus, there was interlocking problem, that dissapeared after some refactor and adding another state to state machine, see Describe the feature / enhancement and how it helps to overcome the problem or limitation segment.
Instead of:
func async_func():
if condition():
action_1()
(func():
await coroutine()
action_3()
).call()
action_2()
you can do:
func async_func():
var cond = condition()
if cond:
action_1()
action_2()
if cond:
await coroutine()
action_3()
Although this is more code and looks less "elegant", I think it is better as it is more explicit and reflects the actual order of actions. While with the proposed option you can easily get confused in the control flow.
As I said, the proposed implementation as lambda inlining has the disadvantage that lambdas capture locals by value. In theory, we could use a different approach, since local variables will exist as long as the internal GDScriptFunctionState
exists, unlike the case with lambda return in #8558). But this would be difficult to implement for conditional await
block[^1][^2], and probably impossible to do properly for loops (plus it would be extra confusing).
[^1]: The compiler would have to take into account all the branches containing the await
block in order to reproduce them (using local variables to avoid side effects) after the sync part of the function has completed.
[^2]: Also, we would have to account for return
s in the sync part of the fuction, since they completely terminate the function and destroy local variables, the async part should not be executed after that. With a lambda, you get around both problems because local variables are captured by value and the lambda can be executed after the main function returns.
await
is a control flow abstraction. When you await an expression the function may suspend execution and allow other functions to run until the awaited expression has completed. Once the expression has completed it will resume execution. This abstraction greatly improves the readability of asynchronous code by making it appear synchronous.
Some notes
await
-block in the examples is not awaiting the condition, but creating an async-block. Which is fine, it just means that this proposal is for a different concept than await
. Maybe async
or task
is a more appropriate keyword.func _act():
normal_execution()
(func():
await some_condition1
do_something1()
(func():
await some_condition2
do_something2()
).call()
).call()
normal_execution_continues()
Could be rewritten to:
func _act():
normal_execution()
some_condition1.connect(func():
do_something1()
await some_condition2
do_something2(), CONNECT_ONE_SHOT)
normal_execution_continues()
I like this example, but it is a different concept than what is proposed.
var condition1_or_2 := Signal.new()
for condition in [condition1, condition2]:
(func(self_f):
await condition
condition1_or_2.emit()
).call()
await condition1_or_2
if is_condition1():
do_something1()
elif is_condition2():
do_something2()
Can be implemented with an async_any()
function that returns the first complete signal.
class EphemeralSignal:
signal complete(data)
func async_any(conditions: Array[Signal])
var any_done = EphemeralSignal.new()
func emit_done(condition): any_done.complete.emit(condition)
for condition in conditions:
condition.connect(emit_done.bind(condition))
var result = await any_done.complete
for condition in conditions:
condition.disconnect(emit_done)
return result
func action():
var condition = await async_any([some_signal_condition1, some_signal_condition2])
match condition:
some_signal_condition1: print("some_signal_condition1 done first")
some_signal_condition2: print("some_signal_condition2 done first")
if reload_seconds > 0:
_reloading = true
(func():
await get_tree().create_timer(reload_seconds).timeout
_reloading = false
if is_possible():
available.emit()
).call()
Could be rewritten to:
if reload_seconds > 0:
_reloading = true
get_tree().create_timer(reload_seconds).timeout.connect(
func():
_reloading = false
if is_possible():
available.emit()
)
Describe the project you are working on
A 3d third-person action game with usage of 2d billboard sprites for characters and wide weapons and spells system.
Describe the problem or limitation you are having in your project
await
still somethimes mindblowing for me. I am used to implementation all blocking functionality in three ways:if
per eachupdate
/_process
/...
call (unpreferred): # GDScriptvia Observer: # GDScript
But in GDScript I just can put
await
statement in the function and all the code down below will postpone its execution until the signal will be emitted: (I tried one time do somthing like this, but my guts said that this is very scary and I should implement this behavior more traditionally)Already implemented await behavior totally works for me in other cases, but these sometimes I limit myself to second option from the list above because I am unsure, will I be able to easily understand these execution jumps between functions in the future development.
Describe the feature / enhancement and how it helps to overcome the problem or limitation
Add
await <some signal>: <code block>
syntax (without affecting already existing await functionality). This wayawait
's nonsync behavior will become more obvious.Example of refactored via proposed feature code from the above:
This way, there opens an opportunity for parallel await in-place calls without creating many small private functions. This way we can set visual boundaries of what goes into await segment.
Last argument can be contrared with creating separated function for exact await call, but I am used to control execution time from outside, using
when something_happens() than do_something()
, notdo_something_when_something_happens()
. I like to separate these things.Access to local variables defined in function that calls function_with_await_inside becomes restricted except for clojures in lambdas and other workarounds when using creating_function_for_each_await approach.
Usage of interlocking events right now:
With await as control structure this becomes straight-forward:
Describe how your proposal will work, with code, pseudo-code, mock-ups, and/or diagrams
This feature can work as a simple macros, which will create complicated static functions or lamdas.
This way this code:
Will be inlined as:
If this enhancement will not be used often, can it be worked around with a few lines of script?
Example of inlined code that used above is (let's be honest) ugly. Workarounds listed in problems section is not uniform and require excess thinking.
Is there a reason why this should be core and not an add-on in the asset library?
This can help newcomers and can provide even more elegancy into existing syntax.
UPD1: added example, how implementation of this feature would resolve interlocking events problem.