Description
In spirit of #12660
Adapted from #14268
What problem does this solve or what need does it fill?
I believe it is not controversial to say that when an API offers two similar functions with similar names, the shorter will seem like the default. As such, I believe many people will instinctively gravitate to single
and single_mut
over get_single
and get_single_mut
. This means we are subtly pushing users to prefer the implicitly panicking version over the fallible one. This is bad, as it leads to games that may run well on the developers machine but then panic in an edge case.
It is currently understood in the wider Rust community that panics should be an exceptional case, a nuclear option for when recovery is impossible.
We should do our best to make it as easy to do the right thing by default (See Falling Into The Pit of Success). This means that it should be easier to handle an error by propagating it with ?
to a logger or similar than to panic. Our current API does the opposite.
This is an issue for the following APIs:
Query::single
Query::get
World::resource
World::non_send_resource
(although eventually this will likely be on App)- All of the other non-deferred entity-component-manipulating APIs listed in Use traits to make ECS APIs for working with entities more consistent #14231
What solution would you like?
Deprecate and remove panicking variants. Improve ergonomics by adding macros for early returns. Using a macro for get_single
as an example:
fn do_stuff(primary_window: Query<&Window, With<PrimaryWindow>>) {
let window = get_single!(primary_window);
// Do stuff with the window. If there's not exactly one, we have already returned.
}
Which expands to:
fn do_stuff(primary_window: Query<&Window, With<PrimaryWindow>>) {
match primary_window.get_single() {
Ok(item) => item,
Err => return default(),
};
// Do stuff with the window. If there's not exactly one, we have already returned.
}
Note that returning default()
will allow the macro to work with systems that return an Option
and get pipe
d into error handling methods.
Similar macros are already being used by some users, as indicated by https://github.com/tbillington/bevy_best_practices?tab=readme-ov-file#getter-macros
Paraphrasing @alice-i-cecile:
I'm also coming around on the macro approach: I'd rather this be a Rust feature where we can just ? in function that return (), but failing that this is better than the endless let else return pattern.
As the engine matures, I'm increasingly against panicking APIs, especially in seemingly innocuous cases like this. While it's nice for prototyping, it's a serious hazard for refactors and production-grade apps.
We should decide on what to do for all of these areas at the same time and make a consistent decision across the board, pulling in both SME-ECS and @cart. I think they're better implemented as separate PRs to avoid extreme size, but they should be shipped in the same cycle if we make the change.
What alternative(s) have you considered?
- Do nothing
- Leave the API as-is and heavily recommend the
get_
variants in documentation - Print a warning when calling panicking variants
- Keep the panicking variants and rename them to something clunky like
_unchecked
- Make the panicking API opt-in via a feature
- Leave the API as-is and let the schedule handle panics
- Use a
#[system]
macro that modifies the code to allow us to use?
in it, like in bevy_mod_sysfail
Open Questions
Naming
There is a sentiment that get_
is used by the standard library in general when returning an Option
.
Quoting @benfrankel:
This isn't true for the standard library:
Vec::first
,Iterator::next
,Path::parent
,Error::source
, etc.The g
et_
prefix is only used to differentiate from a panicking version. If there's no panicking version, there's no need for theget_
prefix. For some reason Bevy has latched ontofoo
vsget_foo
as the only API choice when foo may fail, but it doesn't have to be this way.
In a world where Bevy is not panicking by default and always hands out Option
s and Result
s, I believe there is little reason to stick to a get_
prefix. It is unnecessary noise on otherwise very concise functions. As such, I would advise to change Bevy's naming convention and drop it as part of this initiative. This may sound like something for another issue, but I'll argue that it should be discussed at least in the same release cycle. Users will already have to go and touch every instance of mut
, single
, etc. so I don't want to further annoy them by having to change them yet a second time when we drop the get_
prefix.
I realize that this is more controversial however and I'm fine with leaving it. I just want to point out that this is probably the best chance we will get to change it.
Macro parameters
It would be nice to have some influence over what should be done in the unhappy path. For example, we could have a parameter that controls an error!
log:
get_single!(foo, "Oh heck");
which expands to:
match foo.get_single() {
Ok(item) => item,
Err => {
error!("Oh heck");
return default();
}
};
Of course, the use of error!
is arbitrary here and the user may want to use warn!
instead or something else altogether. To accommodate for this, we could pass a closure:
get_single!(foo, || error!("Oh heck"));
Which expands to the same as above. Note that this closure could even dictate what we return
. Since error!
returns ()
, this works out for the common case, and a user is free to build their own Error
variant. However at that point, I don't know if a macro call is saving much typing work anymore.
One could also combine these variants by using labeled parameters:
get_single!(foo, warn: "Oh heck");
get_single!(bar, error: "Oh heck");
get_single!(baz, returns: || error!("Oh heck"));
This would allow the user to have a terse version for the common case usage and a more verbose one for custom error handling.
continue
This discussion has focused on the case where we want an early return. I think that is the common case, at least in my experience. What about continue
however? Do we want a _continue
variant for all macros? An extra parameter? Is there some kind of Rust hack that turns into either continue
or return
depending on context? Would that even be what the user expects to happen?