Is immutability by default worth the hassle?

Another point is that immutable variables behave more-or-less like algebraic variables: After you’ve figured out the value once, you can mentally replace every appearance of the variable with that value.

The mut keyword serves as a warning that this analysis does not apply for the tagged variable: To determine its value at some point in the program, you have to consider its entire history instead.

4 Likes

I think you are digging too deep. Actual formal, rules are actually too complex for many to know (and even ones who know them only apply them occasionally, because it's so tedious… most of the time they leave to the compiler the nuiances and only have to think about all the fine details when they use unsafe).

This makes this:

Quite obvious. Yes, you often need or want mutability and Rust gives it to you (but then, Haskell can do that, too), but it's more of a freebee: since you are the only one who touches that variable right now you may play with it freely, just be quiet, Ok?.

Even exception with !Unpin into that category: yes, formally there are two references, we know most of them are harmless (they are inside refences), but since our language model is too primitive to express it we relax &mut into &mut. And similar thing happens when people implement complex data structures manually with unsafe.

You couldn't summarise the whole language reference into one slogan (if that were possible then why would we need that reference) but we can define goals.

And Haskell goal is to make sure there are no mutable state at all (but it gives you anyway if you really need it) while Rust goal is to make sure there are unique borrower for every mutable variable.

Neither achieve their stated goals perfectly and neither makes an ideal (“no shared mutable state ever”), but that's Ok.

If you think about it that 99% of computer use is to manage some kind of shared mutable state (e.g. URLO exist to share various thought with members of URLO and other observers on the internet, banking computer system goal is to keep shared mutable eventually consistent information about our finances and so on).

But because shared mutable state is hard, really hard, always hard… it makes reasoning about your code complex… eventually developers realise that to write realiable code it's good idea to reduce that complexity as much as possible.

You can not remove shared mutable state from the language completely (because then said language would immediately become quite useless for many practical tasks), but you may try to control it.

Haskell and Rust pick radically different roads to the desired goal, it's obvious. But it would be nice if it were possible to actually explan the difference. “Rust cares about uniquiness and not about mutability”.

That's why Rust stopped using unique references with closures: simple observation solved the issue: you don't really need unique references ever. If you arrive at point where you actually need it (like with closures) you can just add one star! And capture &*something instead of &something.

Of course it, simultaneously, added exception for the generators, but, again, an attempt was made to stay without the existing range of types: &x, &mut x, *mut x. And it's kinda obvious that difference between &mut x and *mut x is uniqueness, not mutability.

Thus I have to “agree”:

Yes, that's post is from 2014, it's outdated, but it aged extremely well: today it's even more correct then when it was written.

We no longer use unique references (even implicit ones for closures) and while &mut x turns into *mut x for generators it happens to reduce number of distinct concepts, not because it's, somehow, the best solution.

It's just one of the bad solutions picked as an answer for the problem which have no good solutions.

1 Like

I maintain that references are mainly about shared vs exclusive. These concepts are mixed together, so it's both immutable/mutable and shared/exclusive, but when it needs to be simplified to a single axis, the sharing axis makes Rust make more sense. To me the "& is immutable unless you use UnsafeCell" exception is not merely an edge case, but a very important feature that changes the character of & entirely.

When you see a fn foo(&self) method, that & doesn't always mean it's pure and won't mutate self, but it does always mean it can be shared. And there are methods like get_mut on &mut Mutex that can only be valid with &mut due to its exclusivity guarantee, even if you're not mutating anything. The same method on &Mutex would not be sound, not because of mutability, but because of sharing.

And I think that angle is important for teaching Rust. When people learning Rust implement patterns like caching/memoization they go for fn set(&mut self, because it mutates the cache. Correcting this as "no, you need it immutable, except have "interior mutability" that makes immutable mutable" is odd. But the angle of "a cache needs to be shared, but use a synchronization primitive to make shared mutation safe" makes more sense IMHO.

3 Likes

UnsafeCell is weird. It's basically a "get out of the type system free" hack, because whatever great safety rules you invent, there will always be some sound programs which violate them. In my view, the only reason &mut UnsafeCell<T> isn't "this can be aliased despite normally noalias" is because you can always ssafely cast &mut T to a &T. But the uninitializedness rules I linked above show that the rules are still more complex for different kinds of pointers.

I agree that the "shared vs exclusive" perspective is a useful one to know, and a good one to teach. I object to the common sentiment "mutability is irrelevant, aliasing is", because it's just as much a confusing oversimplification as the converse. And it really grinds my gear when the people repeat "if only we could remove mut keyword and rename &mut to &uniq", because it would just trade one misconception for another, and it devalues the very real benefits of the current mutability system.

1 Like

I agree that single-assignment programming style is useful, but the syntax in Rust for it is not the best.

let mut of bindings is a very different feature than &mut and ref mut. One is a weak lint for variables that doesn't really affect semantics and has loopholes for moves and temporaries, and the other is a hard guarantee that affects values/places. Meanwhile noobs are confused which of the three mut syntaxes they're supposed to use. And if you try const foo = 1 for really-really immutable values in your functions, you're in for a surprise.

It's too late to change it now, but if I had a time machine I'd insist on something like var/val instead of mut in let mut context.

I think this is a misuderstanding.

let x = 13;

is a hard guarantee. It guarantees that x will not change.

You can make a copy and change that, but that's not a loophole to that rule: x will still not change.

The same is true for:

let x = String::from("abc");

You have a hard guarantee: whenever you use x, it will contain "abc".

You can move the string elsewhere and change it there, but then it's no longer x that you're changing. That's not a loophole.

3 Likes

Without following the details of the arguments presented here I have to ask a question:

The question posed in the title of this thread presumes that there is some "hassle" in having to add ´mut´ when we want mutability. As opposed to having to add ´const´ when we don't want mutability, as in C++ for example.

So my question is: What hassle?

Ok, the compiler forces me to add ´mut´ if I try to mutate something and have forgotten the ´mut´. After checking I really want to mutate and can sensibly mutate. It also nudges me to remove ´mut´ if it turns out the the thing is never mutated by my program.

Really, is that it? Is that the hassle?

I would have thought that is so minor a hassle as to hardly worth commenting on.

Or is there more "hassle" about it that I am missing?

5 Likes

Except x, of course, may change, it's just that trying to observe it is UB. Simple example:

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

Here x changes during execution of program and we may observe it if we disable optimizations. Of course, as usual for UB, if we enable these we can get nonsense results, but that different story.

You may pretend that x doesn't change in safe Rust because of soundness pledge but it's different from hard guarantee.

const gives you hard guarantee, let doesn't.

True loophole is use of types with interior mutability. They can be changed without triggering UB.

But even normal ints can be changed, you just couldn't observe it in a correct Rust program.

Both the need to add mut in Rust and the recommendation to use const in C/C++ are hassle.

Only in Rust that hassle is inevitable while in C/C++ you can just pretend const doesn't exist and avoid that hassle.

No that's it. But note that in C++ where that hassle is avoidable and matter of style, not matter of necessity, different style guides recommend different approaches and I haven't seen even a single one which says “use const whenever you can, no excuses”.

If people try to avoid hassle then, to me, it says that hassle is real.

I think that depends on whether you mean the binding x or the String that x holds. We often refer to that String as x, even though it's not very precise. Yet we do it a lot.

1 Like

I wouldn't call what happens in that example "x changes". That program has undefined behavior. Which means the program is meaningless, so you can't meaningfully talk about whether x changes or not.

Yes, I should have qualified "x is guaranteed not to change in a program without UB".

But that's pretty redundant because that's how all guarantees work, including the guarantees that we were comparing let x = to, i.e. guarantees about & and &mut. So in the context of the discussion, this is misplaced off-topic nitpicking, a red herring.

const values also give no guarantees in the presence of UB. It's the same level of guarantee.

2 Likes

The same is true for a moved String as what is true for a copied u32 -- the String after a move can change, just as a u32 after a copy can change.

My point was: there is a real guarantee there that can be precisely stated, so it's not true that the lack of mut is merely a "weak lint" that "doesn't really affect semantics" and "has loopholes".

1 Like

Yes, I agree.

Wait a minute. That ´x´ comes into life at the beginning of the iteration with the let ´x = I;´ Its life ends when the scope ends at the end of the iteration. That is to say, on every iteration a new ´x´ comes into existence and then dies. Every iteration is using a different 'x'.

So I would say the 'x' is never being mutated. It's just that there are many different ´x´s coming and going.

Or am I missing a point?

1 Like

You can. Only to open yourself up to a lot more hassle debugging things when const is not used and someone does not realise they should not mutate something.

Oh good. Hardly a bother then.

Alternatively I could say it shows they are too lazy to think things through properly:)

By analogy I could avoid hassle in life by not buckling up my seat belt, not brushing my teeth, all manner of inconveniences. Provided I don't see or don't care that this greatly increases the probability of bad outcomes.

1 Like

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