Lack of `mut` in bindings as a deny-by-default lint

Rust requires declaring bindings with a mut keyword if they're reassigned or mutably borrowed. Otherwise it's an error E0596.

The problem is, lack of mut in let mut is not really a fatal error. It's hardly an error at all. Unlike &mut, this binding mut is not part of the memory safety model. It's merely a lint that nudges programmers to use a programming style with more immutability and single-assignment style. It's not even a strong enforcement, because values of "immutable" let bindings can still be mutated if they're moved:

let v = vec![];
{v}.push(1); // works!

Other lints in Rust can be controlled with allow/warn/deny options. But mut error doesn't have that. I suggest making it a deny-by-default lint.

Personally, I really like the immutable/single-assignment programming style, so in principle I agree with the distinction between let and let mut. However, I came to conclusion that I don't need it enforced, and or me this lint has been very ineffective. I don't recall it ever catching a bug for me, but it does get in the way during development. When I edit code and e.g. add or delete some vec.push(), Rust bugs me to add and remove let mut accordingly. It's a chore, and it slows down my edit-compile-run cycle.

I'd be happy with #![warn(missing_mut)].

14 Likes

There's been some talk of making mut on bindings optional (though I cannot find any reference for this claim), which effectively would make it a lint instead of an error. I think effectively it would have to be made optional for this to be a lint rather than a hard error.

The error definitely can be softened to not block further analysis errors at least, if that isn't already the case.

It is (currently) allowed to (sort of) influence the memory model, though; IIRC there are some optimizations which (currently?) only really occur for let bindings of no-internal-UnsafeCell types, because these are statically known with-no-further-analysis to not be written to. Making mut optional/inferred for let bindings doesn't break anything, but it does make any such optimization pass require first proving that no writes happen.

3 Likes

I don't dislike your idea.

The alternative is that you declare all your bindings as mut, but that might also slow down your edit-compile-run cycle.

Relevant: 2014: Babysteps: Focusing on ownership (or: removing let mut) by @nikomatsakis

12 Likes

Trying to use v again will result in an error as v is moved by {v}:

error[E0382]: use of moved value: `v`
 --> <source>:4:5
  |
2 |     let v = vec![];
  |         - move occurs because `v` has type `Vec<i32>`, which does not implement the `Copy` trait
3 |     {v}.push(1); // works!
  |      - value moved here
4 |     v;
  |     ^ value used here after move
7 Likes

I think this somewhat misrepresents the thing that's being enforced by this rule. It's not about the value but about the variable. Provided its type does not allow interior mutability, you can mostly1 skip sections of code in review and be sure that when an immutable variable is used, it still contains the same value, unmodified, as it did initially. The ability to move out of a variable does not weaken this guarantee in any way. In most cases, such a move its really just an optimization anyways over what would always be allowed if immutability did apply to the value instead of the variable: to clone the value of the variable and modify the clone.

1 The thing you still do need to look out for is new bindings of the same name that could shadow the original one. But these are syntactically easy to spot as only the left-hand-sides of let statements are important as well as the patterns of (if|while) let/match/for/closures that contain the use site of the variable. In particular you can skip looking at any code in between definition and usage that has more indentation than either of those (whereas a &mut v could appear anywhere, even deeply nested in blocks and expressions). Also, with an IDE, when you see an immutable variable being used, you can just jump to definition and immediately be sure that that definition produces exactly the value that's used.


I'm not necessarily against this proposal, but I think there must be some arguments in favor of keeping it a hard error. The total lack of such arguments here seems - to me - as if there's potential to gather more information on the topic first before we can really discuss whether or not it's a good idea. E. g. consider comparing to other programming languages. I'm not too familiar with C-style languages but I assume making a local variable const in any of those (C, C++, Java, etc) might also be triggering more than just a lint of they are modified anyways? Sure, in Rust the default is the other way around, but there might be enough similarities in order to consider prior art when looking for reasons why a hard error might be a good thing.


I personally feel like, from a point of view of pure functional programming, Rust is at an interesting point where it allows mutability, but in a way that - in the absense of interior mutability - is mostly equivalent (ignoring performance benefits of Rust's approach) to the functional style of mutation: take a value by-value and return a new, modified, one. With the significant difference that using mutable references is syntactically a lot neater / more consise. I see no need to make it even more consise by allowing the omission of mut on the variables. I like pure functional programming, because it makes code easy reason about, and with that background I feel like mut annotations are something rather fundamental in the language design. It's perhaps even a bit similar to Rust's approach of making functions signatures mandatory, or the need to annotate the type of a const or a static.

However, with previous experience in Haskell in mind, I just remembered that GHC (the most popular Haskell compiler) has a feature that goes against the fundamental principles of the language: you can defer type errors to run-time, which makes every piece of code (like, every function) that contains type mismatch errors turn into something that will always error at run time, but in turn degrades type errors to just warnings at compile time. The use case: you can still compile your code during development, even if some sections are unfinished, and even test functionality unrelated to the unfinished parts. But you won't be able to disable the type system with this flag, the errors are just deferred to run time.


So going back to Rust, I think there could be approaches that resolve your problems without giving developers an option to outright disable the feature of mutable and immutable variables in Rust. Once we did that, we'd have to forever support this version of Rust where variable mutability is just a weak lint that can easily globally be turned off in a project.

Some (presumably in some ways suboptimal) ideas of alternative approaches follow:

For example it could be made possible to turn this into a warning for cargo check only, if the error being an error is too annoying for fast cycles of modifying code and running check. Or there could be a way to use a flags passed to rustc or an environment variable to downgrade this error, but you wouldn't be allowed to permanently disable the error with something placed into then source code? I think there is more generally sometimes a need to disable even certain warnings (e. g. unused) while your making changes to code that you only want to be turned back on when polishing your code again. Perhaps we could introduce a way to specify a configuration of warnings, and which can be turned on and off easily temporarily and only during development, so that e. g. when publishing or cargo installing a crate or using the crate as an (online/external) dependency, then that configuration mode wouldn't be allowed at all, and certain more radical settings such as disabling the missing mut on variable error (or new options to make tape annotating const items or function return types no longer mandatory) would only be allowed for use in this this temporary in-development mode. We could even keep such new features, like disabling the missing mut on variables error unstable, yet available on the stable compiler, if the design somehow ensures (or at least strongly encourages) that they really are only used temporarily and for convenience of development while code in the process of being changed, and that code that is shared with others or published will never make use of these features. Another feature that comes to mind is trace_macros: It always seems unnecessary overhead to me to be forced to switch to nightly temporarily (with the consequence of needing compile every dependency from scratch a second time) in order to be able to debug a macro, even though nobody cares if the way to debug your macro stays stably the same in the future, as long as it's something that you only ever do manually yourself in the terminal right there and then, and not something that a stable test script depends on or something that needs to work when a crate is used as a dependency.

There's also the alternative of tooling. If your tooling automatically reacts to missing muts and automatically introduces them for you, then this problem shouldn't bother you anymore. Instead of reviewing the changes as they are made (the modus of operation when manually having to fix an error with a suggested change), you could focus on what you focus on, and then review all the automatic implied changes after the fact in version controls. The same kind of mechanism could automatically detect and fix mismatching function return types, or even missing semicolons. Tooling could even be provided by the compiler itself, I'm imagining cargo check with extra flags could accept and automatically modify code with missing mut, missing semicolon or missing or incorrect type signature on const if you want that.

17 Likes

It's a hard compile error to violate a const (final in Java) in an obvious manner.

In Java, final purely applies to the binding itself; you can't reassign the binding, but anything else is allowed (including reassigning members of the object). I don't think it has any impact on bytecode or the bytecode emitted, other than the compile-time hard error if you try to reassign the binding.

In C and C++, const impacts the memory model. If you have const storage, it is Undefined Behavior to write to that storage. (The rough equivalent in Rust would be that let x = 5; let p = &raw mut x; *p = 10; is UB because let x is used rather than let mut x, not due to any aliasing rules.) This means that the storage of auto x and auto const x are actually different in the C++ virtual machine.

@kornel's main argument comes from the fact that Rust doesn't have such a distinction.

The problem with that is that if "it compiles with these flags," there will be projects that just say "enable these flags." That's basically the whole reason we don't allow nightly features on stable at all; to make it a significant enough bump against accidentally de facto supporting something.

cargo fix already can apply any suggestion that's tagged as machine applicable; I'm fairly sure that a missing mut is a machine applicable suggestion. cargo fix is usually suggested in the context of fixing warnings on working code, but can be used just as well on errors.

3 Likes

One reason we keep certain things as hard errors rather than lints: it establishes a baseline that you can safely assume about other people's code, since it can't be turned off. And as a result, that baseline can become part of people's mental model of Rust itself, rather than something that might or might not be true in any given codebase.

We have to take care to not use that lightly, because that places work on all users of Rust to maintain code to that baseline. But there are cases where we do. We don't allow using one integer type where another was expected. We don't allow certain operations outside an unsafe block. We don't allow mutating operations without mut.

Also, note that the reverse (unnecessary mut) is a lint. So you can always err on the side of leaving mut in as you refactor, and you'll get warnings rather than hard errors.

I think the standard we should apply is asking whether something is part of the baseline that people should be able to assume about all Rust code, and if that's worth the tradeoff of requiring that baseline of all Rust users.

31 Likes

Did you ever feel you're genuinely missing it?

I'd like for this to become warn by default. I know that I have made similar mistakes many times because in my mind ownership implies permission to mutate. In terms of learning Rust, I could also see how requiring mut on owned bindings could be confusing for the same reason.

Additionally, I feel like the culture not to commit code with warnings is strong enough in today's Rust ecosystem that the end result would still be the same thing.

2 Likes

You could just as well #![allow(...)] the warning and you have code without warnings. I believe that it's 100% guaranteed going to be the case that some people, particularly people coming from programming languages where mutable variables is the default, will just decide that the mut seems unnecessary, or too long, or whatever, and happily disable the warning and write their code without any let mut at all.

5 Likes

Recent zulip conversation about this: Idea: Downgrade "immutable variable“ errors to lints · t-lang · Zulip Chat Archive

2 Likes

On the reverse side: there are often times I wish C++ didn't have this rule, and const/mutable were just compile time constructs. (I still don't understand what std::launder means other than it has something to do with the fact that const members and placement new don't mix well.) I know the general reason the rule exists and fight for const correctness, but it's just another subtle footgun.

For Rust, it doesn't need there to be a language level distinction between a "let place" and a "let mut place," because instead the distinction can be drawn from "has &mut taken." (And it needs to take into account UnsafeCell of course; what would be mutable in C++.) The borrowing rules about when it's acceptable to write through a pointer supercede the rules that a "const place" can't be written to.

However, even though the language itself doesn't need the marker, I still believe it's a very useful indicator to the developer of intent. To me, that says lint, not hard error, though.


One point I want to bring up in favor of making mut a lint error: the "DerefMut inconsistency."

Because &mut T is a built in type, you can let x: &mut _; &mut *x; But for any other impl DerefMut, let x: impl DerefMut; &mut x; is an error, because you need to take a mutable reference to x in order to call deref_mut and get &mut T.

This does slow me down an edit-check cycle quite often, actually, as I'll do let mut for a &mut or let for an impl DerefMut when I'm juggling between code holding direct or wrapped handles to objects.

Ultimately, I don't think the papercut is big enough nor persistent enough to make mut a downgradable lint rather than a hard error. There's also a direct danger that making mut optional on bindings will accidentally give some people the confidence to treat it as a lint and not UB to drop the mut from references as well, so that's a point against.

There's legitimate arguments for making mut on patterns a lint rather than a hard error. I just honestly don't see the benefit outweighing the complexity and potential harm.

(There's also a slippery slope of then supporting let const. We also already deal with people struggling with trying to use const as let, without let being let mut.)

3 Likes

I just peeked back into that zulip discussion (I think I took a quick look at the time as-well), and thought about the point that downgrading from owned access to mutable access requires adding a mut. E.g. for calling an FnMut, you'll need to explicitly move (weird syntax) or add mut to the variable, while an FnOnce can be called just like that.

If let mut vs. let is supposed to be distinguished by the guarantee that the latter cannot be mutated between the initialization of the variable and any of its use cases (ignoring interior mutability of course), then I think there is not really any reason against allowing a single, final mutable access of the variable. I mean, the variable does not observably change if it isn't ever used again.

I don't know how hard that's to implement, but I wouldn't mind if something like

fn foo(f: impl FnMut()) {
    f();
}

just worked and you wouldn't need to do

fn foo(mut f: impl FnMut()) {
    f();
}

or perhaps

fn foo(f: impl FnMut()) {
    ({f})();
}

instead.

Note that I'm saying that

fn foo(f: impl FnMut()) {
    f();
    f();
}

should still fail. And also note that

fn foo(f: impl FnMut()) {
    f();
    /* more code, not using `f` */
}

should not move and drop f earlier than usual, f is still only dropped at the end of foo's body.


This could be considered similar to how temporaries can be mutated - effectively - only once. (Because you can only write the expression that creates them into a single place.) Although there's the additional power of allowing any kind of immutable access before the final mutable access.

Of course, taking a reference once is a single mutation in this logic, so

fn main() {
    let x = 42;
    let r = &mut x; // single (final) mutable access of `x`
    *r += 1;
    println!("{r}");
    *r += 1;
    println!("{r}"); 
}

would work just fine; and it's similar to how

fn main() {
    let r = &mut 42;
    *r += 1;
    println!("{r}");
    *r += 1;
    println!("{r}"); 
}

works, too, without a single let mut.


Of course the increased complexity that we'd get from such a change with its inherent increased difficulty of teachability, etc., for a feature that's entirely irrelevant for memory safety purposes (i.e. there's no big benefits from adding this complexity) means that all of the above is maybe a bad idea, I suppose.

10 Likes

I really like this idea. It reminds me of the NLL change the the 3-pronged errors. The problem isn't that you changed it, it's that you changed it then something saw that change when you hadn't marked it as mut to warn the reader.

I also like the point that this is like discarding ownership. If the last use could automatically decay to &mut -- the same way you can let &mut decay to & -- then far fewer muts would be needed. That probably can't solve the closure case, though, since FnOnce and FnMut both have the same syntax.

Tentative +1. As an exercise, I tried solving aoc2020 in a “run compiler only once” style. That is, instead of my usual iterative dialog with the compiler, I stared carefully at each solution and run cargo run only when I was reasonably convinced that it’d just work. I’ve documented all the bugs here: GitHub - matklad/aoc2020. “missing mut” is by far the most common problem.

This is subtle, but I think it implies that mut not only “gets in a way”, but also doesn’t prevent bugs. If I am missing a tonne of mut despite carefully reading the code multiple times with the explicit goal of not having any kind of errors, that means that mut just isn’t an input to my mental model of how the code works.

A separate thing, I also had a bug yesterday where I added mut to the variable, where I should have rather changes &mut v to &v down the line. That is, fixing missing mut is so frequent and automatic that, when it actually points at a bug, that signal gets lost in all the noise of mut-tax.

8 Likes

I am obviously strongly against any form of this. As your very own post points out, mut is not part of the memory model. It's there for programmers. The lack of mut equals a guarantee that the given binding is (logically) immutable ("physical", actual mutability can't be prevented because of the mere existence of interior mutability).

This is paramount for efficient and painless code review. I would hate if it could be turned off – that would mean a sudden loss of a whole bunch of guarantees which are not strictly about memory safety, but other, domain-specific invariants.

Let's not forget that Rust is not "only" about memory safety. There are other aspects to correctness, and we should embrace them rather than fight them.

23 Likes

I'm not sure how actionable the outcome of that is, in terms of language design. For example, I'm really good at writing struct Foo { x: i32, y: i32 }; or struct Foo { x: i32; y: i32; }, and could easily miss that even re-reading, but that's ok, not something that needs to be changed in the grammar. I have the compiler to help me, and I want to take full advantage of that.

I do actually agree that I rarely find the "you need to go add mut" to catch bugs. But its contrapositive does -- if I marked something mut but didn't modify it, I probably did something wrong. But that can't happen if I just never write mut because my unit tests run without it, and just add it later.

13 Likes

It's rather niche, but final in Java enables constant folding. For instance, this program will output 97:

class Example {
    public static void main(String[] args) {
        int a = 97;
        System.out.println(true ? a : 'c');
    }
}

While this program will output a.

class Example {
    public static void main(String[] args) {
        final int a = 97;
        System.out.println(true ? a : 'c');
    }
}

Also, in the past, Java required bindings to be declared final for anonymous class binding capture, however with Java 8 and its introduction of "effectively final" this is no longer necessary - compiler will automatically infer final when possible.

2 Likes

Reading this, I think I didn't get across what I wanted to entirely, so let me try again :slight_smile: I do agree with your example, and that "syntactic" aspects of mut is just something that compiler can help with.

But to me, "I get mut wrong syntactically" points at a deeper issue. The "theory of mut" is that "it helps the programmer to read the code, as it makes it more clear what is and what is not mutated". Comment by H2CO3 above is a good explanation why mut should be useful. My experience is an indirect evidence that these theoretical benefits don't pan out for me. They show that I can't get mut right even in correct code when I try hard. By extension, this means that, in buggy code, mut won't help me to spot the bug.

1 Like