Idea: Properties

I'd like to propose to add properties to the language. When invoked, they look like regular fields. They're similar to Kotlin properties and Javascript getters/setters.

They come in two flavors:

1. Automatic implementation, a.k.a. delegation

pub struct Point {
    x: f32,
    y: f32,
}

pub struct Square {
    left_top: Point,
    size: f32,
}

pub trait XY {
    x: f32;
    y: f32;
}

impl XY for Point {
    x;
    y;
}

impl XY for Square {
    x = self.left_top.x;
    y = self.left_top.y;
}

The expressions that are assigned to x and y must be allowed on the left-hand side of an assignment. They may include fields, properties and indexing expressions.

If this form of delegation is added to the language, it would make sense to also allow delegating functions. But that's not my main priority.

2. Manual implementation

pub trait Diag {
    diagonal {
        get(&self) -> f32;
        set(&mut self, new: f32);
    }
}

impl Diag for Square {
    diagonal {
        get(&self) -> f32 {
            self.size * std::f32::consts::LN_2
        }
        set(&mut self, new: f32) {
            self.size = new / std::f32::consts::LN_2;
        }
    }
}

get and set are context-sensitive keywords. Their functions are invoked implicitly when accessing or assigning to a property. You don't have to specify both; a property can be read-only or write-only.

Properties work for owned values as well as borrowed values. For example, we can write

pub trait Foo {
    bar {
        get(self) -> Bar;
        get(&self) -> &Bar;
    }
}

When bar is accessed, the first getter is preferred. The second one is only used if the first isn't applicable:

fn test(foo: impl Foo) {
    let _: Bar = foo.bar;
    let _: &Bar = (&foo).bar;
}

A property can have a getter accepting &self or &mut self, but not both. If you need both, use two properties, e.g. bar and bar_mut.

Properties without a trait

Properties can appear in an impl block:

impl Point {
    inv {
        pub get(&self) -> Point {
            Point { x: -self.x, y: -self.y }
        }
    }
}

Although invoking a property looks like a field, properties are very much like methods. For example, there can be a property with the same name as a field.

In this case, the field has precedence over the property. To invoke the property, you need the fully-qualified syntax, e.g. XY::inv::get(point).

Usage

When a getter is present and visible, a property can be accessed like a field with the dot syntax. When a setter is present and visible, it can be assigned a value. The assignment operators +=, *=, %=, &=, >>=, etc. require both a getter and a setter.

Unresolved questions

How to get a getter or setter of a type or trait? E.g.

point.x === XY::x::get(point)
foo(|p| p.x) === foo(XY::x::get)

These are ambiguous if there's more than one getter, but I don't have a better idea ATM.


Maybe it's better to allow at most 1 getter and 1 setter?


Using properties should feel like using a field. Ideally it should be possible to replace public fields with properties in a backwards-compatible way. However, I'm afraid that's not possible because of how fields of borrowed values behave. Also, properties can not be used when pattern-matching or destructuring.


I'm not entirely satisfied with the syntax.


A different syntax for delegation was proposed a few weeks ago, I'm not sure which is better:

impl XY for Point {
    x = self.left_top.x;
    y = self.left_top.y;
}

impl XY for Point {
    use self.left_top::{x, y};
}

I'm totally open for suggestions!

2 Likes

So what happens when you have both a getter and the field accessible (especially if they have different types)?

As for reference-ness of the return: foo.bar should be a place (i.e. a value) that you take a reference to with &. It would work the way accessing fields through a Box<T> works.

The big problem with field getters is that they cannot, by definition, support partial deconstruction, as the method(s) they are sugar from take the whole object. This makes public field <-> property necessarily not a transparent change.

Because this cannot be a transparent change, properties on their own aren't worth much. And place calculation is expected to be "free" (modulo Deref) in today's Rust, so being able to hide methods behind field syntax ("just" removing the ()) is potentially scary.

However, using properties to give us data-in-traits has potential. I suspect that a proposal would be easier to push through if it enforced purity of the accessor (i.e. just a field access (modulo Deref)).

2 Likes

I guess it would have to default to the field, because the fully-qualified syntax (e.g. <Point as XY>::x::get(point)) doesn't work with fields.

One potential benefit could be the ability to use computed properties in patterns.

For example, consider proc_macro::Group, which is embedded in proc_macro::TokenTree. It would be very nice to be able to write something like

let tt: TokenTree = /* ... */;
match tt {
    TokenTree::Group(Group {
        delimiter: Delimiter::Parenthesis,
        stream,
        ..
    }) => {
        handle_parens(stream);
    },
    // ...
}

But we can't, because Group exposes delimiter and stream as getters, not public fields. This could be fixed if we could add computed properties to Group.

1 Like

I really like the idea of this. But I do have to mention what was mentioned the last time I thought of this.

A reason why it might not be a good idea is the question of how to deal with failure? In c# it is possible to throw an error if the value is invalid. However, in Rust that is not really feasible.

So that would need to be taken into consideration. I do like abstraction though.

The problem with that is, again, one of purity. Patterns should not be running user code except in if guards.

So what I'm getting from this is that properties really want to be more like "controlled field access" than full on computed properties. So C#'s automatic properties moreso than full computed properties. In effect, read-public fields without being write-public.

For fun, that match using if guards:
match tt {
    TokenTree::Group(tt) if tt.delimiter() == Delimiter::Parenthesis => {
        handle_parens(tt.stream());
    },
    // ...
}
3 Likes

That's problematic, because if the property takes ownership of the Group, you can destructure only this one property. And if it borrows the Group, you must make sure that the property can outlive the Group.

I'd like to express my strong objection to, and discomfort about, this proposal. To me, the absence of innocent, field-access-like magic properties that run arbitrary code was one of the selling points of Rust. I'd very much like to see when any computation is happening – not primarily from a performance point of view, rather from one of correctness and safety.

These kind of property getters/setters make local reasoning harder because they introduce context-dependence, breaking readers' mental parsers. It's not nice when I constantly need to jump to the definition of a "field" just in order to find out whether it really is a field or a property. This has been a source of constant annoyance for me in languages with properties, eg. C# and Objective-C.

Properties also open up problems with style as well as more practical issues. For instance, how does one decide whether a computation should be implemented as a property or as a regular method? The language bifurcates here, and there's now yet another thing in the already long list of Very Important Things To Argue About Over A Pull Request Fixing A Critical VulnerabilityTM.

A more practical issue is that the result of an arbitrary computation is not an lvalue. Taking the address of a field-looking property would therefore take the address of a temporary, so it absolutely wouldn't do what taking the address of a field is expected to do. This is a proper nightmare in unsafe code. One could disallow taking the address of a property, although that would make them even more special and reduce their usefulness further (because sometimes the address of a temporary is what we want).

Overall, I think introducing properties would have very little gain (in particular, some convenience advantages) for a price too high to pay.


I could imagine extending visibility modifiers to support publicly read-only and privately writable fields, which wouldn't, hopefully, share these problems. Although I still don't think they're a significant and orthogonal enough feature to warrant a separate language construct. This problem too can be resolved cleanly and trivially by using functions – why not just keep doing that?

35 Likes

I'd like to separately respond to the two different proposals here.

The first proposal feels quite similar to "trait fields", which has come up several times before. Trait fields just map a named field from a trait to a specific field of an object implementing the trait; they always have to map to a named field, and just act like another name for that field. You can also take a reference or mutable reference to a trait field, and that will end up as a reference or mutable reference to the underlying field of the object as defined in the impl block. That seems perfectly fine, and I think we'll likely end up with something along the lines of trait fields in the future.

The second proposal, for fully general properties, means that a simple expression like x.y.z = foo might invoke arbitrary code. I don't think we should ever hide computation like that.

(Yes, we have Deref implementations, but they're relatively rare and only used for types like Box or something like String vs &str; we tend to discourage anything that might use the words "clever" and "Deref" in the same sentence.)

@H2CO3 also made the point that this would also cause problems with &obj.field or especially &mut obj.field, as the field wouldn't correspond to an lvalue location. (Unlike trait fields, for which references and mutable references work as expected, as I mentioned above.)

This doesn't seem worth it for what, ultimately, represents a syntactic change rather than a semantic one. Today, you can already write write obj.computed_field() in place of obj.computed_field, and you can already write obj.set_computed_field(val) rather than obj.computed_field = val. I don't think we should make computed values look like fields.

Could you elaborate a bit more on the problem you want to solve that motivated you to ask for fully general computed properties (your second proposal), rather than just trait fields?

19 Likes

My personal experience after working with JavaScript professionally for ~5 years is that we typically ended up regretting using properties instead of methods, mostly for the unsurprising reasons already given above. The few cases where I’m glad we had getters/setters were essentially metaprogramming, which in Rust would probably be better solved by macros.

Also, although traits in fields are likely to happen someday, that’s because they have a fairly strong performance motivation: guaranteeing cheap field access in spite of runtime polymorphism. Obviously, that motivation goes out the window as soon as we consider “properties”/“getters” that run arbitrary code.

So like most feature requests, especially ones involving new syntax, the biggest problem here is that the proposal completely skips the issue of motivation. Discussing properties in detail is purely academic until we have a compelling reason to consider adding them.

The only compelling argument I’m personally aware of for adding properties to a programming language is that having a layer of indirection to all field accesses makes it easier to reimplement a type without breaking client code, but writing getters and setters by hand for every field of every object is just too tedious. IMO, this argument only really works in a language like Java or C# where A) they want to support upgrading arbitrary modules at runtime without recompiling clients, and B) they allowed fields to be public in the first place, and C) having trivial field accesses made non-trivial without warning is not considered a big deal. A is AFAIK only feasible in managed languages anyway, so Rust will never have that (what it can and arguably already does have is opt-in ABI stability for the few libraries that really need it). And C doesn't really hold for a “systems language” like Rust where we expect performance to be not only high, but also deterministic, consistent, stable, and largely under our control.

5 Likes

Except in the case of lazy_static/once_cell::Lazy :grin:

4 Likes

Here are a few motivations:

  • Properties are more concise and easier to read than method calls. Consider these examples:

    foo.set_bar(bar); // vs
    foo.bar = bar;
    

    With the equals sign, the intent is immediately clear.

    foo.set_bar(foo.bar() * 2); // vs
    foo.bar *= 2;
    

    This is much shorter and easier to understand.

  • Properties are used for state, whereas methods are used for behavior. Without properties, it's not always clear which is which. If you have a method bar(), how do you know if it does an expensive computation, or just returns a field?

    You can of course start getters with get_, but that's more verbose. Also, a method starting with get_ may not be a getter, but will execute a GET HTTP request. True story.

  • Following good practices, getters and setters

    • should be cheap
    • should not produce an error
    • should return the same result over multiple invocations

    As long as you use properties only for state, not behavior, it's easy to follow these rules.

One important concern is that properties hide where computations are happening. However, IDEs like IntelliJ disambiguate properties from fields by using different syntax highlighting colors. This would also be possible in IntelliJ-Rust/RLS/rust-analyzer.

The purpose of properties is to expose state with a nice API, so it usually doesn't matter what the property does under the hood.

I found properties in Kotlin very useful. Kotlin even goes one step further and automatically converts getters and setters from Java APIs into properties. For example, this Java code: editText.setHint("...") is written like this in Kotlin: editText.hint = "..."

I think the same applies to properties. Properties can log a message when they are modified, or calculate a value lazily (e.g. the circumference of a circle from its radius, or a person's full name from their first and last name). Doing something more sophisticated is discouraged.

1 Like

Instead of using getters and setters, I'd propose to build properties on getters and mutable getters. The main reason being that += and other assignment traits require a &mut self, so they can't be expressed via getters and setters on non-Copy, non-Default types.

Specifically I'm suggesting that get should always return a & and that get_mut should always return &mut with the syntax should be changed to:

pub trait Diag {
    diagonal {
        get(&self) -> &f32;
        get_mut(&mut self) -> &mut f32;
    }
}

Some downsides of this change:

  • It doesn't allow destructuring to a property. As we can't ensure orthogonality of properties, we would only be able to destructure to a single property anyways.
  • The returned value cannot be calculated in the property. This limits the usability, but also restricts the the possible complexity of properties. It also removes the lifetime issues mentioned by others.
  • Without a set function it is harder to track when the value is changed. This limit the usability, but again restricts the possible complexity of properties.

All in all, I think this is a more rusty approach to properties.

4 Likes

This doesn't work for lazily computed values, because you would have to return a temporary value.

I personally think that's a benefit, as you don't get unexpected lifetime problems. So I think lazily computed values should still be functions, as they perform a computation.

1 Like

I like that when objects expose their properties, I know they're "dumb". I know access to them is cheap and deterministic. I know that setting doesn't have side effects.

Since Objective C added properties, I see bikeshedding about them, because users don't agree how much they can do before they do "too much" or are "too clever" (can a getter run an SQL query? If you set file.path = new is that a rename()?)

I know that in Java, C# and a few others it's a best practice to hide implementation and only care about the interface, and there's a lot of emphasis on keeping interfaces open and future-extensible. But in Rust I value predictability and understanding what happens under the hood, and properties go against that.

10 Likes

There's one case where I wish I had properties-like features in the language: exposing a value publicly, but not allowing modification of this value.

This comes up quite often, because there are plenty of values that aren't some secret implementation detail, but can't be changed without at least checking validity of the value, or upholding some other invariants. For example, Vec.len. It can be read easily and it's always available. But of course it can't be written to without releasing nasal daemons.

So my mini counter-proposal is read-only fields:

struct Vec<T> {
  pub(but only for reading) len: usize
  capacity: usize,
  data: *mut T
}

Pub-read-only fields act as regular fields (read-write) in the same module (where private fields can be accessed), and act as a read-only fields from other places in which they're public.

By read-only I mean you can get & of the field (and copy a Copy value), but you can't get &mut, can't move it, and can't construct a new struct instance with it (for mutation it acts like a private field).

It's better than pub fn get_field(&self) -> &T, because the borrow checker can still track borrow of the individual field, instead of borrowing all of &self.

It's better than just making the field pub, because users of the object can't break invariants of the object.

18 Likes

As bikeshed, we could spell this

struct Vec<T> {
    pub(ref crate) ptr: *mut T,
    pub(ref) capacity: usize,
    pub(ref, crate) len: usize,
}

Here

  • ptr can be accessed by &_ anywhere in the crate, but &mut _ only within the module
  • capacity can be accessed by &_ anywhere, but &mut _ only within the module
  • len can be accessed by &_ anywhere, but &mut _ only within the crate

In this example, crate can be replaced by anything which can go inside pub($vis)

Note: I am not proposing that we change Vec<_> to do this, only showing off a possible syntax

6 Likes

Shorter, certainly. Easier to understand, no.

Whereas if you have a field, you know it doesn't do a computation. Properties break that. Nothing prevents a property from making an expensive computation except convention, and conventions vary.

Not everyone uses IDEs, and code should read clearly in plain text. Current Rust disambiguates computation from field access in ways plainly visible whether you use an IDE or not.

5 Likes

Unless the field access invokes Deref.

1 Like