Idea: `mut` block

I'm not sure whether this has been raised before. In the past i had suggested that missing mut in patterns could be downgraded to a deny-by-default lint. Over time i have changed my mind, now here's my recent thinking:

We could have a postfix mut block, evaluating to () as result, that temporarily make a place (like local variables, etc) mutable. (Syntax subject to bikeshedding)

Example:

let ds = DataStructure::new();
ds.mut {
    // this set up internal state within `ds`,
    // without using `OnceLock` and friends.
    ds.initialize_with(&env);
};
ds.query_a();
ds.query_b();

This allows much more fine-grained mutable scopes, without breaking the intention that ds should be shared but not mutable during the following queries. I believe this is much more inline with Rust's original design, than "missing mut in patterns be downgraded to a deny-by-default lint" or something similar.

Of course today this effect is also achievable with:

let ds = {
    let mut ds = DataStructure::new();
    ds.initialize_with(&env);
    ds
};
ds.query_a();
ds.query_b();

But this requires initialize_with be called immediately, which is a lot less flexible.

2 Likes

It’d be pretty straightforward to build this as a macro that shadows the original name and then shadows the shadow to remove the mut again. But honestly this feels like an excess of caution to me. I’d make it mut to begin with, and maybe reset it to non-mut once (with an explicit shadow) when done.

mut!(ds, {
});
6 Likes

I don't see how that's any different from your example above, where you also call initialize_with immediately. It seems that you're basically proposing a sugar for the existing pattern of shadowing a binding with a different mutability.

1 Like

I don't think this can work due to hygiene. The best you could do is re-shadow it explicitly:

let foo = bar();

let foo = mutate!(foo, { ... });

which you could also do with tap_mut:

let foo = bar();

let foo = foo.tap_mut(|foo| { ... });
3 Likes

It does work the way @jrose proposed.

Hygenie does not prevent the macro from accessing external variables, as long as the identifier token itself came from the same location as the variable:

Playground

The only thing hygenie prevents is trying to access an external variable with an internal name or vice versa.

2 Likes

Huh, yeah, I see. Of course at the syntactic level it doesn't matter that it's a new let binding as long as it's the same identifier brought from outside. Thanks for the correction.

Would this compile? What would it print?

    let a: u32 = 1;
    let p = &a;
    a.mut { a = 2; }
    println!("{}", *p);

If it's supposed to be equivalent to shadowing this should compile and print 1, but the a.mut notation suggests mutating a rather than shadowing, so it would be super confusing.

6 Likes

Here's another tricky example:

let a = 1;
if true {
    a.mut { a = 2; }
}
println!("{a}");

If the mut block is equivalent to shadowing then it would shadow a only inside the if, resulting in 1 being printed in the end.

9 Likes

Hmm, interesting.

I've been talking for a while about wanting to replace FRU with someting more like this. As a sketch,

let ds = DataStructure::new() ☃ {
     field1: 10,
     field3: None,
};

But looking at your example makes me ponder phrasing that differently::

let ds = DataStructure::new() ☃ {
     .field1 = 10;
     .field3 = None;
};

Because that extends more naturally to

let ds = DataStructure::new() ☃ {
     .field1 = 10;
     .set_name("bob");
};

and such.

But I do think it's important that it not require repeating the name of the local every time, because that discourages longer, more meaningful local names.

If this would have to repeat ds everytime, I think I'd rather just leave it as

let data_structure = {
    let mut x = DataStructure::new();
    x.initialize_with(&env);
    x.a = 10;
    x
};

which really isn't that far off from the proposal here -- it's just got a let mut instead of a .mut.

And, TBH, I'm not sure what's supposed to happen with

let ds = DataStructure::new();
ds.query_a();
ds.mut {
    // this set up internal state within `ds`,
    // without using `OnceLock` and friends.
    ds.initialize_with(&env);
};
ds.query_b();

Is that shadowing ds afterwards? Was it secretly mut the whole time?

Feels clearer to not introduce the final "immutable" name until after the mutations are done.

TL;DR: don't overthink it.

Sorry, I don't like this idea, to me it's a step back in readability and grokability of the code. The whole point of having mut ds is to inform the reader that ds can and will change at some point in time (and if it doesn't, compiler will warn you about redundant mut), so that they don't falsely assume any invariants. VSCode with rust-analyzer underscores mutable bindings for that reason, so you can quickly scan the code for mutable variables without effort. Manually transferring ownership into a temporary scope and back to mutate and then shadowing the original ds is explicit and easy to follow. With your proposal, it would look like even immutable variables can somehow mutate. On top of that, the more you use it, the more confusing it looks:

let ds = DataStructure::new();
ds.mut {
    ds.initialize_with(&env);
};
ds.query_a();
ds.mut {
    ds.update(parameter);
};
ds.query_b();

But it doesn't even save lines of code, if you don't needlessly introduce a scope.

let mut ds = DataStructure::new();
ds.initialize_with(&env);
let ds = ds;
ds.query_a();
ds.query_b();

The above is both shorter and cleaner than this

let ds = DataStructure::new();
ds.mut {
    ds.initialize_with(&env);
};
ds.query_a();
ds.query_b();

Example in VSCode:

You can clearly see when data is mutable and when it's not. There are explicit bindings, to which the editor will jump when you Ctrl+click on relevant data occurrences. No magic, no confusion.

5 Likes