5.5 Variables and Mutability

Variables associate names with data stored in memory.

5.5.1 Declaring Variables

Use the let keyword to declare a variable and initialize it.

#![allow(unused)]
fn main() {
let message = "Hello"; // Declare 'message', initialize it with "Hello"
let count = 10;       // Declare 'count', initialize it with 10
}

A Note on Terminology: “Binding”

You will frequently encounter the term “binding” in Rust literature (e.g., “variable binding,” “let binds a value to a name”). This term emphasizes that let creates an association between a name and a value or memory location.

While accurate, especially when discussing immutability, shadowing, or references, the term “binding” might feel slightly abstract for simple cases like let x: i32 = 5; if you’re used to C’s model where the variable x is the memory location holding 5. In such simple cases, thinking of let as declaring a variable and initializing it with a value is perfectly valid and perhaps more direct.

This chapter will often use simpler terms like “declare,” “initialize,” “assign,” or “holds a value” for basic variable operations, while reserving “binding” for contexts like immutability or shadowing where it adds clarity. Be aware that other Rust resources heavily use “binding” in all contexts.

5.5.2 Immutability by Default

By default, variables declared with let are immutable. Once initialized, their value cannot be changed.

fn main() {
    let x = 5;
    println!("The value of x is: {}", x);
    // x = 6; // Compile Error: cannot assign twice to immutable variable `x`
}

This design choice encourages safer code by preventing accidental modifications and making program state easier to reason about, especially important for concurrency. We refer to let x = 5; as creating an immutable binding.

5.5.3 Mutable Variables

To allow a variable’s value to be changed after initialization, declare it using let mut.

fn main() {
    let mut y = 10;
    println!("The initial value of y is: {}", y);
    y = 11; // OK, because y was declared as mutable
    println!("The new value of y is: {}", y);
}

Use mut deliberately when you need to change a variable’s value. Prefer immutability when possible.

5.5.4 Type Annotations and Inference

Rust’s compiler features powerful type inference. It can usually determine the variable’s type automatically based on the initial value and how the variable is used later.

#![allow(unused)]
fn main() {
let inferred_integer = 42;       // Inferred as i32 (default integer type)
let inferred_float = 2.718;     // Inferred as f64 (default float type)
}

However, you can (and sometimes must) provide an explicit type annotation using a colon (:) after the variable name.

#![allow(unused)]
fn main() {
let explicit_float: f64 = 3.14;  // Explicitly typed as f64
let count: u32 = 0;              // Explicitly typed as u32

// Annotation needed when type isn't clear from initializer or context
let guess: u32 = "42".parse().expect("Not a number!");

// Annotation needed if declared without immediate initialization
let later_initialized: i32;
later_initialized = 100; // OK now
}

Annotations are required when the compiler cannot uniquely determine the variable’s type from its initialization and usage context (a common example is with functions like parse() which can return different types based on the annotation).

5.5.5 Uninitialized Variables

Rust guarantees, through compile-time checks, that you cannot use a variable before it has been definitely initialized on all possible code paths.

fn main() {
    let x: i32; // Declared but not initialized

    let condition = true;
    if condition {
        x = 1; // Initialized on this path
    } else {
        // If we comment out the line below, the compiler will complain
        // because 'x' might not be initialized before the println!.
        x = 2; // Initialized on this path too
    }

    // OK: The compiler knows 'x' is guaranteed to be initialized by this point.
    println!("The value of x is: {}", x);

    // let y: i32;
    // println!("{}", y); // Compile Error: use of possibly uninitialized variable `y`
}

This check eliminates a common source of bugs found in C/C++ related to reading uninitialized memory. Note that compound types like tuples, arrays, and structs must generally be fully initialized at once; partial initialization is usually not permitted for safe Rust code.

5.5.6 Constants

Constants represent values that are fixed for the entire program execution and are known at compile time. They are declared using the const keyword.

  • Must have an explicit type annotation.
  • Must be initialized with a constant expression – a value the compiler can determine without running the code (e.g., literals, simple arithmetic on other constants).
  • Conventionally named using SCREAMING_SNAKE_CASE.
  • Can be declared in any scope, including the global scope.
  • Are effectively inlined by the compiler wherever they are used. They don’t necessarily occupy a specific memory address at runtime.
const SECONDS_IN_MINUTE: u32 = 60;
const MAX_USERS: usize = 1000;

fn main() {
    let total_seconds = 5 * SECONDS_IN_MINUTE;
    println!("Five minutes is {} seconds.", total_seconds);

    let user_ids = [0u32; MAX_USERS]; // Use const for array size
    println!("Max users allowed: {}", MAX_USERS);
    println!("User ID array size: {}", user_ids.len());
}

Use const for values that are truly fixed, program-wide parameters or mathematical constants.

5.5.7 Static Variables

Static variables (static) also represent values that live for the entire duration of the program ('static lifetime), but unlike const, they have a fixed, single memory address. Accessing a static variable always reads from or writes to that specific location.

  • Must have an explicit type annotation.
  • Immutable statics (static) must be initialized with a constant expression (similar to const).
  • Mutable statics (static mut) exist but are inherently unsafe due to potential data races in concurrent programs. Accessing or modifying a static mut requires an unsafe block. Their use is strongly discouraged in favor of safe concurrency primitives like Mutex, RwLock, or atomics (AtomicU32, etc.).
  • Conventionally named using SCREAMING_SNAKE_CASE.

Note: By default, the latest Rust compiler refuses to compile code that uses static mut variables:

// Immutable static: lives for the program duration at a fixed address.
static APP_VERSION: &str = "1.0.2";

// Mutable static: requires unsafe to access (AVOID IF POSSIBLE).
static mut REQUEST_COUNTER: u32 = 0;

fn main() {
    println!("Running version: {}", APP_VERSION);

    // Accessing/modifying static mut requires unsafe block.
    // This is generally bad practice without proper synchronization.
    unsafe {
        REQUEST_COUNTER += 1;
        println!("Requests processed (unsafe): {}", REQUEST_COUNTER);
    }
    unsafe {
        REQUEST_COUNTER += 1;
        println!("Requests processed (unsafe): {}", REQUEST_COUNTER);
    }

    increment_safe_counter(); // Prefer safe alternatives
    increment_safe_counter();
}

// A safer way to handle global mutable state using atomics
use std::sync::atomic::{AtomicU32, Ordering};
static SAFE_COUNTER: AtomicU32 = AtomicU32::new(0);

fn increment_safe_counter() {
    // Atomically increment the counter
    SAFE_COUNTER.fetch_add(1, Ordering::Relaxed);
    println!("Requests processed (safe): {}", SAFE_COUNTER.load(Ordering::Relaxed));
}

const vs. static:

  • Use const when the value can be computed at compile time and you want it inlined directly into the code (no fixed address needed).
  • Use static when you need a single, persistent memory location for a value throughout the program’s lifetime (like a C global variable). Only use static mut within unsafe blocks and with extreme caution, preferably replacing it with safe concurrency patterns.

5.5.8 Shadowing

Rust allows you to declare a new variable with the same name as a previously declared variable within the same or an inner scope. This is called shadowing. The new variable declaration creates a new binding, making the previous variable inaccessible by that name from that point forward (or temporarily, within an inner scope).

fn main() {
    let x = 5;
    println!("x = {}", x); // Prints 5

    // Shadow x by creating a new variable also named x
    let x = x + 1; // This 'x' is a new variable, initialized using the old 'x'
    println!("Shadowed x = {}", x); // Prints 6

    {
        // Shadow x again in an inner scope
        let x = x * 2; // This is yet another 'x', local to this block
        println!("Inner shadowed x = {}", x); // Prints 12
    } // Inner scope ends, its 'x' binding disappears

    // We are back to the 'x' from the outer scope (the one holding 6)
    println!("Outer x after scope = {}", x); // Prints 6

    // Shadowing is often used to transform a value while reusing its name,
    // potentially even changing the type.
    let spaces = "   ";       // 'spaces' holds a &str (string slice)
    let spaces = spaces.len(); // The name 'spaces' is re-bound to a usize value
    println!("Number of spaces: {}", spaces); // Prints 3
}

Shadowing differs significantly from marking a variable mut. Mutating (let mut y = 5; y = 6;) changes the value within the same variable’s memory location, without changing its type. Shadowing (let x = 5; let x = x + 1;) creates a completely new variable (potentially with a different type) that happens to reuse the same name, making the old variable inaccessible by that name afterwards.

5.5.9 Scope and Lifetimes

A variable is valid (or “in scope”) from the point it’s declared until the end of the block {} in which it was declared. When a variable goes out of scope, Rust automatically calls any necessary cleanup code for that variable (this is part of the ownership and RAII system, detailed later).

fn main() { // Outer scope starts
    let outer_var = 1;
    { // Inner scope starts
        let inner_var = 2;
        println!("Inside inner scope: outer={}, inner={}", outer_var, inner_var);
    } // Inner scope ends, 'inner_var' goes out of scope and is cleaned up

    // println!("Outside inner scope: inner={}", inner_var);
    // Compile Error: `inner_var` not found in this scope
    println!("Back in outer scope: outer={}", outer_var);
} // Outer scope ends, 'outer_var' goes out of scope and is cleaned up

5.5.10 Declaring Multiple Variables (Destructuring)

While C allows int a, b;, Rust typically uses one let statement per variable. However, Rust supports destructuring assignment using patterns, which is often used with tuples or structs to initialize multiple variables at once.

fn main() {
    let (x, y) = (5, 10); // Destructure the tuple (5, 10)
                         // This binds x to 5 and y to 10

    println!("x={}, y={}", x, y);
}

We will see more advanced uses of patterns and destructuring later.