-
-
Notifications
You must be signed in to change notification settings - Fork 3.6k
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
bevy_reflect: Type parameter bounds #9046
Conversation
I like the approach. It's also more predictable, as it's in line with how the std lib traits work. But because — unlike with std lib traits — it's extremely onerous to implement yourself the Here is my suggestion: We keep the derive stupid simple by default (ie: do as std, ie: as this PR) But the controls should be: // We don't need `T` to be `Reflect` since we only care about `T::Assoc`
#[derive(Reflect)]
#[reflect(custom_trait_bounds(T::Assoc: Reflect)]
struct Foo<T: Trait>(T::Assoc); This would disable the default generic trait bounds and replace them with the provided value. Just paste as-is the bounds from the Would this help? Being able to provide custom bounds is valuable (I think, right?). And in term of implementation, treating the meta attribute as a "discard all default behavior" rather than "customize default behavior" should simplify the code. |
Yes, hard agree! The last thing we want is to force people to have to manually implement any of the reflection traits themselves since it's pretty easy to implement something the wrong way. I just realized I wasn't thinking this out clearly enough. My thought was that if we don't go with this PR, we just require users to add the bounds themselves. But I forgot about the fact that users might not want to always have For other readers' reference, it's like when you do this: #[derive(Default)]
struct Foo<T>(Option<T>); But then you realize that this adds the struct Bar;
// Should be `Foo(None)`
let foo = Foo::<Bar>::default(); // ERROR! `Bar` does not implement `Default` The fix there is to manually implement
With the above consideration in mind, I think this would extremely beneficial. However, we might still need both because I don't know if Would we want to use both or is there a better way to handle both opt-out and custom bound cases? I can probably add that to this PR (unless you really wanted to code it yourself haha). Also, bikeshed: what about |
The The advantage over any alternative, is that there is no bound-specific logic, no need to keep track of which bound is disabled etc. It's either "all generic parameters of the struct" or "what the user specified". It's KISS and fully flexible. A potential issue is that it might generate bad error messages that are difficult to untangle as a user. |
Ok Edit: Maybe |
Are you saying that to opt-out of the normal bounds, you'd need to do I think the dedicated attribute is the clearest (so far).
Yeah I like |
Well, you would use |
bc06a10
to
37eafe9
Compare
Okay so unfortunately you can't use keywords like I made it so that it only overrides type parameters explicitly called out in the attribute. This is so that types with tons of generics don't need to redefine the same reflection bounds on all of them even though they really only intended to override one. But here's my question: should we still have something like Without Here are some examples: // Use `'static` to opt-out of the default bounds for `T`
#[derive(Reflect)]
#[reflect(custom_where(T: 'static))]
struct Foo<T> {
a: String,
#[reflect(ignore)]
b: PhantomData<T>,
#[reflect(ignore)]
c: usize,
} trait Trait {
type Assoc;
}
// We don't need `T` to be `Reflect` since we only care about `T::Assoc`.
// To prevent `T` from getting the default bounds, we can use the same `'static` trick
#[derive(Reflect)]
#[reflect(custom_where(T: 'static, T::Assoc: FromReflect))]
struct Foo<T: Trait>(T::Assoc); |
I re-iterate that I'd prefer to see a dumb simple solution rather than a more advanced one that tries to conform to user expectations (and IMO fails, or actually sets the expectations itself).
Do you have an example of such type? My generic structs don't usually have more than 3 type parameters. I'll refer to "only overwriting parameters referred to by the custom clause" by "named-only". IMO "erase and replace" is more predictable for users:
With erase-and-replace, the user might be surprised that setting the bound of IMO if we go with named-only a "disable all bound" is not needed, because you can always do |
I think you raise some good points, and I agree that having |
Simplified the attribute as per @nicopap's suggestions (Discord thread). Now the entire where-clause is replaced when the attribute is present (minus the absolutely required bounds). This was decided on due to its simplicity. We can always return to the more granular approach in the future if needed. So now we can achieve the examples above like so: // Use `'static` to opt-out of the default bounds for `T`
#[derive(Reflect)]
#[reflect(custom_where())]
struct Foo<T> {
a: String,
#[reflect(ignore)]
b: PhantomData<T>,
#[reflect(ignore)]
c: usize,
} trait Trait {
type Assoc;
}
// We don't need `T` to be `Reflect` since we only care about `T::Assoc`.
#[derive(Reflect)]
#[reflect(custom_where(T::Assoc: FromReflect))]
struct Foo<T: Trait>(T::Assoc); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Some question regarding certain implementation details.
@nicopap I went ahead and just refactored the entire Let me know what you think and if there is anything I should add/remove/change! |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Lovely!
it's true that we need an attribute to opt-out of bounds and the way however, i'm not sure i'm in favour of the changes to the default bound resolution logic: our current algorithm i think is less prone to false positives overall, being better tailored to on the other hand, it is true that this method provides an antidote for some of the arcane components of the derive macro and matches the behaviour of all the standard library's macros, so i'm not wholly against the change. i would recommend slicing the breaking changes from this PR out into a separate one where we can discuss, but if i'm fighting the consensus that won't be necessary. |
Serde has a different syntax for type bounds. What about re-using it? |
i think my favourite possible syntax is to force (in a separate attribute definition from any other configuration) |
My (extremely lazy) solution was, for () if meta.path.is_ident("set_params") => {
self.set_params = Some(meta.input.parse()?);
} Results in |
675d7fb
to
01bff72
Compare
How would we opt-out of default bounds? In the above examples they have the
I actually do sorta like this. It gives us the option to modify the where clause on specific impls if we find we can/should add that sort of configuration in the future. Although, I guess it doesn't matter too much right now since I don't think we have any impls we want to allow the user to control separately (yet). |
# Objective > Issue raised on [Discord](https://discord.com/channels/691052431525675048/1002362493634629796/1179182488787103776) Currently the following code fails due to a missing `TypePath` bound: ```rust #[derive(Reflect)] struct Foo<T>(T); #[derive(Reflect)] struct Bar<T>(Foo<T>); #[derive(Reflect)] struct Baz<T>(Bar<Foo<T>>); ``` ## Solution Add `TypePath` to the per-field bounds instead of _just_ the generic type parameter bounds. ### Related Work It should be noted that #9046 would help make these kinds of issues easier to work around and/or avoid entirely. --- ## Changelog - Fixes missing `TypePath` requirement when deriving `Reflect` on nested generics
Now it overwrites the where clause for all type parameters rather than just the ones included in the attribute.
Co-authored-by: Nicola Papale <nicopap@users.noreply.github.com>
01bff72
to
fcce728
Compare
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I added a new commit (be18a8f) that replaces custom_where
with just a regular where
clause as suggested by @soqb. I'd like to get feedback on which one we might want to go with.
Example:
// Change parameter bound
#[derive(Reflect)]
#[reflect(where T: Default)]
struct Foo<T>(String, #[reflect(ignore)] PhantomData<T>);
// Opt-out parameter
#[derive(Reflect)]
#[reflect(where)] // Yeah, I'm surprised a lone `where` is valid syntax too lol
struct Bar<T>(String, #[reflect(ignore)] PhantomData<T>);
match meta.tokens.clone().into_iter().next() { | ||
// Handles `#[reflect(where T: Trait, U::Assoc: Trait)]` | ||
Some(TokenTree::Ident(ident)) if ident == "where" => { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is the best I could come up with for peeking into a TokenStream
. If anyone else has a better way of doing this, please let me know!
3f0433d
to
496c158
Compare
496c158
to
7ea40fb
Compare
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Great docs, good explanation, sensible code changes and lots more tests.
I'm going to leave this to Monday, but I'm content to merge this without further review. Added back to the milestone as a result. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
i'm still not in favour of the new default bounds (on type parameters instead of active fields).
however the implementation is solid so i'll defer to y'all on this one.
/// // + ::core::marker::Send | ||
/// // + ::core::marker::Sync, |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
don't we want Any + Send + Sync
on T::Assoc
instead?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
also, you'll need to add TypePath
to the list for T::Assoc
.
#[derive(Reflect)] | ||
struct SelfRecurse { | ||
recurse: Vec<SelfRecurse>, | ||
} | ||
|
||
let _ = <SelfRecurse as Typed>::type_info(); | ||
let _ = <SelfRecurse as TypePath>::type_path(); | ||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
these checks seem redundant given what's below them.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Took a while to regain context here. I completely forgot what I thought was important…
I don't love the new syntax. I think the serde approach is better1 (if only to follow established norms). Though it's not a dealbreaker for me.
Couldn't take a very deep look at the code this time, but from a quick glance it looks good.
LGTM.
Footnotes
I'm going to merge this now then: we can clean it up further in follow-ups. |
Type parameters no longer receive reflection bounds. Fields receive those bounds now (as they did before bevyengine#9046).
# Objective Revert the changes to type parameter bounds introduced in #9046, improves the `#[reflect(where)]` attribute (also from #9046), and adds the ability to opt out of field bounds. This is based on suggestions by @soqb and discussion on [Discord](https://discord.com/channels/691052431525675048/1002362493634629796/1201227833826103427). ## Solution Reverts the changes to type parameter bounds when deriving `Reflect`, introduced in #9046. This was originally done as a means of fixing a recursion issue (#8965). However, as @soqb pointed out, we could achieve the same result by simply making an opt-out attribute instead of messing with the type parameter bounds. This PR has four main changes: 1. Reverts the type parameter bounds from #9046 2. Includes `TypePath` as a default bound for active fields 3. Changes `#reflect(where)]` to be strictly additive 4. Adds `#reflect(no_field_bounds)]` to opt out of field bounds Change 1 means that, like before, type parameters only receive at most the `TypePath` bound (if `#[reflect(type_path = false)]` is not present) and active fields receive the `Reflect` or `FromReflect` bound. And with Change 2, they will also receive `TypePath` (since it's indirectly required by `Typed` to construct `NamedField` and `UnnamedField` instances). Change 3 was made to make room for Change 4. By splitting out the responsibility of `#reflect(where)]`, we can use it with or without `#reflect(no_field_bounds)]` for various use cases. For example, if we hadn't done this, the following would have failed: ```rust // Since we're not using `#reflect(no_field_bounds)]`, // `T::Assoc` is automatically given the required bounds // of `FromReflect + TypePath` #[derive(Reflect)] #[reflect(where T::Assoc: OtherTrait)] struct Foo<T: MyTrait> { value: T::Assoc, } ``` This provides more flexibility to the user while still letting them add or remove most trait bounds. And to solve the original recursion issue, we can do: ```rust #[derive(Reflect)] #[reflect(no_field_bounds)] // <-- Added struct Foo { foo: Vec<Foo> } ``` #### Bounds All in all, we now have four sets of trait bounds: - `Self` gets the bounds `Any + Send + Sync` - Type parameters get the bound `TypePath`. This can be opted out of with `#[reflect(type_path = false)]` - Active fields get the bounds `TypePath` and `FromReflect`/`Reflect` bounds. This can be opted out of with `#reflect(no_field_bounds)]` - Custom bounds can be added with `#[reflect(where)]` --- ## Changelog - Revert some changes #9046 - `#reflect(where)]` is now strictly additive - Added `#reflect(no_field_bounds)]` attribute to opt out of automatic field trait bounds when deriving `Reflect` - Made the `TypePath` requirement on fields when deriving `Reflect` more explicit ## Migration Guide > [!important] > This PR shouldn't be a breaking change relative to the current version of Bevy (v0.12). And since it removes the breaking parts of #9046, that PR also won't need a migration guide.
# Objective Fixes bevyengine#8965. #### Background For convenience and to ensure everything is setup properly, we automatically add certain bounds to the derived types. The current implementation does this by taking the types from all active fields and adding them to the where-clause of the generated impls. I believe this method was chosen because it won't add bounds to types that are otherwise ignored. ```rust #[derive(Reflect)] struct Foo<T, U: SomeTrait, V> { t: T, u: U::Assoc, #[reflect(ignore)] v: [V; 2] } // Generates something like: impl<T, U: SomeTrait, V> for Foo<T, U, V> where // Active: T: Reflect, U::Assoc: Reflect, // Ignored: [V; 2]: Send + Sync + Any { // ... } ``` The self-referential type fails because it ends up using _itself_ as a type bound due to being one of its own active fields. ```rust #[derive(Reflect)] struct Foo { foo: Vec<Foo> } // Foo where Vec<Foo>: Reflect -> Vec<T> where T: Reflect -> Foo where Vec<Foo>: Reflect -> ... ``` ## Solution We can't simply parse all field types for the name of our type. That would be both complex and prone to errors and false-positives. And even if it wasn't, what would we replace the bound with? Instead, I opted to go for a solution that only adds the bounds to what really needs it: the type parameters. While the bounds on concrete types make errors a bit cleaner, they aren't strictly necessary. This means we can change our generated where-clause to only add bounds to generic type parameters. Doing this, though, returns us back to the problem of over-bounding parameters that don't need to be bounded. To solve this, I added a new container attribute (based on [this](dtolnay/syn#422 (comment)) comment and @nicopap's [comment](bevyengine#9046 (comment))) that allows us to pass in a custom where clause to modify what bounds are added to these type parameters. This allows us to do stuff like: ```rust trait Trait { type Assoc; } // We don't need `T` to be reflectable since we only care about `T::Assoc`. #[derive(Reflect)] #[reflect(where T::Assoc: FromReflect)] struct Foo<T: Trait>(T::Assoc); #[derive(TypePath)] struct Bar; impl Trait for Bar { type Assoc = usize; } #[derive(Reflect)] struct Baz { a: Foo<Bar>, } ``` > **Note** > I also [tried](bevyengine@dc139ea) allowing `#[reflect(ignore)]` to be used on the type parameters themselves, but that proved problematic since the derive macro does not consume the attribute. This is why I went with the container attribute approach. ### Alternatives One alternative could possibly be to just not add reflection bounds automatically (i.e. only add required bounds like `Send`, `Sync`, `Any`, and `TypePath`). The downside here is we add more friction to using reflection, which already comes with its own set of considerations. This is a potentially viable option, but we really need to consider whether or not the ergonomics hit is worth it. If we did decide to go the more manual route, we should at least consider something like bevyengine#5772 to make it easier for users to add the right bounds (although, this could still become tricky with `FromReflect` also being automatically derived). ### Open Questions 1. Should we go with this approach or the manual alternative? 2. ~~Should we add a `skip_params` attribute to avoid the `T: 'static` trick?~~ ~~Decided to go with `custom_where()` as it's the simplest~~ Scratch that, went with a normal where clause 3. ~~`custom_where` bikeshedding?~~ No longer needed since we are using a normal where clause ### TODO - [x] Add compile-fail tests --- ## Changelog - Fixed issue preventing recursive types from deriving `Reflect` - Changed how where-clause bounds are generated by the `Reflect` derive macro - They are now only applied to the type parameters, not to all active fields - Added `#[reflect(where T: Trait, U::Assoc: Trait, ...)]` container attribute ## Migration Guide When deriving `Reflect`, generic type params that do not need the automatic reflection bounds (such as `Reflect`) applied to them will need to opt-out using a custom where clause like: `#[reflect(where T: Trait, U::Assoc: Trait, ...)]`. The attribute can define custom bounds only used by the reflection impls. To simply opt-out all the type params, we can pass in an empty where clause: `#[reflect(where)]`. ```rust // BEFORE: #[derive(Reflect)] struct Foo<T>(#[reflect(ignore)] T); // AFTER: #[derive(Reflect)] #[reflect(where)] struct Foo<T>(#[reflect(ignore)] T); ``` --------- Co-authored-by: Nicola Papale <nicopap@users.noreply.github.com>
# Objective Revert the changes to type parameter bounds introduced in bevyengine#9046, improves the `#[reflect(where)]` attribute (also from bevyengine#9046), and adds the ability to opt out of field bounds. This is based on suggestions by @soqb and discussion on [Discord](https://discord.com/channels/691052431525675048/1002362493634629796/1201227833826103427). ## Solution Reverts the changes to type parameter bounds when deriving `Reflect`, introduced in bevyengine#9046. This was originally done as a means of fixing a recursion issue (bevyengine#8965). However, as @soqb pointed out, we could achieve the same result by simply making an opt-out attribute instead of messing with the type parameter bounds. This PR has four main changes: 1. Reverts the type parameter bounds from bevyengine#9046 2. Includes `TypePath` as a default bound for active fields 3. Changes `#reflect(where)]` to be strictly additive 4. Adds `#reflect(no_field_bounds)]` to opt out of field bounds Change 1 means that, like before, type parameters only receive at most the `TypePath` bound (if `#[reflect(type_path = false)]` is not present) and active fields receive the `Reflect` or `FromReflect` bound. And with Change 2, they will also receive `TypePath` (since it's indirectly required by `Typed` to construct `NamedField` and `UnnamedField` instances). Change 3 was made to make room for Change 4. By splitting out the responsibility of `#reflect(where)]`, we can use it with or without `#reflect(no_field_bounds)]` for various use cases. For example, if we hadn't done this, the following would have failed: ```rust // Since we're not using `#reflect(no_field_bounds)]`, // `T::Assoc` is automatically given the required bounds // of `FromReflect + TypePath` #[derive(Reflect)] #[reflect(where T::Assoc: OtherTrait)] struct Foo<T: MyTrait> { value: T::Assoc, } ``` This provides more flexibility to the user while still letting them add or remove most trait bounds. And to solve the original recursion issue, we can do: ```rust #[derive(Reflect)] #[reflect(no_field_bounds)] // <-- Added struct Foo { foo: Vec<Foo> } ``` #### Bounds All in all, we now have four sets of trait bounds: - `Self` gets the bounds `Any + Send + Sync` - Type parameters get the bound `TypePath`. This can be opted out of with `#[reflect(type_path = false)]` - Active fields get the bounds `TypePath` and `FromReflect`/`Reflect` bounds. This can be opted out of with `#reflect(no_field_bounds)]` - Custom bounds can be added with `#[reflect(where)]` --- ## Changelog - Revert some changes bevyengine#9046 - `#reflect(where)]` is now strictly additive - Added `#reflect(no_field_bounds)]` attribute to opt out of automatic field trait bounds when deriving `Reflect` - Made the `TypePath` requirement on fields when deriving `Reflect` more explicit ## Migration Guide > [!important] > This PR shouldn't be a breaking change relative to the current version of Bevy (v0.12). And since it removes the breaking parts of bevyengine#9046, that PR also won't need a migration guide.
Objective
Fixes #8965.
Background
For convenience and to ensure everything is setup properly, we automatically add certain bounds to the derived types. The current implementation does this by taking the types from all active fields and adding them to the where-clause of the generated impls. I believe this method was chosen because it won't add bounds to types that are otherwise ignored.
The self-referential type fails because it ends up using itself as a type bound due to being one of its own active fields.
Solution
We can't simply parse all field types for the name of our type. That would be both complex and prone to errors and false-positives. And even if it wasn't, what would we replace the bound with?
Instead, I opted to go for a solution that only adds the bounds to what really needs it: the type parameters. While the bounds on concrete types make errors a bit cleaner, they aren't strictly necessary. This means we can change our generated where-clause to only add bounds to generic type parameters.
Doing this, though, returns us back to the problem of over-bounding parameters that don't need to be bounded. To solve this, I added a new container attribute (based on this comment and @nicopap's comment) that allows us to pass in a custom where clause to modify what bounds are added to these type parameters.
This allows us to do stuff like:
Alternatives
One alternative could possibly be to just not add reflection bounds automatically (i.e. only add required bounds like
Send
,Sync
,Any
, andTypePath
).The downside here is we add more friction to using reflection, which already comes with its own set of considerations. This is a potentially viable option, but we really need to consider whether or not the ergonomics hit is worth it.
If we did decide to go the more manual route, we should at least consider something like #5772 to make it easier for users to add the right bounds (although, this could still become tricky with
FromReflect
also being automatically derived).Open Questions
Should we add askip_params
attribute to avoid theT: 'static
trick?Decided to go withScratch that, went with a normal where clausecustom_where()
as it's the simplestNo longer needed since we are using a normal where clausecustom_where
bikeshedding?TODO
Changelog
Reflect
Reflect
derive macro#[reflect(where T: Trait, U::Assoc: Trait, ...)]
container attributeMigration Guide
When deriving
Reflect
, generic type params that do not need the automatic reflection bounds (such asReflect
) applied to them will need to opt-out using a custom where clause like:#[reflect(where T: Trait, U::Assoc: Trait, ...)]
.The attribute can define custom bounds only used by the reflection impls. To simply opt-out all the type params, we can pass in an empty where clause:
#[reflect(where)]
.