Restricting "place expressions" (aka lvalues)?

This is not urgent at all, but just a nit I found the other day.

According to the Rust reference, all array indexing and field access expressions are place expressions. This means that the following is valid rust code:

(1, 2, 3).1 = 4;
MyStruct { field: value1 }.field = value2;
vec![1, 2, 3][1] = 5;

This might not seem alarming at all, but if you switch the temporary values with function return values, things can become more confusing:

function_that_returns_struct().field = value;

It’s a no-opt, but the user who wrote this code might have expected some reference to be returned.

C++ solves this problem by saying that field access is only an lvalue when the struct is an lvalue.

Admittedly this is already a problem with method calls, as function_that_returns_struct().set_field(value); is similarly a no-op, but that’s IMO a deeper problem with the value const-ness model in languages like Rust and C++. I have to think more on this.

1 Like

I definitely think this is a good idea. In fact, aggressively linting against all of the examples you provide, which are generally errors in C++ iirc, is probably the right call. Such things should certainly be allowed, but they should be discouraged because they can and will be optimized away.

In the last example you give, maybe we want to restrict taking references of value expressions? I’m not sure how to handle it, really, since I like being able to write things like &0 for “push 0 onto the stack and give me its reference”.

1 Like

The variant of this that I have seen many people confused by is that you can reassign fields of const structs. playground

#[derive(Debug)]
struct MyStruct {
    field: i32,
}

const C: MyStruct = MyStruct { field: 0 };

fn main() {
    C.field = 1; // ???
    println!("{:?}", C);
}
4 Likes

Huh. This seems to be a different problem, but arguably even more confusing. What’s actually happening here? (In C++ terms, “C” would be an lvalue indeed, but it would be const so no assignment can happen.)

I believe it is exactly the problem you described. My snippet is equivalent to:

fn main() {
    MyStruct { field: 0 }.field = 1;
    println!("{:?}", MyStruct { field: 0 });
}
3 Likes

Ah, I wasn’t aware that’s what const meant in Rust. It is the same problem then.

I think I just remembered that static would be the equivalent of what I thought it was (ie. fixed location in memory, immutable, hence non-assignable).

1 Like

I’d much rather this were a warn-by-default lint in the specific case of dead assignments (that would be a good idea in general, in fact!) than a hard error. The problem with temporaries not being lvalues is that it disables some absolutely valid and often-used constructs, such as:

hashmap.get(&fn_returning_a_struct().field)

5 Likes

You can already take the reference of an rvalue in Rust though. That is, hashmap.get(&fn_returning_a_struct()) already also works.

I know that, otherwise &fn_returning_a_struct().field couldn’t possibly work either – it just seems we have different definitions of what an lvalue is. My point is, regardless of what we call these kind of values, special-casing and forbidding assignment as opposed to taking the address seems inconsistent and surprising.

That's not true. In Rust func().field is a place expression (lvalue), even though func() is a value expression (rvalue). That's the point of this thread. The fact that you can take the reference of an rvalue is orthogonal to this topic.

Okay, again, I’m not trying to litigate the name or definition of either of these constructs – my point is merely that arbitrarily restricting assignments (that is, emitting hard errors) only in special cases based on rules that differ from those governing address-of seems like an ugly afterthought.

I don’t necessarily agree with that, either. In Rust the “&” operator applies to any kind of expression, regardless of whether it can be assigned to, and people like that (see drXor’s post above, where he raises the example of &0 being valid and useful Rust).

On the other hand, things appearing on the left side of an assignment has to be a "place expression’ (lvalue). That’s hardly arbitrary – you would supposedly never want to write 23 = 7; in Rust (and this indeed doesn’t compile today).

I still think that such statements should emit dead code lints, since that's what they are; such code should be optimized out even in debug mode, since it can't be observed.

Well, that's different. 23 is not a place expression, while (23,).0 is. Field access is always a place, with the desugaring

expr.field = foo;
// ...
let $temp = expr;
$temp.field = foo;

if expr isn't a place.

Exactly my point – why does (23,).0 = 7; compile when 23 = 7; doesn’t?

By the same logic, you could do the same desugaring for non-field-access: expr = foo; could technically be desugared into let $temp = expr; $temp = foo; when expr is not a place. That would be very useless – so why do we tolerate the field access case?

In C++, expr.field is an lvalue iff expr is an lvalue. This is not the case in Rust; expr.field is always a place (i.e., an lvalue). You can always assign to places. Meanwhile, you can take the reference of anything, which, when we take the reference of a value, produces a reference to the stack. We can dereference the pointer to obtain a place. This is why the following is valid Rust, and should be valid Rust:

*&mut 23 = 7;

This is a nop, because it only fusses around on the stack, unobservable from the outside. I also think it should be linted against, because it’s dead code.

On the other hand, you suggest that assigning to values should be allowed. On one hand, I am ok with this, because we’d just spit out a dead code warning and move on with our lives. But then you’re allowed to write bizarre things like

const C: u8 = 0;
C = 1;

which I think should be a hard error, like in C++, for our collective sanity. As much as I think const items are really just a typed #define, I think we should stick to some degree of common-sense usage.

I think I’d like to hear other internals folks weigh in on this, since I think a lot of this comes down to a matter of taste, and what is a lint versus a hard error.

2 Likes

That's not what I'm trying to suggest; I'm saying the current situation is inconsistent, and the way to fix it is to make expr.field not a place if expr is not a place (ie. do what C++ does).

I just read my first post again and realized that it might not have been clear that was what I was suggesting -- sorry about that.

Ah, I see! Well, I’m curious if this change would break anything… after all, C++ has several arcane rules surrounding the relationship between rvalues and lvalues (and nonsense like T&& and std::forward that Rust should avoid at all costs). I’ll need to think it over a bit.

Just a tiny note wrt T&& and std::forward in C++. Those are about lvalue and rvalue references, which are different (but related) concepts from lvalue-and-rvalue-ness (value categories). All Rust references are technically lvalue references so there’s no need to worry about that nonsense making it into Rust :slight_smile:

(footnote – C++ rvalue references simply correspond to passing by value in Rust – which is so much more elegant)

EDIT: Never mind. Of course, the call to the func wouldn't be optimized away, just the setting of the field of the returned struct which would not have any possible side-effect.

What about side-effects?

some_func_that_launches_missile_and_returns_launch_report_struct().field = "Yay!";

It is not necessarily a no-op, since it may mutate the effect of Drop::drop....

3 Likes