Idea: Properties

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

FWIW, my main interest in computed properties is for things that fall under the umbrella of "custom data representations". For example:

  • Bitfields

  • "Big-endian u32" and "little-endian u32" types

  • Relative pointers

  • Layout optimizations beyond what rustc does – e.g. given

    struct Foo {
        a: Option<u32>,
        b: Option<u32>,
        c: Option<u32>,
        // ...
    }
    

    it would be nice to pack all the tag bits into one word. This does mean that you can't take references to the individual Options.

Oh, and one thing that isn't a custom data representation:

  • Cell! Specifically, being able to have Cell fields without having to use .get() and .set() everywhere to access them. Atomics as well.

Each of the above examples represents an optimization of some sort:

  • Bitfields are more space-efficient than individual bool fields.
  • Fixed-endian types make it easier to work directly with data in fixed-endian format (e.g. an on-disk representation) rather than converting to a "native" representation and back.
  • Relative pointers can allow storing pointers more space-efficiently, among other things.
  • Layout optimizations are optimizations, of course.
  • Cell is usually faster than alternative designs (say, using RefCell on the entire structure); atomics can be much faster than locks.

Yet as I see it, Rust currently effectively discourages all of those optimizations, because adopting them comes with the ergonomic penalty of having to use getters and setters instead of the nice struct field syntax. And discouraging optimizations is not something Rust should be doing.

4 Likes

I like the idea of publicly only exposing an &T reference to a field of type T. Features like this make interfaces more precise and allow the type system and borrow checker to prove more of your program. This is the direction in which I hope to see Rust continue to extend.

I wonder what you think of limiting the access/knowledge even more by naming a trait:

struct Foo {
  pub(crate as &) x: Bar,
  pub(crate as &Trait) y: Bar,
}
2 Likes

@comex raises very compelling use-cases. However, I'm not keen seeing Rust going back on its very elegant SRP inspired design that separated data from behavior to a great extent (types vs. traits respectfully).

Here's an alternative suggestion that just occurred to me that I feel keeps closer to the current elegance of Rust - If types represent data layout in Rust, why can't we use types as the data interface for other types?

struct Representation { ... }
struct MyType { ... }
impl Representation for MyType {....}  

pub fn foo(x: T) 
    where T: Representation { 
    // we can use Representation's fields here
}

The syntax here can be bike-shedded but the main idea here is that this is a natural extension in line with the current design. In the same manner that traits can implement other traits so should types be allowed to do the same.

3 Likes

This seems like a reasonable idea to me.

6 Likes

And you'd also be able to pattern match on it, which wouldn't work with the getter method.

8 Likes

Although most getter methods can be made const. The intention I thought was to allow const fn in patterns.

1 Like