-
Notifications
You must be signed in to change notification settings - Fork 211
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
Add get
/set
to property
attribute to specify custom getter/setter
#841
Conversation
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.
Thanks a lot for your contribution! 🙂
Custom getters/setters are a very useful feature. I think they should even supersede the clumsy {before|after}_{set|get}
group of attributes; with a good design they will be powerful enough to do everything the former are capable of. Those could be removed/deprecated in a separate PR though, once this is finished.
Regarding the example from the test, leaving out the Cell
logic:
#[derive(NativeClass)]
#[inherit(Node)]
struct CustomGetSet {
#[property(get = "Self::get_foo", set = "Self::set_foo")]
foo: i32,
}
#[methods]
impl CustomGetSet {
fn get_foo<'a>(&'a self, _owner: TRef<Node>) -> &'a i32 {
&self.foo
}
fn set_foo(&mut self, _owner: TRef<Node>, value: i32) {
self.foo = value;
}
}
While a nice test, this is likely not how this feature would be used in practice. The getters/setters do what #[property]
already provides out of the box.
As mentioned on Discord, I think a more general scenario includes the case when there is no backing field for the property, and getters/setters access internal state in an indirect way.
For example, a container-like class that provides access to the size.
#[derive(NativeClass)]
#[inherit(Node)]
struct MyCollection {
#[property(get = "Self::get_size")]
size: Property<i32>,
vec: Vec<u8>,
}
#[methods]
impl CustomGetSet {
fn get_size(&self, _owner: TRef<Node>) -> i32 {
self.vec.len() as i32
}
}
Where Property
is a simple marker struct:
// ZST (zero-sized type)
struct Property<T> {}
We also need to define what happens for each combination of get
and set
. We have the following cases:
#[property]
(no attributes) keep working as-is, auto-generating getter and setter#[property(get = "...")]
has a custom getter -- I would say no setter is generated (consistent with GDScriptsetget
)#[property(set = "...")]
has a custom setter -- also, no getter#[property(get = "...", set = "...")]
has custom getter and setter
Maybe we should require that, as soon as at least one of get
or set
attributes are present, the field type must be Property<T>
.
In this light, it could also make sense that the default getter is not by-ref but by-value. By-ref is mostly an optimization that's more limiting and verbose to use; we could always add support for it later, but I'd start small.
gdnative-derive/src/lib.rs
Outdated
@@ -218,6 +218,10 @@ pub fn profiled(meta: TokenStream, input: TokenStream) -> TokenStream { | |||
/// Call hook methods with `self` and `owner` before and/or after the generated property | |||
/// accessors. | |||
/// | |||
/// - `get` / `set` `= "Self::accessor_method"` |
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.
Maybe list the two separately?
/// - `get` = "Self::my_getter`
/// - `set` = "Self::my_setter`
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.
OK. Should I add an example to doc or somewhere else?
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.
No, I think it's good for now. Maybe later, we can update the book accordingly. But before that, we need to converge on a final design.
if let Some(old) = self.get.replace(path) { | ||
return Err(syn::Error::new( | ||
pair.span(), | ||
format!("there is already a get value set: {:?}", old), |
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.
"get value set" is a bit confusing, maybe "there is already a 'get' attribute with value:"
same below
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.
Agree. Actually I copy-paste this from other arms inside the same method (add_pair
) and others has almost the same format of error message. How about use a pair of back quote ("`"
) to surround the variable name?
For example, "there is already a `get` value set:\n{:?}"
. I think this will be more convenient to write a macro to generate each arm? (But I am not familiar with macro, not sure whether it is suitable to use in this case)
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.
You can also use backticks, they will likely not be formatted though.
Why a macro? Not sure I understand it...
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.
Because every arm (e.g. "set"
, "get"
) has a very similar implementation, only differs in field name. I think it will be better if these arms can be generated by macro. (Like how this crate implement Export
trait for core types (source))
test/src/test_derive.rs
Outdated
foo: 0, | ||
} | ||
} | ||
fn get_foo<'a>(&'a self, _owner: TRef<Node>) -> &'a i32 { |
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 lifetime can be inferred, no? There's no other reference parameter than self
.
fn get_foo(&self, _owner: TRef<Node>) -> &i32 {
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.
It's a very subtle difference but TRef
does have an elided lifetime parameter here: TRef<Node>
= TRef<'_, Node>
.
@Bromeon I think the |
No worries, I should have copied it to the GitHub issue. Discord is temporary aether ✨
If it's possible with reasonable effort, gladly. But don't hesitate to reach out when you encounter issues or want someone else to jump in 👍 |
Generally agree. I believe it might be better if we allowed users to declare auto-generated accessors explicitly, a la C#, and only require the field to be
Having this option is good for many simple use cases where the custom setters boil down to "assign with side effects". For example: struct SomeType {
#[property(get, set = "Self::set_foo")]
foo: Foo,
bar: Bar,
update_required: bool,
}
#[methods]
impl SomeType {
#[export]
fn _process(&mut self, _owner: TRef<'_, Node>, _delta: f32) {
if self.update_required {
self.update_required = false;
unimplemented!("do expensive stuff");
}
}
#[export]
fn set_foo(&mut self, _owner: TRef<'_, Node>, value: Foo) {
self.bar = value.compute_bar();
self.foo = value;
self.update_required = true;
}
} This becomes visibly more verbose if we require that the value be stored separately from the property declaration: struct SomeType {
#[property(get = "Self::get_foo", set = "Self::set_foo")]
foo: Property<Foo>,
foo_storage: Foo, // New field
bar: Bar,
update_required: bool,
}
#[methods]
impl SomeType {
#[export]
fn _process(&mut self, _owner: TRef<'_, Node>, _delta: f32) {
if self.update_required {
self.update_required = false;
unimplemented!("do expensive stuff");
}
}
// New method
#[export]
fn get_foo(&self, _owner: TRef<'_, Node>) -> &Foo {
&self.foo_storage // Error-prone: users are tempted to write `&self.foo`, which won't compile
}
#[export]
fn set_foo(&mut self, _owner: TRef<'_, Node>, value: Foo) {
self.bar = value.compute_bar();
self.foo_storage = value; // Error-prone, same as above.
self.update_required = true;
}
} |
@chitoyuu That's a good addendum, thanks for mentioning C#. I fully agree with your suggestions! |
d9d4744
to
3416667
Compare
@Bromeon I think it's ready to be reviewed now. |
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.
Thanks a lot for the update, it's really appreciated! 🙂
Added a few comments.
Clippy is currently failing, you can run it locally with this command:
cargo clippy --workspace --features gdnative/async,gdnative/serde -- -D clippy::style -D clippy::complexity -D clippy::perf -D clippy::dbg_macro -D clippy::todo -D clippy::unimplemented
(I should really write a local .sh which emulates CI -- there's an old commit hook but it's quite outdated).
Would be nice if you squashed the commits. If you wish, you can also reduce it to 2 (one with the initial implementation and one with the Property
one), but please do what's easiest/least work for you. A very easy way is
git reset --soft origin/master && git commit
struct CustomGetSet { | ||
pub get_called: Cell<i32>, | ||
pub set_called: Cell<i32>, | ||
#[allow(dead_code)] |
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.
It seems like we always need #[allow(dead_code)]
whenever Property
is involved (and obviously not accessed)?
I wonder if adding a
quote! {
#[allow(dead_code)]
};
might do the job...
But this PR is already quite big, I'm also fine if we do this at some point in the future 🙂
these attributes are used to set custom getter and setter.
fa0d2c4
to
de96485
Compare
@Bromeon I have finished the most of changes. But not sure how to generate |
bors try |
tryBuild succeeded: |
One thing I noticed during testing: #[derive(NativeClass)]
#[inherit(Node)]
struct MyStruct {
#[property(get = "Self::get_i32")]
#[allow(dead_code)]
extra: Property<i32>,
}
#[methods]
impl MyStruct {
fn new(_owner: &Node) -> Self {
MyStruct {
extra: Property::default()
}
}
fn get_i32(&self, _owner: &Node) -> i32 {
53
}
} fails to compile with: error[E0308]: mismatched types
--> examples\array_export\src\lib.rs:5:10
|
5 | #[derive(NativeClass)]
| ^^^^^^^^^^^ expected `&gdnative::prelude::Node`, found struct `gdnative::prelude::TRef`
|
= note: expected reference `&gdnative::prelude::Node`
found struct `gdnative::prelude::TRef<'_, gdnative::prelude::Node>`
= note: this error originates in the derive macro `NativeClass` (in Nightly builds, run with -Z macro-backtrace for more info) Changing the getter's parameter type But this seems to be a limitation of the builder in general and has existed before this PR, so I don't think we have to fix it here. It could be revisited when addressing #850. |
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.
The logic is more complex than I expected, I think we overlooked a few cases.
As far as I can see, we have now the following combinations (+their mirrored parts).
It looks like T
and Property<T>
are mutually exclusive? Also possible that I'm missing something.
Field type -> | i32 |
Property<i32> |
---|---|---|
#[property] |
✔️ default get/set | ❌ |
#[property(get, set)] (same as above) |
✔️ default get/set | ❌ |
#[property(get)] |
✔️ default get (no set) | ❌ |
#[property(get="path")] |
❌ | ✔ custom get (no set) |
#[property(get="path", set)] |
✔️ custom get, default set | ❌ |
#[property(get="path", set="path")] |
❌ | ✔ custom get + set |
We might also want to think about some smarter testing...
This just to write down my ideas, we can do the full thing outside the scope of this PR, also to unlock the CI for first-contributors. What do you think?
gdnative-derive/src/native_script.rs
Outdated
&& config.get.as_ref().map(|get| !matches!(get, PropertyGet::Default)).unwrap_or(false) | ||
// custom setter used | ||
&& config.set.as_ref().map(|set| !matches!(set, PropertySet::Default)).unwrap_or(false) |
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 two conditions are quite hard to understand, with ref-conversion, map
, negation, and unwrap_or
.
See suggestion comment further below for possible simplification.
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.
And small nitpick, could you leave an empty line between logically related blocks?
For example, empty line before
// #[property] is not attached on `Property<T>`
would make clear that this is a new condition being checked. Same for others.
gdnative-derive/src/native_script.rs
Outdated
"The `#[property]` attribute can only be used on a field of type `Property`, \ | ||
if a path is provided for both get/set method(s)." |
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 realized this error message is ambiguous: "can only be used on field of type Property
if..." can be understood as "you used it on field type Property
but are not allowed so".
See suggestion below for possible error message.
Sorry for the back and forth...
if a path is provided for both get/set method(s)." | ||
)); | ||
} | ||
// if both of them are not set, i.e. `#[property]`. implicitly use both getter/setter |
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.
There is an extra case we should handle here, basically the opposite of the above.
All these (and their symmetric equivalents) should be an error:
#[property]
field: Property<i32>,
#[property(get)]
field: Property<i32>,
#[property(get, set ="path")]
field: Property<i32>,
See suggestion below for possible implementation.
Would you have the time to add this to the tests?
gdnative-derive/src/native_script.rs
Outdated
// #[property] is not attached on `Property<T>` | ||
if property_ty.is_none() | ||
// custom getter used | ||
&& config.get.as_ref().map(|get| !matches!(get, PropertyGet::Default)).unwrap_or(false) | ||
// custom setter used | ||
&& config.set.as_ref().map(|set| !matches!(set, PropertySet::Default)).unwrap_or(false) | ||
{ | ||
return Err(syn::Error::new( | ||
ident.span(), | ||
"The `#[property]` attribute can only be used on a field of type `Property`, \ | ||
if a path is provided for both get/set method(s)." | ||
)); | ||
} | ||
// if both of them are not set, i.e. `#[property]`. implicitly use both getter/setter | ||
let (get, set) = if config.get.is_none() && config.set.is_none() { |
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 what I tried. Test string needs to be adapted too.
I got some error procedural macro API is used outside of a procedural macro
however; not sure why exactly.
// #[property] is not attached on `Property<T>` | |
if property_ty.is_none() | |
// custom getter used | |
&& config.get.as_ref().map(|get| !matches!(get, PropertyGet::Default)).unwrap_or(false) | |
// custom setter used | |
&& config.set.as_ref().map(|set| !matches!(set, PropertySet::Default)).unwrap_or(false) | |
{ | |
return Err(syn::Error::new( | |
ident.span(), | |
"The `#[property]` attribute can only be used on a field of type `Property`, \ | |
if a path is provided for both get/set method(s)." | |
)); | |
} | |
// if both of them are not set, i.e. `#[property]`. implicitly use both getter/setter | |
let (get, set) = if config.get.is_none() && config.set.is_none() { | |
// Attribute is #[property] (or has other arguments which are not relevant here) | |
let is_standalone_attribute = config.get.is_none() && config.set.is_none(); | |
// Attribute is #[property(get)] or #[property(get, set="path")] | |
let has_default_getter = matches!(config.get, Some(PropertyGet::Default)); | |
// Attribute is #[property(set)] or #[property(get="path", set)] | |
let has_default_setter = matches!(config.set, Some(PropertySet::Default)); | |
// Field type is not `Property<T>` | |
if property_ty.is_none() | |
&& !is_standalone_attribute | |
&& (has_default_getter || has_default_setter) | |
{ | |
return Err(syn::Error::new( | |
ident.span(), | |
"A `#[property]` attribute which does not use any of the default `get` and `set` \ | |
arguments -- e.g. #[property], #[property(get)] or #[property](get, set=\"path\") -- \ | |
requires the field type to be `Property<T>`." | |
)); | |
} | |
// Field type is `Property<T>` | |
if property_ty.is_some() | |
&& (is_standalone_attribute || has_default_getter || has_default_setter) | |
{ | |
return Err(syn::Error::new( | |
ident.span(), | |
"The `#[property]` attribute requires explicit paths for `get` and `set` argument; \ | |
the defaults #[property], #[property(get)] and #[property(set)] are not allowed." | |
)); | |
} | |
// if both of them are not set, i.e. `#[property]`. implicitly use both getter/setter | |
let (get, set) = if is_standalone_attribute { |
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 have got the same error message when running test. It occurs when I got proc_macro::TokenStream
in a test function.
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.
It seems like I missed a !
in my logic. For some reason, this doesn't just fail the test, but creates this entirely unrelated error message.
Alternative semantics according to Discord discussion:
As such, the field type might as well be |
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.
Thanks a lot for the great work so far! 🙂
As discussed on Discord, I tried to tweak the logic a bit according to the table. I managed to get the unit tests running by working with proc_macro2::TokenStream
instead of proc_macro1::TokenStream
(using a thin wrapper function). Also added Property
to prelude
and documented it.
Let me know what you think, I think it's soon ready to merge!
bors try
GitHub Actions is down 📉 Will retry tomorrow. |
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.
@Bromeon LGTM!
Just one small suggestion about derive_native_class
(but I think it's also OK to leave it as is), other parts are awesome.
Thanks a lot about the documentation, I really can't explain it so detailed! 👍
gdnative-derive/src/native_script.rs
Outdated
@@ -49,6 +49,10 @@ pub(crate) fn impl_empty_nativeclass(derive_input: &DeriveInput) -> TokenStream2 | |||
} | |||
|
|||
pub(crate) fn derive_native_class(derive_input: &DeriveInput) -> Result<TokenStream, syn::Error> { |
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 wrapper seems to unnecessary?
I found that other function in gdnative_derive
(e.g. methods::derive_methods
, variant::derive_to_variant
, etc.) returns a Result<proc_macro2::TokenStream, syn::Error>
, and convert the result into proc_macro::TokenStream
in lib.rs
.
I think it might be more consistent if native_script::derive_native_class
also returns proc_macro2::TokenStream
?
4a03dc1
to
44bb3f9
Compare
Very good point, I removed the wrapper. bors r+ |
841: Add `get`/`set` to `property` attribute to specify custom getter/setter r=Bromeon a=Bogay Fix #547 Co-authored-by: bogay <[email protected]> Co-authored-by: Jan Haller <[email protected]>
tryTimed out. |
Build failed: |
Changes: * Logic that allows/disallows property <-> field type combinations * Unit tests * Create internal proc_macro2 derive that can be used in tests * Add Property to prelude * Extensive documentation for Property
44bb3f9
to
768020f
Compare
MSRV 1.51 with its array iteration is a sneaky one. Glad they fixed this in more recent Rust... bors r+ |
Build succeeded: |
Fix #547