Skip to content

Add PhantomVar<T> to support properties without a backing field#1261

Merged
Bromeon merged 1 commit intogodot-rust:masterfrom
ttencate:feature/synthetic_properties
Aug 7, 2025
Merged

Add PhantomVar<T> to support properties without a backing field#1261
Bromeon merged 1 commit intogodot-rust:masterfrom
ttencate:feature/synthetic_properties

Conversation

@ttencate
Copy link
Copy Markdown
Contributor

@ttencate ttencate commented Aug 5, 2025

Closes #1151.

@GodotRust
Copy link
Copy Markdown

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

@ttencate ttencate force-pushed the feature/synthetic_properties branch from 96fa239 to 59e5b32 Compare August 5, 2025 14:32
@Bromeon Bromeon added feature Adds functionality to the library c: register Register classes, functions and other symbols to GDScript labels Aug 5, 2025
Copy link
Copy Markdown
Member

@Bromeon Bromeon left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fantastic work! Solid implementation, great docs and examples, great tests.
Thanks a lot! 💪

Comment on lines +67 to +84
// `PhantomVar` does not _really_ implement `Var`, but it has to, otherwise we cannot implement `Export` either.
// The `GodotClass` derive macro should ensure that the `Var` implementation is not used.
impl<T> Var for PhantomVar<T>
where
T: GodotType + Var,
{
fn get_property(&self) -> Self::Via {
unreachable!("PhantomVar::get_property should never have been called")
}

fn set_property(&mut self, _value: Self::Via) {
unreachable!("PhantomVar::set_property should never have been called");
}

fn var_hint() -> PropertyHintInfo {
<T as Var>::var_hint()
}
}
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The var_hint part is used though, no? Just get/set_property aren't useful.
So, part of Var is implemented 🤔

Also, "PhantomVar::set_property should never have been called" is somewhat implied by unreachable!... you can maybe just use unreachable!() or add some extra information, like "property getters/setters should be directly called by proc-macro, not through Var".

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Updated comment and strings.

Comment on lines +100 to +142
impl<T> Default for PhantomVar<T> {
fn default() -> Self {
Self(Default::default())
}
}

impl<T> Clone for PhantomVar<T> {
fn clone(&self) -> Self {
*self
}
}

impl<T> Copy for PhantomVar<T> {}

impl<T> fmt::Debug for PhantomVar<T> {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
f.debug_tuple("PhantomVar").finish()
}
}

impl<T> PartialEq for PhantomVar<T> {
fn eq(&self, _other: &Self) -> bool {
true
}
}

impl<T> Eq for PhantomVar<T> {}

impl<T> PartialOrd for PhantomVar<T> {
fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
Some(self.cmp(other))
}
}

impl<T> Ord for PhantomVar<T> {
fn cmp(&self, _other: &Self) -> Ordering {
Ordering::Equal
}
}

impl<T> Hash for PhantomVar<T> {
fn hash<H: std::hash::Hasher>(&self, _state: &mut H) {}
}
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can any of these be derived, or does it cause problems due to the generic parameter?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Exactly. The stdlib's derive macros always add a bound T: TraitWeAreDeriving even if it's not strictly needed. This is why crates like derive_where exist.

Comment on lines +754 to +779
if let Some(field_var) = &mut field.var {
if field_var.getter.is_generated() {
errors.push(error!(
field_var.span,
"PhantomVar<T> stores no data, so it cannot use an autogenerated getter"
));
}
if field_var.setter.is_generated() {
errors.push(error!(
field_var.span,
"PhantomVar<T> stores no data, so it cannot use an autogenerated setter"
));
}

if field_var.getter.is_omitted() && field_var.setter.is_omitted() {
errors.push(error!(
field_var.span,
"PhantomVar<T> is useless without a custom getter, setter, or both"
));
}
} else {
errors.push(error!(
field.span,
"PhantomVar<T> field is useless without attribute #[var]"
));
}
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe extract into a function, e.g. validate_phantomvar(...)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

OK but then I'll also extract validate_base_field for the code right above this.

Comment on lines +353 to +359
# This test is commented out, because Godot seems to allow reading a write-only property, and simply returns null.
# func test_phantom_var_reading_write_only():
# # This must be untyped, otherwise the parser complains about our invalid read.
# var obj = HasPhantomVar.new()
# expect_fail()
# var x = obj.write_only
# assert_fail("HasPhantomVar.write_only should not be readable")
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do you believe this behavior will change in the future?

If not, we should maybe rather test the current behavior, and document why we can't have write-only. If semantics on Godot's side ever change in that regard, at least we'll notice...

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Couldn't we implement a custom getter that panics instead when no_get is specified?

Copy link
Copy Markdown
Contributor

@Yarwin Yarwin Aug 6, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Couldn't we implement a custom getter that panics instead when no_get is specified?

as far as I'm aware the editor must be able to read given property (in case of #[export]), so it can't really panic.

Copy link
Copy Markdown
Contributor

@sylbeth sylbeth Aug 6, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Couldn't we return default values for a type? Is that even more unsound? Right now you get null anyway, so what would be wrong in doing push_error + return null / default value if type is non-nullable?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Wait, how would the editor even read a write only property? Hm, I suppose that would push an error every time the node's inspector is open? We can make it push_warning instead, or disable it when #[var(no_get, set)]#[export]. I would prefer push error if no editor, push warning if editor and add a bit of "Ignore this message if you haven't used the getter from code, the editor is trying to fetch the new value" or something.

Copy link
Copy Markdown
Contributor Author

@ttencate ttencate Aug 6, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I can think of two use cases for write-only properties:

  • Setting a password, which is immediately hashed so it can never be recovered.
  • Setting a seed on a random number generator, which becomes meaningless after changing the RNG state. Godot's RandomNumberGenerator actually allows you to read the seed back again but has some warnings in the docs about it.

Both use cases are better served by methods, I think.

As for #[export], it also has the effect of serializing the value, which can never work if the editor can't read it back. So exported write-only properties are also a bit of a contradiction.

Let me just make write-only PhantomVar properties an error for now.

(None of this is specific to PhantomVar by the way, but I don't want to break backwards compatibility yet!)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do I open an issue for write-only properties and their soundness? And there still has to be the rework on [no_get, get, no_set, set] done, right?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If you have a concrete use case for write-only properties, you could open an issue to investigate. I personally don't think it's worth it, but don't let that stop you 🙂

The compatibility-breaking changes with no_get, no_set etc. should be a separate issue for sure.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Two different issues then?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍

mod instance_id;
mod on_editor;
mod on_ready;
mod phantom_var;
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this should be added to the godot::register::property module instead.

Maybe we need to move OnReady/OnEditor as well in the future... 🤔 modules generally need a bit of cleanup at some point.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

To me it makes most sense for PhantomVar to be next to OnReady and OnEditor, wherever those are. I'd be happy to move all three of them in a follow-up PR.

Copy link
Copy Markdown
Member

@Bromeon Bromeon Aug 6, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's just that we can't change modules for patch releases (at least not without deprecation), and if we already know now that godot::obj is the "wrong" place, it just means one extra breaking change for users that we could easily avoid...

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fair enough, I thought these modules were all internal. I'd expect that most usage is through the prelude, but breaking is breaking. Moved to godot-core/src/registry/property.rs instead.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For the record, I never use prelude, don't like them, I prefer being explicit with imports and I believe rust-analyzer finds their definition rather than re-exports, so yeah, this would indeed be a breaking change for all my godot-rust code (although an easy one to fix).

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I always use the godot prelude, as I can't generally be bothered to micromanage modules. It's also a pain for merge conflicts and the like, without adding real value.

That said, we should have a somewhat structured module hierarchy for people who do want it -- and let's not forget that lots of symbols aren't in the prelude (especially godot::classes::*). However, if someone opts in to use fine-grained modules rather than prelude, they readily accept the risk of more micromanaging. So it's OK to break module paths as long as we respect SemVer -- i.e. remove across minor versions, or deprecate across patch versions.

{
}

// ----------------------------------------------------------------------------------------------------------------------------------------------
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's now 200 LoC, I think it deserves its own file phantom_var.rs? I should have mentioned this (sorry!), I just think it's easier to navigate to it in the IDE, especially with the doctests "polluting" a considerable part.

Copy link
Copy Markdown
Contributor Author

@ttencate ttencate Aug 6, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sure, I would prefer that too.

Would you want the module private with a pub use crate::registry::phantom_var::PhantomVar in property.rs? This is a bit weird because it's a sibling module though. I could also make property.rs into property/mod.rs and put property/phantom_var.rs next to it.

Or just expose the phantom_var module directly?

Copy link
Copy Markdown
Member

@Bromeon Bromeon Aug 6, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It can also just be a sister module in https://github.com/godot-rust/gdext/tree/master/godot-core/src/registry. I need to clean up a bit in v0.4 anyway, but if they are already separate files, it's going to be simpler 🙂

Or the mod.rs approach is also fine -- I hope Git picks up the rename.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

OK, I hope I got it right. The public name is now godot::register::property::PhantomVar.

@Bromeon
Copy link
Copy Markdown
Member

Bromeon commented Aug 6, 2025

Good from my side, you can squash. Maybe @Yarwin has more comments, otherwise we can merge 🙂

@ttencate ttencate force-pushed the feature/synthetic_properties branch from b9a8288 to e9c26d6 Compare August 6, 2025 17:50
@ttencate ttencate force-pushed the feature/synthetic_properties branch from e9c26d6 to 3fa3eb1 Compare August 7, 2025 12:23
@Bromeon Bromeon added this pull request to the merge queue Aug 7, 2025
Merged via the queue into godot-rust:master with commit 1606a7f Aug 7, 2025
18 checks passed
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.

ZST type for synthetic properties

5 participants