Skip to content
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

[proposal] add 'notify' to '#[var]' #946

Draft
wants to merge 8 commits into
base: master
Choose a base branch
from

Conversation

0x53A
Copy link
Contributor

@0x53A 0x53A commented Nov 7, 2024

I've noticed that I have the following pattern, where I need to do some action after one of many vars has been changed:

#[derive(GodotClass)]
#[class(base=Node2D, init, tool)]
pub struct HealthBar {

    #[var(get, set = set_filled)]
    #[export(range = (0.0, 1.0))]
    filled: f32,

    #[init(val = Color::GREEN_YELLOW)]
    #[var(get, set = set_outline_color)]
    #[export]
    outline_color: Color,

    #[init(val = Color::GREEN)]
    #[var(get, set = set_fill_color)]
    #[export]
    fill_color: Color,

    base: Base<Node2D>
}

#[godot_api]
impl HealthBar {
    
    #[func]
    pub fn set_filled(&mut self, new_value: f32) {
        if self.filled != new_value {
            self.filled = new_value;
            self.base_mut().queue_redraw();
        }
    }
    
    #[func]
    pub fn set_outline_color(&mut self, new_value: Color) {
        if self.outline_color != new_value {
            self.outline_color = new_value;
            self.base_mut().queue_redraw();
        }
    }
    
    #[func]
    pub fn set_fill_color(&mut self, new_value: Color) {
        if self.fill_color != new_value {
            self.fill_color = new_value;
            self.base_mut().queue_redraw();
        }
    }
}

That's ... quite verbose.

Instead I'd like to propose the following:

#[derive(GodotClass)]
#[class(base=Node2D, init, tool)]
pub struct HealthBar {

    #[var(get, set, notify = redraw)]
    #[export(range = (0.0, 1.0))]
    filled: f32,

    #[init(val = Color::GREEN_YELLOW)]
    #[var(get, set, notify = redraw)]
    #[export]
    outline_color: Color,
    
    #[init(val = Color::GREEN)]
    #[var(get, set, notify = redraw)]
    #[export]
    fill_color: Color,

    #[var(get, set, notify = redraw)]
    width: f32,
    
    #[var(get, set, notify = redraw)]
    height: f32,

    base: Base<Node2D>
}

#[godot_api]
impl HealthBar {
    
    fn redraw(&mut self) {
        self.base_mut().queue_redraw();
    }
}

Here notify is a new option. If it is set, the autogenerated setter looks like this:

    pub fn set_a(&mut self, a: <i32 as ::godot::meta::GodotConvert>::Via) {
        let prev_value = self.a;
        <i32 as ::godot::register::property::Var>::set_property(&mut self.a, a);
        if prev_value != self.a {
            self.on_change();
        }
    }

This currently requires that the type of the property implements Eq. Another option would be to unconditionally call the notify fn.


Is this something that makes sense to include in gdext for you? I'm totally fine if you say no and close this PR.

If yes, what do you think about the design, w.r.t. naming and equality?

@0x53A 0x53A marked this pull request as draft November 7, 2024 01:42
@GodotRust
Copy link

API docs are being generated and will be shortly available at: https://godot-rust.github.io/docs/gdext/pr-946

@Bromeon
Copy link
Member

Bromeon commented Nov 7, 2024

There are different ways how I could imagine users to benefit from a notify function, for example:

  • a callback invoked on change (like you propose)
  • a callback invoked on setting (not sure if common, maybe for things like passwords?)
  • a callback invoked when an input operation is complete (press OK button rather than type a new character)

Anecdote: there was an after_set property in gdnative, see e.g. godot-rust/gdnative#715. However it was removed at some point in favor of an actual set, and no one complained 🙂


There's also the question, should the notify callback receive information about a) name of the field, b) type of the value, c) the value itself? Probably not all of them (since that defeats genericity over fields), but I could imagine that someone having 10 bool fields would like to know which one was toggled.

Can we not achieve the same by providing a more flexible callback for set itself?

#[derive(GodotClass)]
#[class(base=Node2D, init, tool)]
pub struct HealthBar {
    #[var(get, set = custom_set)]
    width: f32,

    #[var(get, set = custom_set)]
    height: f32,
}

impl HealthBar {
    // old way:
    fn hardcoded_set(value: f32) {...}

    // new possible way:
    fn custom_set<T>(value: PropertyUpdate<T>) {
        // may have meta-info about property name, variant etc
    }
}

I'm not sure if set = itself could be made compatible with something like this (through conversion traits maybe), but otherwise rich_set = or so?

The advantage of such a mechanism as opposed to notify is that it's not limited to one specific use case, but can be generalized to any sort of update logic, with various degrees of input requirements.

@Bromeon Bromeon added feature Adds functionality to the library c: register Register classes, functions and other symbols to GDScript labels Nov 7, 2024
@0x53A
Copy link
Contributor Author

0x53A commented Nov 8, 2024

That's some good points.

If you have a generalized set as opposed to an after_set, that would imply that actually updating the backing field would still be the obligation of your method. Since it a) should be applicable to multiple fields of b) potentially different types, the only way I could envision this is if the backing field is referenced in PropertyUpdate.

struct PropertyUpdate {
    backing_field: &mut T,
    new_value: T,
    field_name: String
}

then user-code could look like this:

    fn custom_set<T>(value: PropertyUpdate<T>) {
        // pre-set checks

        // actually write to backing field
        *value.backing_field = value.new_value;

        // post-set actions
        if value.field_name == "a" || value.field_name == "b" { /* ... */ }
    }

That's pseudo code written outside the editor, I'm not sure how to handle that with regard to lifetimes etc; especially since you very likely want custom_set to also receive &mut self, and I believe that might cause issues with aliasing, as you can reference the field either through self or through value.backing_field.

An after_set would be much easier,

struct PropertyUpdate {
    prev_value: T,
    new_value: T,
    field_name: String
}

    fn custom_set<T>(&mut self, value: PropertyUpdate<T>) {
        if value.field_name == "a" || value.field_name == "b" { /* ... */ }
    }

I'll see if I can spend some time of my life learning more about rust lifetimes

@Bromeon
Copy link
Member

Bromeon commented Nov 8, 2024

Yes, you're correct that referencing &'a mut T in the struct (where 'a is the lifetime) and simultaneously calling custom_set(&mut self) would violate aliasing rules, since it would allow the function to obtain two exclusive (mut) references to the field.

However, it's possible that PropertyUpdate instead stores the function updating the field (aka the default setter), in the form of fn(&mut C, T) or Box<dyn Fn(&mut C, T)> to allow closures.

Or, even more flexible, it could return the exclusive reference to the field, given an exclusive reference to the struct:

 // C = surrounding class, T = field type
struct PropertyUpdate<'a, C, T> {
    new_value: T,
    field_name: &'a str, // might also be &'a StringName, depending on what's available
    get_field_mut: fn(&mut C) -> &mut T,
}

impl<'a, C, T> PropertyUpdate<'a, C, T> {
    // Slightly more convenient access + encapsulation.
    pub fn default_set(&self, this: &mut C) {
        let field: &mut T = (self.get_field_mut)(this);
        *field = self.new_value;
        // TODO see if we need to move out, or can have &T reference...
    }

    // For cases where someone modifies the input first (e.g. clamp to a range).
    pub fn set(&self, this: &mut C, new_value: T) {
        let field: &mut T = (self.get_field_mut)(this);
        *field = new_value;
    }
}

And then:

    fn custom_set<T>(&mut self, update: PropertyUpdate<T>) {
        // pre-set checks

        // actually write to backing field
        update.default_set(self);

        // alternatively, if someone changes T first
        let corrected_value = clamp(update.value());
        update.set(self, corrected_value);

        // post-set actions
        ...
    }

@Bromeon
Copy link
Member

Bromeon commented Nov 8, 2024

An after_set would be much easier,

I'd argue that equally common as "notify on changed" (after-set) logic is "validate before update" (before-set) or "adjust before update" (like bringing a value into a valid range).

Having a dedicated callback for each possible workflow may quickly get out of hand. And if someone has a slightly different requirement (e.g. take not just input, but also previous value into account), then the user is again stuck 🙂 so a more general design may be more powerful, even if it means a bit more code for defining such a "rich setter" once.

@Dheatly23
Copy link
Contributor

With your example it can be done like so:

impl HealthBar {
    /// Sets attribute, then queue rerender.
    fn set_then_queue_rerender<T: PartialEq>(
        &mut self,
        t: T,
        f: impl FnOnce(&mut Self) -> &mut T,
    ) {
        let p = f(self);
        if *p != t {
            *p = t;
            self.base_mut().queue_rerender();
        }
    }
}

#[godot_api]
impl HealthBar {
    // Example
    #[func]
    pub fn set_filled(&mut self, new_value: f32) {
        self.set_then_queue_rerender(new_value, |v| &mut v.filled);
    }
}

Not the cleanest, but it does abstract after set behavior.

I'm also curious with custom property. For example, in my code there are "phantom" properties (returns zero value and ignore setter) where it's custom getter/setter is pretty expensive (serialization/deserialization with caching). Will it call getter for every setter?

@Bromeon
Copy link
Member

Bromeon commented Nov 23, 2024

@0x53A What do you think about my comment? 🙂

It looks like you added a commit implementing something like this, could you elaborate a bit?

@0x53A
Copy link
Contributor Author

0x53A commented Nov 23, 2024

yep it's basically what it looks like - after you demonstrated how to implement it using a function to return the exclusive reference to the field, implementing it was easy.
I just kinda forgot to add a comment here after i pushed the commit.

So now the bikeshedding can begin.

(Quick note, I'll clean up the implementation next week.)

I believe we are in agreement that something like custom_set, (named set_ex as a placeholder in the implementation), shall be added

set and set_ex are exclusive, you can obviously only have one.

set can be either of the form set or set=f. set_ex can only be of the form set_ex=f.


Question 1, what should be the user-facing attribute name? #[var(??? = f)]?

I actually kinda like set_ex, and the _ex suffix has precedent.


Question 2, what should be the signature of the user-defined callback function f?

A, a single struct with both a reference to the field and the new value

// defined by gdext
pub struct PropertyUpdate<'a, C, T> {
    pub new_value: T,
    pub field_name: &'a str,
    pub get_field_mut: fn(&mut C) -> &mut T,
}

impl<C, T> PropertyUpdate<'_, C, T> {
    pub fn set(self, obj: &mut C) {
        *(self.get_field_mut)(obj) = self.new_value;
    }
    pub fn set_custom(self, obj: &mut C, value: T) {
        *(self.get_field_mut)(obj) = value;
    }
}

// ------------------

// defined by the user

    fn custom_set<T>(&mut self, update: PropertyUpdate<Self, T>) {
        // pre-set checks

        update.set(self);
        // or, alternatively ...
        // update.set_custom(some_other_value);

        // post-set actions
    }

B, a struct with just the reference to the field, and a second argument for the new value

// defined by gdext
pub struct PropertyRef<'a, C, T> {
    pub field_name: &'a str,
    pub get_field_mut: fn(&mut C) -> &mut T,
}

impl<C, T> PropertyRef<'_, C, T> {
    pub fn set(self, obj: &mut C, new_value: T) {
        *(self.get_field_mut)(obj) = new_value;
    }
}

// ------------------

// defined by the user

    fn custom_set<T>(&mut self, field_ref:PropertyRef<Self, T>, new_value: T) {
        // pre-set checks

        field_ref.set(self, new_value);

        // post-set actions
    }

I very slightly prefer B, because it does not distinguish between setting the value as passed from godot, or setting a custom value; but I could also count that exact same argument as pro A, because maybe you do want to emphasize whether you're just passing through the value, or modifying it inside the setter.


Question 3, should only set_ex be implemented, or both set_ex to allow implementing arbitrary actions, and notify (to be renamed) to also provide an easier to use shortcut? Because I can immediately see myself making the mistake of implementing set_ex and forgetting to actually set the value.

I'd prefer b, "both", but I can see your argument, "why stop at two".

@Bromeon
Copy link
Member

Bromeon commented Nov 23, 2024

Question 1, what should be the user-facing attribute name? #[var(??? = f)]?

I actually kinda like set_ex, and the _ex suffix has precedent.

_ex has precedent but in an entirely different context: as builders for default parameters. For this reason I would not use the same suffix here, as it creates an association that doesn't exist. Godot made this mistake in several places ("exporting", "singleton", ...) which keeps creating confusion to this day.

Not sure if I have a great name though. Some thoughts:

  • *_set is probably better than set_* because it emphasizes that this is a different version of set, rather than setting something.
  • I was thinking of shared_set but don't like that this term is mostly associated with memory management. Also, it doesn't have to be shared, it's perfectly fine to use just for one.
  • It's hard to find a word that expresses both "the set function is more general/takes more input" and "multiple vars can refer to the same set function". Alongside concept of general(ized), common, unified, reusable, flexible, versatile, adaptive, polymorphic, ...
  • Maybe simply general_set or so?

I'll write a separate answer about the rest, as it's going to be a bit involved...

@Bromeon
Copy link
Member

Bromeon commented Nov 23, 2024

Question 2, what should be the signature of the user-defined callback function f?

A, a single struct with both a reference to the field and the new value
B, a struct with just the reference to the field, and a second argument for the new value

Hm, that is a harder problem than I initially assumed 😅

My first instinct was to tend towards (A), because it's easier to get one parameter to have the right type than two:

fn custom_set<T>(&mut self, field_ref: PropertyRef<Self, Vector2>, new_value: Vector2i) 
// During refactor, updated one type but not the other -> maybe weird compile error?
// Also less repetition + more integrated functionality in PropertyUpdate.

However, there's one problem: T is passed by value. That means if we want to extract the new_value: T from PropertyUpdate and assign it to the field, we need to either clone or consume the update object (take self).

  • Cloning isn't ideal for ref-counted types -- I wouldn't want this general_set generalization to punish performance.
  • Consuming would mean a user can no longer access the other PropertyUpdate fields, such as get_field_mut. They would need to be extracted first.

It is technically possible to work with public fields and encourage destructuring:

fn custom_set<T>(&mut self, update: PropertyUpdate<Self, T>) {
    let PropertyUpdate { new_value, get_field_mut, .. } = update;
    let new_value = adjust(new_value);
    *get_field_mut(self) = new_value;
}

// or:

fn custom_set<T>(&mut self, update: PropertyUpdate<Self, T>) {
    update.set_with(|new_value| adjust(new_value));
    // set_with takes self, i.e. update is no longer accessible after here.
}

Comparison of (A) and (B)

In summary, the trade-offs are:

(B)

  • ✔️ a bit simpler
  • ✔️ data flow from parameter into update-object is clearer
  • ❌ repetition of T type

Possible API:

// assuming no public fields
impl PropertyRef<'a, C, T> {
    fn set(&mut self, class: &mut C, new_value: T);
    fn get(&self, class: &C) -> T;
    fn field_name(&self) -> &'a str;
}

(A)

  • ✔️ can provide several common patterns (set-unchanged, set-with-map, set-custom) directly on the update-object
  • ✔️ allows consuming self of update-object to express "the value has been written", meaning it prevents accidental 2nd access to the field by design
  • ❌ needs destructuring (or getter calls for individual fields) in more complex cases

Possible API:

// assuming no public fields
impl PropertyRef<'a, C, T> {
    fn set(self, class: &mut C, different_value: T);
    fn set_unchanged(self, class: &mut C);
    fn set_with(self, class: &mut C, update: impl FnOnce(T) -> T);
    fn field_name(&self) -> &'a str;
}

// however forcing user code into closures can be annoying/limiting...

Other aspects

One thing we haven't considered yet: in the future I'd like to provide a ZST dummy field like gdnative's Property, which would allow uses to not actually need memory for fields if they store the value elsewhere (e.g. in a HashMap...). Although I think this would still work with both approaches -- get_field_mut would just return a &mut ZstType which is useless but not problematic.

I'm not sure how I feel about public fields; it takes away our freedoms of changing implementation, optimizing things, etc. At least the struct should be #[non_exhaustive] to allow field additions, but maybe getters are better. Which would mean we can't use destructuring in case of (A).


Conclusion

I don't see one approach (A) or (B) that is clearly superior, both have their trade-offs.

But (B) feels slightly simpler as in "less overengineered", and at the same time more flexible. It comes at a cost of a bit more verbosity (repetition of T, no "set-unchanged"), but maybe that's OK if someone already opts-in for #[var(general_set)]?

Opinions?

@0x53A
Copy link
Contributor Author

0x53A commented Nov 23, 2024

About the naming, it might be possible to overload set through some terrible hacks, by generating the code for both cases and then letting the compiler/linker remove the dead code, I'll experiment with that over the next days. (That hack can then get removed if/when we get proper compile time introspection.)


Unrelated to this issue, would you be to open to me changing the way set=f works?

Currently the name of f is exported to godot, and if the user forgot to add #[func] (or used rename), it'll just error at runtime (I believe).

I propose the following:

The setter is always generated, with the Godot name set_{fieldname}. The user should not add #[func] to f.

That is, for the user code

pub struct MyNode {
    #[var(get, set=set_foo)]
    foo: bool,
}

impl MyNode {
    fn set_foo(&mut self, new_value: bool) { /* */ }
}

The compiler would generate:

impl MyNode {
    #[doc(hidden)]
    #[func(rename=set_foo)]
    fn __internal_set_foo(&mut self, new_value: bool) {
        self.set_foo(new_value);
    }

That would also solve #863


Yes, this is a big breaking change, but I believe it is straight better, and it's still early enough to do it.

@Bromeon
Copy link
Member

Bromeon commented Nov 23, 2024

About the naming, it might be possible to overload set through some terrible hacks, by generating the code for both cases and then letting the compiler/linker remove the dead code, I'll experiment with that over the next days. (That hack can then get removed if/when we get proper compile time introspection.)

I found that adding complexity and brittleness for tiny naming tweaks is rarely worth it -- we tend to overestimate how big of an impact these things have. Especially as long as things are consistent.

In this particular case, having distinct names can even be good for documentation purposes -- one immediately sees that the receiving end isn't a regular setter, but a generalized one. Also, I'd rather have bad naming than extra complexity 😉


Currently the name of f is exported to godot, and if the user forgot to add #[func] (or used rename), it'll just error at runtime (I believe).

You're right, it's a runtime error in Godot:

ERROR: Invalid setter 'Class::set_thing' for property 'thing'.

Unrelated to this issue, would you be to open to me changing the way set=f works?

Interesting idea! One issue is that now we taught users to put #[func] on their custom getter/setter. If the library now registers an additional method with the same method in Godot, we have a conflict.

That would also solve #863

It solves it by having a second function that's registered with a different name, thus exposing two identical APIs to Godot. I'm not sure if this is intended -- as a user, if I rename my setter, I'd expect to have it renamed and not copied. This would also pollute Godot docs, for example 🤔

So I think both #863 and avoiding runtime errors (or moving them to compile time) are worthwhile goals, but we should maybe iterate a bit on the way we solve them 🙂


Edit: see my comment in #863 (comment) for a potential idea. We can gladly continue discussing there, as I think the two problems are closely related.

@ValorZard
Copy link

I feel like the easiest way to deal with the name problem is to just call it set_callback Sounds good enough to me, since your adding extra logic on top of setting it

it can only be used in conjunction with an autogenerated setter.
the callback_fn is a 'fn(&[mut ]self) { }', which will be called whenever the backing value of the var changes.
@Bromeon
Copy link
Member

Bromeon commented Dec 22, 2024

I rebased again, but I haven't received any answer to my comment from 1 month ago, although there have been some commits. It's a bit unclear to me if you still plan to pursue this?

If yes, some guidelines:

  • Once you think it's ready for review, please move it out of draft state. At that point, CI should pass.
  • Make sure you use sentence capitalization for comments, i.e. start with uppercase and end with fullstop (with rare exceptions, in case of just 1-2 keywords).
  • When ready, please squash commits -- one per logical change. Often, this can mean just 1 commit in the PR.

If you no longer have time/interest, that's totally fine as well -- but please communicate it, so a decision can be made about moving forward, and someone else can potentially pick up the work. Thanks! 😊

@0x53A
Copy link
Contributor Author

0x53A commented Dec 23, 2024

Hey sorry for the radio silence, I was planning on continuing on it but ended up doing other things.

It’s not yet in a state to be merged, so yes it’s still draft on purpose. First step was/is to define the goal. I also wanted to first solve #863.

I plan on taking another stab at this early next year; if you (or someone else) want to work on it before then I wouldn’t take it personally though.

@Bromeon
Copy link
Member

Bromeon commented Dec 23, 2024

Thanks for the update! I'm currently reworking functions/signals a bit, so there's a chance that #863 becomes easier with the new system. So let's maybe catch up in January regarding this. Happy holidays 🙂

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
c: register Register classes, functions and other symbols to GDScript feature Adds functionality to the library
Projects
None yet
Development

Successfully merging this pull request may close these issues.

5 participants