Clarification on conditional assignment

Hi everyone!

I hope I don't come of as oblivious. I'm currently following the Interactive Rust Book and one of the quizzes presented in the topic of Control Flow in Rust is this:

let x;

if cond {

  x = 1;

} else {

  x = 2;

}

Although it was explained briefly like this:

Note that Rust does not require x to be initially declared with let mut in the second snippet. This is because Rust can determine that x is only ever assigned once, since only one branch of the if-statement will ever execute.

My question then is, how come this is valid Rust code, and x is assigned a value while we didn't declare x to be mutable? Isn't semicolon terminates the previous expression and that the next one is ready to begin?

An immutable variable can be assigned once (per execution path) to initialize it. It does not count as mutation because there was no value at all before the assignment.

4 Likes

"Mutation" means "assigning more than once", not just assigning at all.

let x here is declaration but it is not assignment.

Then either x = 1 or x = 2 executes, performing an assignment. The compiler can prove two important facts using control flow analysis:

  1. Exactly one of the branches from the if/else statement will execute.
  2. Each branch assigns to x exactly once.

Therefore the compiler can safely conclude not only that x is not assigned more than once (i.e. it is not mutated) but also that it definitely is assigned for the first time (i.e. it is initialized) for all subsequent code after the if/else statement, because all subsequent code is "dominated by" a block that initializes x.

The following sequences would not work because they each violate at least one of the constraints I described above:

// both declaration _and_ assignment in a single statement,
// so `x` is immediately initialized.
let x = 1;
if cond {
    // This branch is "dominated by" x = 1, so x is
    // already initialized...
    x = 2; // ... so this counts as a mutation, and is disallowed. 
} else {
    // This branch is "dominated by" x = 1, so x is
    // already initialized...
    x = 3;  // ... so this counts as a mutation, and is disallowed. 
}
let x; // x is declared but not initialized
if cond {
  // No predecessor of this block already initialized x,
  // so x is uninitialized on entry.
  x = 0; // this assignment is an initialization of x
  // x is initialized on exit...
}
// ...but we can't use x here because `x = 0` has
// not necessarily run here, so the compiler can't
// prove that x is definitely initialized.
print(x); // compile error here

In thinking about this, keep in mind that the compiler is not executing the program but is instead modelling the program: it's gathering information about what facts are known to be true on entry or exit from each branch of the program and then making checks against those facts. And the compiler is not considering each statement in isolation but is rather tracking facts about each variable on entry or exit of each block[1].

One possible fact is "x has definitely been initialized (assigned once)", and that fact is used both in the rule blocking multiple assignment of a non-mutable variable and for blocking use of a variable that has not been initialized yet.

I am not deeply familiar with the Rust compiler implementation of this in particular, but I assume it's handling this using some form of data-flow analysis to propagate the "is assigned" fact between blocks, so the theory around that concept might be helpful if you aren't already familiar.


  1. (and between statements in a block too, but that's an easier question because statements in a single block always execute sequentially, at least in the abstract model.) ↩︎

1 Like

(This topic may be better suited to URLO.)

Note that you cannot assign more than once even if no value is overwritten -- even if you're reinitializing a variable which was initialized at some point prior.

    let mut s = String::from("hi");
    let _move_out_of_s = s;
    // The compiler will not let you use `s` here as its value has been
    // moved.  `s` is effectively no longer initialized.
    // println!("{s}"); // error[E0382]: borrow of moved value: `s`
    
    // This line is only allowed because `s` was declared with `mut`.
    s = String::from("hello");
    // If you remove the `mut` you get an error instead:
    // error[E0384]: cannot assign twice to immutable variable `s`

    // Now `s` is usable again.
    println!("{s}");

Sometimes it's possible to use variable shadowing instead of mut.

    let s = String::from("hi");
    let _move_out_of_s = s;

    // Here we introduce a new variable with the same name.
    let s = String::from("hello");
    println!("{s}");
    
    // You can shadow variables even if they're still initialized and
    // even if the new variable has a different type.
    let s = 42;
    println!("{s}");

Not having mut doesn't prevent moving values, including to a new mut variable.

    let s = String::from("hi");
    let mut s = s;
    s.push('!');
    println!("{s}");

In short, the new things declaring a binding (variable) as mut allows you to are

  • assigning more than once
    • including overwriting values and reinitializing the variable
  • creating a &mut _ to the variable

But you can initialize a variable once, move it, shadow it, and take a &_ to it, all without mut.


Incidentally, thoughout this topic and throughout most of the book, an "immutable vs mutable" terminology has been used. But Rust's model is actually more "shared vs exclusive". For example, late in the book, they reveal that sometimes values can be mutated behind &_ references.[1]

So you may wish to consider more of a shared vs exclusive perspective. I wish I had been introduced to the concept earlier in my Rust learning as it would have avoided a lot of seeming incongruity and relearning.

E.g. the following compiles, as none the mut x abilities (&mut x, reassignment) are required.

    let x = Cell::new(0);
    assert_eq!(0, x.get());
    x.set(1);
    assert_eq!(1, x.get());

(This example is contrived, but you'll inevitably run into shared mutability at some point,[2] as it underlies shared ownership, the multithreaded primitives, and many interactions with the operating system.)


  1. and that values can have multiple owners ↩︎

  2. albeit perhaps in encapsulated form -- println! is actually one such example ↩︎