godotengine / godot-proposals

Godot Improvement Proposals (GIPs)
MIT License
1.12k stars 69 forks source link

Add await as a control structure #9249

Open yoloroy opened 6 months ago

yoloroy commented 6 months ago

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:

  1. via if per each update/_process/... call (unpreferred): # GDScript
    func _process(dt: float):
    if is_reacted_on_contition_once and <condition>:
      do_something()
  2. via Observer: # GDScript

    var f := func(args, this_func):
    do_something()
    some_condition.disconnect(this_func)
    
    some_condition_met.connect(f.bind(f))
  3. via coroutine: # Kotlin
    launch {
    someEvent.await()
    do_something()
    }

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)

func _act():
  normal_execution()
  await some_condition1
  do_something1()
  await some_condition2
  do_something2()

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 way await's nonsync behavior will become more obvious.

Example of refactored via proposed feature code from the above:

func _act():
  normal_execution()

  await some_condition1:
    do_something1()

    await some_condition2:
      do_something2()

  normal_execution_continues()

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(), not do_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:

## Example of bad interlocking implementation

var f1: Callable
var f2: Callable

f1 = func(f1_, f2_):
  await condition1
  condition1.disconnect(f1_)
  condition2.disconnect(f2_)
  do_something1()

f2 = func(f1_, f2_):
  await condition2
  condition1.disconnect(f1_)
  condition2.disconnect(f2_)
  do_something2()

# direct calls cannot be used because of clojure magic
f1.bind(f2).bind(f1).call()
f2.bind(f2).bind(f1).call()
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()

## and how will condition1_or_2 freed in this case?

With await as control structure this becomes straight-forward:

var is_conditions_resolved := false

await condition1:
  if not is_conditions_resolved:
    is_conditions_resolved  = true
    do_something1()

await condition2:
  if not is_conditions_resolved:
    is_conditions_resolved  = true
    do_something2()

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:

func _act():
  normal_execution()

  await some_condition1:
    do_something1()

    await some_condition2:
      do_something2()

  normal_execution_continues()

Will be inlined as:

func _act():
  normal_execution()  

  (func():
    await some_condition1
    do_something1()
    (func():
       await some_condition2
       do_something2()
    ).call()
  ).call()

  normal_execution_continues()

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.

Mickeon commented 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?

dalexeev commented 6 months ago

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)
  1. 1, 12, 123, 1234, 12345;
  2. 1, 12, 13, 134, 135 (right answer)?

If it should be equivalent to the following:

Code ```gdscript extends Node signal signal_1() signal signal_2() func _ready(): test() signal_1.emit() signal_2.emit() func test(): var x = 1 print(x) (func(): await signal_1 x = 10 * x + 3 print(x) (func(): await signal_2 x = 10 * x + 5 print(x) ).call() x = 10 * x + 4 print(x) ).call() x = 10 * x + 2 print(x) ```

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.

yoloroy commented 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?

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.

dalexeev commented 6 months ago

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

PrestonKnopp commented 6 months ago

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

  1. 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.
  2. All of the examples, presuming identifiers named with "condition" are bound to signals, can be solved cleanly by connecting to the condition signal itself.

Counter Examples

Example 1

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

Example 2

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

Example 3

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