Pre-RFC new trait Set, impl on struct fields

But we already have AddAssign and other traits like it. The same argument could be made that they shouldn’t exist and only really do is use the Add trait in a shorter syntax. But we don’t and thus I don’t agree that changing how assignment operators (for fields in structs only) would be any less reliable or correct.

No I don’t want to use C++, Rust has better ergonomics (syntactically in general) and much better guarantees. Also I don’t see how this breaks assumptions of unsafe code at all. I should probably change my initial post since what I now am proposing has changed. Basically these traits would work more like C#'s get; set; where internally they still have to be represented by a private field that can be memcpy-ed so that wouldn’t break at all.

Yes I did author that RFC but also, that is not about traits but about Annotations which I would argue are quite different.

The point of this is exactly that contract so that the you can enforce what sort of values are stored and what to do when they are stored. Just in a much nicer looking structure

To me the underlying issue is one of the reader's intuition:

  • If I see an assign operator (e.g., =), I think of that compiling to a possible addressing computation and a store.

  • If I see an X-assign operator (e.g., +=), I think of that compiling to a possible addressing computation, a fetch and a store (with the advantage over separate assign and unary/binary-op that the target's addressing need only be defined once, and that in some architectures the load-modify-store may be a single instruction).

  • If I see a setter or getter method, I know that I can't intuit the compilation result and thus have to examine the method definition.

As of the time of my writing, the most recently posted "TWIR quote of the week" on the Rust users forum summarizes this nicely:

Explicitness is the fourth core value of Rust. Ironically, I don’t see that “Explicitness” is ever explicitly stated as a goal of Rust. But, given the choice between implicitness and explicitness, Rust usually chooses explicitness.

5 Likes

Fair, rust does generally choose to explicitness. Would a different syntax for the fields be good enough to hint that these are setters and getters? What about the following:

let a: MyStruct = MyStruct::new();

a.b = 1; // Pure assignment
a{c} = 3; // using setter

return a{c}; //using getter

Those traits exist for efficiency reasons. If you couldn’t overload the something-assign operators, it would mean, for example, that a matrix addition would have to allocate a huge temporary matrix, just to then have it copied into the LHS. These traits are not provided so that arbitrary trickery can be done in overloaded operators (this is/should be true for all overloaded operators in general, not only compound assignments).

In other words, using += to add two values then in addition do some of the trickery that you describe is frowned upon (and for the good reasons I’ve already explained). Basically, the ability to overload these particular operators is a necessary bad for performance-critical code, however it can and should be used in an unsurprising way if treated cautiously.

Basically, the expectation/implicit contract of these traits is that they will do the semantic equivalent of whatever the corresponding non-assignment operator does, followed by a simple assignment, and nothing else.

The plain = operator, however, can’t possibly be used in this manner. It has one and only one purpose: moving/copying the RHS into the LHS. This can’t be done any more efficiently than the compiler already generates, hence it’s useless to overload for performance reasons. And it’s actively harmful to overload for any other reason, because any other, non-trivial implementation thereof would be surprising, as I explained earlier. So, basically, what I’m saying is the only expected/non-abusive overload of it would perform exactly the same action as it already does today.

I can image some different syntax which calls getters and setters, because then the distinction would be very clear, which is good. However, at that point I don’t really see value in it either, because in that case you could just use a setter function and a plain old function call, and we are back at square 1.

4 Likes

I agree that using += to have some additional side affects on a type should be massively frowned upon. However, I disagree that = only purpose ever is for moving or copying the RHS to the LHS. We already use it for comparisons.

What about the a{c} syntax I wrote about above? There is a benefit for this syntax is the same for why people want pipelining. I am going to use the C# example again because I think that it does the syntax the best. The getters and setters are used to enforce contracts with the outside user. This is also how the rust books recommend to do it, not with getters and setters that are called but with a public field. That is why I think that this is also good. (I would argue that still using a.c is the best option because it is the most obvious way)

I disagree that = only purpose ever is for moving or copying the RHS to the LHS. We already use it for comparisons.

No, that is not the case. Equality comparisons in Rust are done using ==. The = operator is only used for assignment.

What about the a{c} syntax I wrote about above?

I think I addressed that in the last paragraph of my last reply. foo{c} = thing is not a step forward compared to foo.set_c(thing). It doesn't have the same advantage as pipelining because, well, the setter- (plain old function-) based syntax isn't as awkward in the first place as it would be in the case of pipelined expressions. So, when field accesses are composed,

foo{bar}{baz} = new_value;

isn't much of an improvement over

foo.bar.set_baz(new_value);

By the way, while we are at nested property access: are you aware that it's considered an anti-pattern in exactly the object-oriented languages you are referring to? The quoted "Law of Demeter" is a principle that basically states that if you have deeply nested property access in your code, you are violating encapsulation, and you should refactor (instead of, as in this case, making it more convenient to violate encapsulation). Incidentally, this is probably the case why I never felt the need for this — I always try to factor my code so that reading or writing deeply embedded fields simply isn't necessary.

The getters and setters are used to enforce contracts with the outside user.

In Rust, it's actually the type system that should be the primary means of enforcing contracts and expectations or providing guarantees.

This is also how the rust books recommend to do it, not with getters and setters that are called but with a public field.

I'm afraid I'm not exactly following that. Could you please provide an example?

How exactly do you intend to handle restrictions on “implicit setters”?

If the only purpose of a get/set pair is to map between two representations with the same cardinality, then things are fine. But in returning &T from get, you’ve actually precluded this usage, as to tie the lifetime to something you have to either borrow a static or borrow a member of the structure.

But what if you’re using it to hold some invariant on the field, as you’ve suggested is the purpose? In C# you’d use exceptions, but Rust doesn’t have exceptions, it has Result. And I’m pretty sure that nobody wants to have assignment return a Result. You could panic, but that’s not how you control code flow, and then you also need the try_assign setter function anyway that returns a Result.

Using a function is strictly more powerful than any trait based overloading could be.

1 Like

Sorry, I don’t know why I though = was also comparison.

However,

foo{bar}{baz} = new_value;

is not the same as

foo.bar.set_baz(new_value);

but actually

foo.set_bar(foo.get_bar().set_baz(new_value));

Furthermore:

Yes, the type system should be primary means and it does a very good job of that. But there are some things that are enforceable directly with the type system such as maximums or covariance between settings.

We read here that visibility and encapsulation are desirable and on by default (the private by default part). And read only fields would definetly fall under better visability

So the pair isn’t a map between two representations with the same cardinality but more of letter box that might restrict what kind of letters are left in (and obviously not by throwing an error).

Fair enough, you are right – then it would indeed be syntactic improvement. However, I still hold my position that it’s generally not a good idea to do this (nested property access) at all, or to write code that will result in other programmers needing it, hence it shouldn’t be the target of a language change that makes it easier.

Except that if it's enforcing something interesting (that's not trivially checkable beforehand), the setter should probably be returning something like Result<(), SomeError> to let you know what happened. Which makes it another surprising difference from normal assignment. (And I never want (a.x = foo())?; to be a thing.)

3 Likes

So I guess that you are right that returning a Result would make sense. However, even if it was trivially possible, code reusability should dictate that you would want it in a setter anyway

So I guess that the opinion of deep accesses is just that, an opinion. I would generally not fall into that camp because of the undesirable obfuscating and narrowness of the interfaces when designing a system. But I guess I can see where not having deep accesses would lead to better code.

It’s not just my opinion, it’s a well-established observation of systems in general. And it’s not about the narrowness of interfaces, it’s about not exposing implementation details. These two are orthogonal issues. And while unnecessary narrowness of interfaces can be an anti-pattern (although generally, several small interfaces are better than one giant one), but relying on being able to pull out and push back the guts of a nested object is generally a code smell. I’m not saying it’s never useful; I’m saying it’s usually dangerous.

The way I see it, there are three main “classes” of structures.

Dumb data: your i32s, or structs consisting entirety of publicly addressable members. Basically a tuple of related data maybe with names attached to the members.

Smartish data: structs that are otherwise just dumb data, but maintain some invariant over it. Your NonZero or R32 or Quaternions of the world.

Abstractions: structures that present a wildly different interface than their internal structure. Or put another way, their logical structure is separate from their physical representation. Most library types are going to be this.

Of course, this is a continuous scale, not discrete, and dumb data can actually be wrappers around abstractions, so the lines get even more blurred.

But I think this shows where = overloading might be most useful: in the minor case of thin smart data. It might be useful to have let r: R32 = 0 rather than let r: R32 = 0.into() or let r = r32(0). But that’s actually distinct from properties, so /shrug.

Nested properties are pretty much an antipattern in languages with them anyway. And I think I’ve been convinced that most cases can be better represented with a semantic setter than representational properties.

3 Likes

I would actually prefer .into() over the other choices. I don't want implicit value-to-value conversions to happen without my knowledge. (Deref coercions is an entirely different thing because it has one well-defined semantics with smart pointers and synchronization primitives as one-element containers.)

The into() call is non-invasive and common enough that it can be skipped over instinctively if its presence is not important (most of the cases), but it can still be easily spotted or searched for when it is still important. I've been wondering about explicit vs implicit conversions in language design for a long time before I encountered Rust, and then I came to the conclusion that into() is basically the sweet spot, a very well-balanced compromise between self-documenting code and minimization of noise.

That is also true – it may well be useful. But "it's sometimes useful in a niche situation" is not enough justification for a feature to be added to the language. A language change is a huge responsibility. It really is a big deal, because growing the language leads to bugs in its implementation and its usage. Consequently, a feature has to carry very significant and extensive advantage in order to be worth the burden of making the language bigger.

1 Like

I’d have a question.

Considering that Rust structs are not objects in the OOP paradigm, they are just storage for fields and, by the way, you can glue some functions to them and call them methods, how would this proposal work with other custom data types that can have functions glued onto them? Like enums? Because in most places you don’t have to care if the data type is struct or something else.

If glueing methods onto data is done by placing them inside an impl block and an impl block for a trait acts basically the same way, would it be possible to add setters/getters into a trait?

What I try to hint here is, just throwing something into the language without considering the whole consistency of the design leads to complicated mess. I don’t see much added value in getters/setters (and I probably don’t use any language that uses them), but if they were to be added, I think the proposal would have to consider much more than „lets put them on structs somehow“.

I agree that we have to be careful about such an addition and should consider all possibilities.

As for a language that uses this sort of feature, I have mentioned C# before because that was a major inspiration for this.

I also agree that Rust structs are not objects in the OOP paradigm. However, there is a difference between structs and enums that would not make sense for enums to have this feature. Structs can have more than one field where as enums cannot. Enums are basically a structs with a single field that has a setter which the compiler guarantees some contract (that the assigned value is within the enum). Yes, I acknowledge that generally it does not matter what is within an impl block but there are cases (most notably what other traits have been implemented) where it does matter

I think we really need to insist on concrete use cases in this thread (and many other proposal threads, tbh). We all know the general abstract arguments for/against this sort of thing, and it was always obvious that the general arguments alone probably don’t justify adding this, so the discussion is only going to go anywhere if someone presents a compelling example of actual code that would be dramatically (not just significantly) improved by a proposal like this.

So, does anyone know of any compelling, concrete examples of Rust code that would be dramatically improved by setters?

6 Likes