Variables, mutability and types
In Rust a binding is created with let, and it is immutable unless the keyword mut says otherwise. That one decision removes a whole class of bugs, and it is the first place where Rust asks for a keyword that C++ does not have: in C++ a variable is writable by default and const is the exception.
Coming from C++? Variables and mutability puts the two habits next to each other and shows what the compiler says to each.
This lesson covers the forms a binding can take, the types a first program needs, and two rules that surprise people: a variable must be initialised before it is read, and arithmetic overflow behaves differently in a debug build and a release build.
let, mut and immutability
let count = 0; creates a binding that cannot be assigned again. The compiler does not merely warn: an assignment to it is E0384, and the help line shows the exact edit, adding mut after let. The message also names the first assignment, which is the line the new one contradicts.
With mut the binding can be updated, and the value keeps its type. Mutability is a property of the binding, not of the value: two bindings can point at the same data while only one of them is allowed to change it, which is the subject of the lesson on mutable borrows.
fn main() {
let mut count = 0;
count += 1;
count += 1;
println!("count: {count}");
}
fn main() {
let count = 0;
count += 1;
println!("count: {count}");
}
rustc --edition 2024 mutable.rs
./mutable
rustc --edition 2024 immutable.rs
count: 2
error[E0384]: cannot assign twice to immutable variable `count`
--> immutable.rs:3:5
|
2 | let count = 0;
| ----- first assignment to `count`
3 | count += 1;
| ^^^^^^^^^^ cannot assign twice to immutable variable
|
help: consider making this binding mutable
|
2 | let mut count = 0;
| +++
error: aborting due to 1 previous error
For more information about this error, try `rustc --explain E0384`.
The second build fails and leaves no binary, while the first one printed its line. Nothing is implicitly mutable, so a function cannot change a value the caller did not hand over as mutable.
Shadowing
A second let with the same name creates a new binding that hides the old one for the rest of the scope. The old value is not changed and it is not an error: the name simply refers to something else from that line on, which is why the new binding may even have a different type.
The program below starts with a string slice, then shadows it with the length, which is a usize. The same name, a different type, and no cast. This is the usual way to turn a parsed value into its result without inventing names such as spaces_len.
fn main() {
let spaces = " ";
let spaces = spaces.len();
println!("spaces: {spaces}");
}
rustc --edition 2024 shadow.rs
./shadow
spaces: 3
Shadowing is not assignment. With mut the type would have to stay the same, and the old value would be gone; with shadowing the old value is still there, hidden, until the scope ends. The lesson on Drop and scope uses that difference.
Types and inference
The scalar types are the ones a first program needs: signed and unsigned integers from 8 to 128 bits, f32 and f64, bool and char. A char is four bytes because it is a Unicode scalar value, not a byte, and the lesson on char and bits explains what that costs.
An annotation is needed only where the compiler cannot infer the type. Integer literals default to i32 and float literals to f64, and size_of turns the widths into numbers. The underscores in the large literal are separators and change nothing.
fn main() {
let small: i8 = -128;
let big: u64 = 18_446_744_073_709_551_615;
let ratio: f64 = 0.75;
let letter: char = 'R';
let flag: bool = true;
println!("{small} {big} {ratio} {letter} {flag}");
println!(
"sizes: i8={} i32={} u64={} f64={} char={}",
std::mem::size_of::<i8>(),
std::mem::size_of::<i32>(),
std::mem::size_of::<u64>(),
std::mem::size_of::<f64>(),
std::mem::size_of::<char>()
);
}
rustc --edition 2024 types.rs
./types
-128 18446744073709551615 0.75 R true
sizes: i8=1 i32=4 u64=8 f64=8 char=4
The sizes are the ones every target with a conventional ABI uses, and usize is the pointer width, so it differs between a 32-bit and a 64-bit target. A literal that does not fit its type is a compile error, not a truncation.
Uninitialised values
A declaration without a value is allowed as long as the compiler can see an assignment before every read. Reading it first is E0381, and the message names the declaration, the read and the fix. There is no "undefined value" in safe Rust: the compiler either proves the initialisation or refuses the program.
This is the opposite of the C++ rule, where a local without an initialiser holds whatever the stack held. The check happens at compile time and costs nothing at run time.
fn main() {
let total: i32;
println!("{total}");
}
rustc --edition 2024 uninit.rs
error[E0381]: used binding `total` isn't initialized
--> uninit.rs:3:16
|
2 | let total: i32;
| ----- binding declared here but left uninitialized
3 | println!("{total}");
| ^^^^^ `total` used here but it isn't initialized
|
help: consider assigning a value
|
2 | let total: i32 = 42;
| ++++
error: aborting due to 1 previous error
For more information about this error, try `rustc --explain E0381`.
The declaration itself is not the mistake, which is why the arrow points at the print and the note points back at the declaration. A variable declared and then assigned inside both branches of an if is accepted, because every path initialises it.
const and static
const declares a value that is computed at compile time and pasted wherever it is used; it has no address and no storage of its own. static declares a value with one address for the whole program. Both are immutable, both need a type, and a mutable static is unsafe to touch.
The naming convention is upper case for both. The program copies the constant into a local binding and then mutates the copy, which is allowed because the copy is a new value; the static is read through a reference without copying the string.
const MAX_POINTS: u32 = 100_000;
static GREETING: &str = "hello";
fn main() {
let mut points = MAX_POINTS;
points += 1;
println!("{GREETING}, max was {MAX_POINTS}, now {points}");
}
rustc --edition 2024 constants.rs
./constants
hello, max was 100000, now 100001
A constant may be used in another constant, in an array length and in a match pattern, because its value is known before the program runs. The lesson on const fn goes further and computes values with functions at compile time.
Overflow in debug and release
The largest u8 is 255, and adding one to it has no correct value. A debug build panics with a message that names the operation; a release build, compiled with -O, wraps around to zero and says nothing. Both are defined behaviour, and the difference is a build flag.
That is why the same program can be correct on your machine and wrong on a server: one was built for debugging and the other for speed. The checked, wrapping and saturating methods on the integer types make the choice explicit in the source, which the lesson on numbers shows.
fn main() {
let mut value: u8 = 255;
value += 1;
println!("value: {value}");
}
rustc --edition 2024 overflow.rs
./overflow
rustc -O --edition 2024 overflow.rs -o overflow_release
./overflow_release
thread 'main' (3415) panicked at overflow.rs:3:5:
attempt to add with overflow
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
value: 0
The first run ends with status 101, the panic status, and the thread id in brackets changes per run. cargo build --release sets the same flag as -O, and the lesson on build variants lists what else changes between the two profiles.
Exercises
- Make the failing program in the first section compile with the smallest change, then explain why the compiler needed it.
- Shadow a bool with an integer and print both values before and after. What does the first binding refer to after the second let?
- Compile overflow.rs with -C overflow-checks=off and without -O. Does it panic, and what does that say about which flag really controls the check?
Questions
A new set of questions in a new order every time. Choose one answer for each, then check.
-
What is the difference between
mutand shadowing?Answer
mut changes the value and keeps the type; shadowing creates a new binding of any type
A mutable binding keeps its type and its storage; a shadowing let creates a new binding that hides the old one.
-
What is the type of
xinlet x = 5;without an annotation?Answer
i32
An integer literal with nothing to constrain it defaults to i32.
-
What happens on an integer overflow in a debug build?
Answer
The program panics at run time
Debug builds keep overflow checks and panic, which is how the mistake is found early.
-
How does a
constitem differ from astaticitem?Answer
A const is inlined at every use and has no address; a static has one address
A constant is a value substituted where it is used, while a static is one place in memory.
-
What does this program print?
fn main() { let spaces = " "; let spaces = spaces.len(); println!("{spaces}"); }Answer
3
The second binding shadows the first with a usize, and the string has three characters.
-
What does this program print?
const LIMIT: u32 = 10; static NAME: &str = "rust"; fn main() { println!("{NAME}: {LIMIT}"); }Answer
rust: 10
Both names are printed by value, the static string first and the constant second.
-
What does this program print?
fn main() { println!( "sizes: i8={} i32={} u64={} f64={} char={}", std::mem::size_of::<i8>(), std::mem::size_of::<i32>(), std::mem::size_of::<u64>(), std::mem::size_of::<f64>(), std::mem::size_of::<char>() ); }Answer
sizes: i8=1 i32=4 u64=8 f64=8 char=4
The integer widths are the ones in their names, a float is eight bytes, and a char is four because it is a scalar value.
-
What is the first line of the compiler output for this program?
fn main() { let count = 0; count += 1; println!("{count}"); }Answer
error[E0384]: cannot assign twice to immutable variable
countThe binding has no mut, and the message counts the first assignment as the one that already happened.
-
What is the first line of the compiler output for this program?
fn main() { let total: i32; println!("{total}"); }Answer
error[E0381]: used binding
totalisn't initializedThe binding exists but has no value, and the error names the declaration and the read.
-
What does this program print?
fn main() { let mut total = 0; total += 1; println!("{total}"); }Answer
1
The binding is mutable, so the compound assignment is allowed and the value becomes one.
The code on this page was built with rustc 1.96.1 and run on Alpine Linux 3.24.3 (x86_64) in a Docker container.
Article author: Arthur Isaev