Code Smell
We have a term for what you describe: it is a code smell.
Sometimes yield
can be an indication of a bigger problem. Also there are cases where using yield
is not safe.
However, I don't think the example is a bad use of yield
. In particular I would worry if:
- Can
GUI
be freed prematurely?
- Can something else interact with
GUI
messing up this code?
Are there gotchas to yield() that I need to be aware of?
Yes. There are a couple:
- No cancellation: you yield on a signal and there is no way to cancel that.
- No auto-disconnection: if the object that yielded is freed, you have a runtime error.
Not so much a gotcha of yield
, but of the scene tree timers: they don't pause when you pause the game (get_tree().paused = true
).
Rules of thumb
Rules of thumb?
Using yield
on the correct object and signal will avoid timing issues. For example, if you need to wait for an AudioStreamPlayer
to finish, yield on the AudioStreamPlayer
instead of creating a timer.
Using yield
on signals that must happen (that nothing will prevent the signal from happening) will avoid the code from being awaiting until tear down. The AudioStream
loops and never finishes, oops.
Addendum: I should mention that there is a delay when playing audio, so if you play audio on a finished signal of another audio, there is an audible gap (there is a silence, I mean). But a timer won't fix that (they don't have enough temporal resolution). You need a thread sleeping for the appropriate time. Thankfully, we rarely need that much precision. Try audio tracks in the AnimationPlayer
instead (and queue animations if needed).
No cancellation
Sometimes you want to convert your yielding code into a state machine in _process
.
Also, avoid yield
inside a process that needs to cancel its prior execution (if there is a chance you will call the method before the prior execution finished, and it needs to operate on the same objects). In that case I suggest to use tweens, or animations (notice the call method tracks in the AnimationPlayer
).
No auto-disconnection
Since there is no auto-disconnection, use yield
on things that will be there:
- The same object.
- The scene tree.
- Objects created for it.
Once you start yielding on other Node
s you can start worrying. Is there a way for the other node to be freed independently? Can it happen while the code is awaiting in a yield?
- Yielding on an autoload is probably fine (you won't free those, right? RIGHT?).
- Yielding on children nodes might be fine, if there is no way for a
queue_free
or free
to get them.
- Avoid yielding on anything else, even if there is no
queue_free
or free
that can get them, if the yield can be ongoing during tear down (when the scene is being unloaded).
Every Node
will be destroyed at the end.
Error example
I decided to make a minimal example where yield
fails. It looks like this:
func _ready() -> void:
get_tree().change_scene("res://some_scene.tscn")
yield(get_tree().create_timer(0.1), "timeout")
print("DONE")
If the path to the scene is invalid, that is the error you get.
But if the scene path is valid, after requesting the scene change the code will create the timer. While the code is awaiting the timer, the scene changes. And when the timer emits the "timeout"
signal, Godot will try to resume this code it is already gone since the scene changed, and that is the error.
Note that it didn't require to access another Node
.
I also tried yielding on a signal from a Node
that is being freed:
$other_node.queue_free()
yield($other_node, "some_signal")
print("DONE") # never happens
And to my surprise instead of an error, yield
never returned.
What reminds an error is freeing the same Node
:
queue_free()
yield($other_node, "some_signal")
print("DONE")
Refactoring yield
You might want to refactor to using signals connections. Signal connection auto-disconnect when the objects are freed.. In particular with CONNECT_ONESHOT
(which auto-disconnect when they are fired).
However, be aware you cannot have the same method of the same object connected to same signal multiple times. Which means that sometimes it is better to create an object - of a custom class - to represent the process. Once every process has an object, you can connect to its method without worry of collision.
If you are using yield
in a loop, the refactor becomes a bit more complicated, but it is worth it. You need to rotate (unroll) the instructions so that the check to continue iterating happens where the yield
is… Which also means you might have to repeat some instruction before the loop. Then you make a method for the iteration of the loop, and another method for the lead up to the loop. So the lead up method connects to the iteration method, and the iteration method connects to itself.
I want to mention that Godot 4 has anonymous functions ("lambda" functions), so you can do all of this inline, and you can avoid the extra class.
On return values
Know that a method that uses yield
returns a GDScriptFunctionState
that has a completed
signal. So you can do this:
yield(my_method(), "completed")
But the method only returns GDScriptFunctionState
if it yields. Thus, make sure you can predict if a method will yield from its arguments. The easy way to do this is to make sure your methods either always yield, or never yield. This is so you know when and how you can yield on the method when calling it.
If you migrate said method to not use yield
, you can make it return an object with a "completed" signal, and you don't have to change the way you call it. Furthermore, the object it returns might be itself, in which case you might need to do this:
call_deferred("emit_signal", "completed")
This is because the signal must happen after the method returned for yield to get it.
I'll also point out that yield
returns. It returns the first argument (if any) it gets from the signal. So you can do this:
var my_var = yield(my_method(), "completed")
Which is how it works if the method uses yield
and also returns.
If the method does not use yield
, but instead returns an object that has the signal, that object would have to emit it the signal like this
emit_signal("completed", result)
And if the method is returning the same object, then you want do this:
call_deferred("emit_signal", "completed", result)