Is immutability by default worth the hassle?

I'm pretty sure I understand the semantics of let mut correctly, I just think they're bad and the distinction you're pointing out means very little.

let x = y;

can x change? It depends. If y is &mut T then yes, if y is T then not, but actually yes if T contains &mut references inside or interior mutability. It's all muddy. People may assume let about immutability, but it's not. It's just a weak lint for the binding that in some common cases looks like immutability.

let mut is about the binding, but also kinda sorta tries to be about the value. If it only prevented reassignment, it would be clearly about the binding for enforcing single-assignment style. But, I assume, to stop swap it also has opinion on getting &mut out of it, which makes it seem like it's about making the value immutable. If it's about the value, then {x}.mutate() seems like a loophole.

BTW: the raw pointer workaround/aliasing/UB is not relevant to what I'm trying to say. unsafe can break anything.

That's not what actually generated intermediate representation does. And you can even rewrite it like this:

   let mut p: *const i32 = &0;
   for i in 0..100 {
        if i == 42 {
            p = &i;
        }
        if i == 2 * 42 {
            println!("{p}", p = unsafe { &*p })
        }
   }

Then from Rust language definition it would still be UB, but there are no UB in the intermediate representation, generated by compiler, which means, that yes, that code actually works even with optimizations.

You just noticed the problem which is easily fixable, see above. But yeah, good catch.

What about updated version which does have UB in the source code but which is translated into representation which no longer contains UB?

x can't change. If x's type is &mut T, then the reference (x) can't change. What can change is the referent (*x). That's not the same thing, it's not muddy, it's a clear distinction.

I think interior mutability can be thought of in a similar way, with Cell<T> being similar to &mut T. I'm less clear about that.

But at least in the more common case of u32 or String I think this is all very clear.

That's not a loophole. That's making a copy and mutating the copy. Of course that's allowed. This doesn't change the value of x.

3 Likes

That's clear from language implementor's perspective when you're pedantic about reasoning differently about the x binding and the value of x, but it's not clear from average users' perspective where it's just a label for whatever value this binding is used for.

{x}.mutate() can be a move, not a copy.

An attempt to clarify some ideas being discussed:

As I see it, way in which let vs. let mut is ā€œjust a lintā€ is that it only affects things within a single function body. Thus, it can be useful to readers of that single function, but it can never give a guarantee relevant to any larger scope of a program (module or crate boundary), like & vs. &mut does.

Is it a language rule, which cannot be suppressed? Yes, definitely, so it is not really a lint. But it also isn't very powerful.

2 Likes

You are thinking of some other guarantee from the user perspective from what the guarantee actually is, which is why I think you're confusing guarantees.

The guarantee you actually get is this:

let x = String::from("abc");
// doesn't matter what code you put here
println!("{x}");

Will print abc no matter what (if you don't shadow x with the same name, of course).

Yes, I'm trying to say that the guarantee let mut actually gives in Rust is a very specific language-lawyery detail that is misaligned with practical guarantees that users may expect from "immutable let".

Again, please don't explain to me what let mut does. I'm quite sure I exactly know what it does to the point I'd be able to write a compiler that implements it perfectly (including really bad edge cases like its interaction with match ergonomics). I just think that what it does is not useful, and semantics of what it actually does are poorly aligned with typical use-cases and semantics a reasonable user can expect from controlling mutability of variables.

Interior mutability and shadowing may not be loopholes from Rust's perspective (it's all well defined and sound), but they're loopholes from high-level user view if user wanted a guarantee like "x is set to 1 and this won't change". In practical terms, if you're reading code and see:

let x = 1;

// ā€¦some code here you glossed overā€¦

println!("{x}");

it would be useful to be able to say "I see let is 'immutable' so it defintiely prints 1 regardless of what code is between", but Rust doesn't have this feature. Because Rust allows you to create a technically new binding under the same name, so binding not changing doesn't mean that uses of x have the same value. And when you're getting into distinction of different bindings named x in the same scope, that's deep into language specifics, and far away from the original "can I know if this changes?" use-case.

2 Likes

If the program has UB, then there are no guarantees about what the IR is, the compiler might as well compile your code into a nuke-launching program in IR. What IR it generates for code with UB is completely irrelevant to any guarantees we're talking about. All guarantees the language gives you operate under the assumption that the program doesn't have UB. If it has UB, there is no behavior in the Abstract Machine at all.

I think all this talk about UB is a red herring here and off-topic. If there is UB, you don't know anything about what your program will do, period.

1 Like

Clearly useful: it prevents bugs. As many people have said from their own experience in this thread.

I don't find your example convincing. For example, let's say I do what you did in your example:

let v = vec![1, 2, 3];
{v}.push(4);

OK I might be slightly surprised that this compiles if I don't quite understand the language details. But the protection I wanted from let /* no mut! */ v = vec![1, 2, 3] is still preserved. If I now do:

println!("{v}");

I might be surprised that it doesn't compile, but I'm still protected. v hasn't changed, it's not going to print a changed value for me, which is what I wanted to be protected from by not writing mut.

That's a separate issue. I agree shadowing is potentially a footgun.

3 Likes

But you also need to know the type. If it was let v = RefCell::new(vec![1,2,3]) then let suddenly loses its ability to tell if it changes or not.

It can be obscured, e.g. let v = foo.get_vec() and it could have been Vec once with no-change guarantee, but refactoring of get_vec to return &mut Vec would affect let's control despite your functions' code not changing syntactically. I know Rust doesn't do this, but I think it'd be fair to expect "immutable" let to have semantics of &* or Freeze so that "doesn't change" applies regardless of the type.

Sorry to disappoint you, but that's not the semantics of immutable bindings, and there is nothing muddy about it, they are very sharp and clear. An immutable binding means that the memory underlying the binding cannot be mutated (excluding the part within some UnsafeCell). That's it. No more, no less. It doesn't say anything about transitively references memory, whether through inner references or pointers. In your example, let p: &mut T means that the value of p cannot change: it's always the same mutable reference. Contrast it with e.g. an impl Read/Write for &[T], which actually changes the reference itself, advancing it forward.

Now, one can argue whether that's useful semantics, or whether something like true transitive immutability would be more desirable, but with Rust's unsafe semantics the latter would be pretty much impossible to enforce and not too useful. The same question arises in all other languages (Python, Java, Kotlin): does an immutable container means that the data within is also immutable? And the answer is usually "no".

That's a trolling level comment. UB breaks all guarantees, and you know that. If that's your kind of argument, here is how I can break const in C++. Watch:

const int n = 4;
auto p = reinterpret_cast<int*>(&n);
// I have mutated const, yolo!
*p = 0;
1 Like

I know. Please don't treat me as someone who doesn't know how it works.

And that's what I'm trying to argue. I'm trying to say that let has weird and not-so-useful meaning that happens to only partially match some use-cases, you're interpreting this as me just being a confused noob who doesn't understand the feature. Whenever you think I don't know what let mut, it is me arguing it has poor semantics and ideally it should mean something else.

1 Like

True that, you have to know your types. I am not a fan of implicit dereference. I think the language would be better with explicit deref. Except the * operator should be on the opposite side, so that you don't need extra parentheses when you dereference and call a method:

v*.push(5);

(I know push requires a reference, so this inserts an implicit &mut borrow, but maybe that's OK)

2 Likes

You're not getting your point across clearly, in that case. If your comment above was supposed to represent the thoughts of a confused newbie, fine, but I don't think it's helpful to repeat the confusing interpretation without clearly stating it's wrong (someone reading this thread won't know otherwise), and I don't think it's constructive to dismiss the attempts to precisely specify the semantics as "language lawyery".

Yes, the semantics of immutable bindings take some getting used to. The semantics of const also take some getting used to, even though it is supposed to represent a truly immutable value (e.g. can you allocate? how can you take a &mut in const expressions? how can a Mutex be const?). I am yet to see a language which would entirely avoid that confusion (except Haskell, but its tradeoffs are much worse for most programmers). I don't find let-bindings any more confusing that the immutability in other languages, and there are real arguments that immutability by default helps with reading the code and avoiding errors.

Immutable bindings don't give you any hard guarantees in generic code, or if you don't control the type of the binding (so someone add interior mutability under your feet), but for concrete trusted types it is a useful and meaningful guarantee.

1 Like

I think "it's fine and useful if you get used to it" can be separated from "it's a proper design". For example PHP is fine if you get used to its array functions (with irregular naming and inconsistent argument order). It's quite usable and productive, but also clearly not an ideal design.

For what it could have been, e.g. const bindings in JavaScript are purely about the binding itself.

const arr = [];
arr = [1]; // not legal
arr.push(1); // legal 

It makes values behave like having interior mutability, and const is not an ideal name for this, but in terms of semantics it makes the distinction about the binding and the value behind the binding much clearer. You can teach this as being only about re-assignment of the binding, rather it giving immutability in some cases but not others. In Rust let mut being for both reassignment and taking of &mut is IMHO two features under one syntax, and their meaning is mudded by existence of shadowing and interior mutability.

Without shadowing: Because shadowing is not allowed for const in JS:

const x = 1;

 // ā€¦skip over code hereā€¦

console.log(x);

in JS you know it prints 1. In Rust you don't.

I realize that Rust's backwards-compatibility need and other practical uses for shadowing make it impossible to adopt this design, but a better design is possible.

What do you mean? I think this was already addressed as false.

let x = 1;

// no shadowing!
// ... skip over code here...

println!("{x}");

In Rust you know it prints 1.

1 Like

I mean the skipped code could contain shadowing. To check if it does, you're forced not to skip it.

But you clearly said "without shadowing".

I've meant it as "without shadowing existing in the language, so you can assume it's not there without reading the skipped code".

Ah I see. OK that makes sense.

But I think whether to allow shadowing or not is a completely orthogonal discussion from whether to have mut by default. It was decided that shadowing is not that dangerous, for whatever reasons and from practical experience. Even with everything mut by default, shadowing can change your types and scopes and things, which can be considered equally weird.

2 Likes