Rust · lesson 3 of 30
Beginner
15- 1 Introduction to Rust
- 2 Setting Up Rust
- 3 Variables in Rust
- 4 Data Types in Rust
- 5 Operators in Rust
- 6 Control Flow in Rust
- 7 Functions in Rust
- 8 Ownership in Rust
- 9 Borrowing and References
- 11 Structs in Rust
- 12 Enums in Rust
- 13 Pattern Matching in Rust
- 17 Collections in Rust
- 25 Testing in Rust
- 26 File I/O in Rust
Intermediate
9Advanced
6Variables in Rust
Understand let, mut, shadowing, constants, and type inference in Rust.
By SkillByExample · Updated
Declaring Variables with let
In Rust, variables are declared with the let keyword. By default they are immutable — once bound to a value, you cannot reassign them. This is a deliberate design choice: immutability is the safe default, and you explicitly opt into mutability when you need it. This catches a large category of bugs where a value is accidentally changed somewhere it shouldn’t be.
fn main() {
let x = 5;
println!("x = {}", x);
// x = 6; // ERROR: cannot assign twice to immutable variable
// The compiler error message even suggests adding `mut` if you need to change x
}
$ rustc variables.rs && ./variables
x = 5
Mutable Variables with mut
When a value genuinely needs to change — like a counter or an accumulator — add the mut keyword. Making mutability explicit in the variable declaration means anyone reading the code can immediately see which values are expected to change and which are fixed.
fn main() {
let mut count = 0;
count += 1;
count += 1;
println!("count = {}", count);
}
$ rustc variables_mut.rs && ./variables_mut
count = 2
Only use mut when the value genuinely needs to change. Unnecessary mutability is a code smell in Rust — it signals that a value might be changed anywhere in its scope, which makes reasoning about code harder.
Type Inference
Rust has a powerful type inference engine that eliminates most type annotations without sacrificing type safety. The compiler determines the type of a variable from how it is initialised and used, so you get the benefits of strong static typing without the verbosity of writing types everywhere. You can always add an explicit annotation for clarity.
fn main() {
let integer = 42; // inferred: i32
let float = 3.14; // inferred: f64
let boolean = true; // inferred: bool
let text = "hello"; // inferred: &str
// Sometimes inference needs help — annotate the variable
let parsed: i64 = "100".parse().unwrap();
// Or annotate directly on the literal with a suffix
let big = 1_000_000u64; // u64 suffix on the literal itself
println!("integer={integer}, float={float}, boolean={boolean}, text={text}");
println!("parsed={parsed}, big={big}");
}
$ rustc variables_inference.rs && ./variables_inference
integer=42, float=3.14, boolean=true, text=hello
parsed=100, big=1000000
You can always annotate explicitly: let x: i32 = 5;
Shadowing
Shadowing lets you redeclare a variable with the same name in the same scope. Each new let creates a fresh binding that hides the previous one for the rest of the scope. This is useful when you want to transform a value through several steps while keeping a clean, descriptive name throughout — rather than inventing new names like x_trimmed or x_parsed.
fn main() {
let x = 5;
let x = x + 1; // new binding that shadows previous x
let x = x * 2; // shadows again
println!("x = {}", x);
}
$ rustc variables_shadow1.rs && ./variables_shadow1
x = 12
Shadowing is more powerful than mut because it can change the type of the binding:
fn main() {
let spaces = " "; // type: &str
let spaces = spaces.len(); // type: usize — a completely different type, same name
println!("spaces = {}", spaces);
}
$ rustc variables_shadow2.rs && ./variables_shadow2
spaces = 3
With mut this would be a compile error because you cannot change a variable’s type through reassignment. Shadowing is commonly used for sequential transformations:
fn main() {
let input = " 42 ";
let input = input.trim(); // shadow: still &str, but whitespace stripped
let input: i32 = input.parse().unwrap(); // shadow: now an i32
println!("{}", input + 1);
}
$ rustc variables_shadow3.rs && ./variables_shadow3
43
Constants
Constants are values that are truly fixed for the lifetime of the program. They are declared with const, must have an explicit type annotation, and must be initialised with a constant expression that the compiler can evaluate at compile time. They can be defined at any scope — including at the module or global level — making them the right choice for values like configuration limits, mathematical constants, or magic numbers.
const MAX_POINTS: u32 = 100_000;
const PI: f64 = 3.141_592_653_589_793;
fn main() {
println!("max points: {}", MAX_POINTS);
println!("pi: {}", PI);
}
$ rustc variables_const.rs && ./variables_const
max points: 100000
pi: 3.141592653589793
Constants follow the naming convention SCREAMING_SNAKE_CASE. Unlike let bindings:
- Cannot use
mut - Must have an explicit type annotation
- Must be initialised with a constant expression (no runtime function calls, unless
const fn) - Live for the entire duration of the program
- Can be placed in any scope including global/module scope
Constants can reference other constants — the compiler evaluates the whole expression at compile time:
const HOURS_IN_DAY: u32 = 24;
const SECONDS_IN_DAY: u32 = HOURS_IN_DAY * 60 * 60; // fully computed at compile time
fn main() {
println!("{} seconds in a day", SECONDS_IN_DAY);
}
$ rustc variables_const2.rs && ./variables_const2
86400 seconds in a day
Static Variables
static variables are similar to const but have a fixed memory address for the lifetime of the program. Use static when you need a stable address (e.g., for FFI or global shared state) or when the value is too large to be inlined everywhere. Mutable statics require unsafe because they can cause data races.
static GREETING: &str = "Hello, world!"; // fixed address, lives for the whole program
fn main() {
println!("{}", GREETING);
}
$ rustc variables_static.rs && ./variables_static
Hello, world!
Prefer const over static unless you specifically need a stable address or interior mutability.
Numeric Literals
Rust allows underscores in numeric literals for readability, and supports several prefix formats. The underscore is purely cosmetic and can appear anywhere in the number.
fn main() {
let decimal = 1_000_000; // 1000000 — underscores improve readability
let hex = 0xFF; // 255
let octal = 0o77; // 63
let binary = 0b1111_0000; // 240
let byte = b'A'; // 65 (u8 only — byte literal)
let float = 1_234.567_8; // 1234.5678
println!("decimal={decimal}, hex={hex}, octal={octal}, binary={binary}, byte={byte}, float={float}");
}
$ rustc variables_literals.rs && ./variables_literals
decimal=1000000, hex=255, octal=63, binary=240, byte=65, float=1234.5678
Variable Scope
Variables are scoped to the block they are declared in. When the block ends, the variable goes out of scope and is dropped — its memory is freed. This deterministic, block-based scoping is the foundation of Rust’s ownership system, which you will explore in the ownership tutorial.
fn main() {
let outer = 10;
{
let inner = 20;
println!("outer={}, inner={}", outer, inner); // both visible inside the block
} // inner is dropped here
// println!("{}", inner); // ERROR: inner is not in scope
println!("outer={}", outer); // outer still lives in this scope
}
$ rustc variables_scope.rs && ./variables_scope
outer=10, inner=20
outer=10
Destructuring
let can destructure tuples and structs directly into named bindings. This is a form of pattern matching and is one of the most readable ways to work with compound values.
fn main() {
// Destructure a tuple into three separate bindings
let (a, b, c) = (1, 2, 3);
println!("{} {} {}", a, b, c);
let point = (3.0_f64, -1.5_f64);
let (x, y) = point;
println!("x={}, y={}", x, y);
// Use _ to ignore fields you don't need
let (first, _, third) = (10, 20, 30);
println!("{} {}", first, third);
}
$ rustc variables_destructure.rs && ./variables_destructure
1 2 3
x=3, y=-1.5
10 30
Summary
| Feature | Syntax | Notes |
|---|---|---|
| Immutable variable | let x = 5; | Default; cannot reassign |
| Mutable variable | let mut x = 5; | Can reassign same type |
| Shadowing | let x = x + 1; | New binding; can change type |
| Constant | const N: u32 = 10; | Compile-time, global scope OK |
| Static | static S: &str = "hi"; | Fixed address, lives forever |
Practice
-
Predict the output of the shadowing example above, then uncomment the
mut-style reassignmentx = x + 1;(withoutlet) in a copy of it — what compile error do you get, and why does shadowing avoid it?Solution
error[E0384]: cannot assign twice to immutable variable.xwas never declaredmut, so a bare reassignment is rejected. Shadowing avoids this becauselet x = x + 1;introduces a brand-new binding rather than mutating the old one — the compiler never objects to declaring a new variable. -
Change
let parsed: i64 = "100".parse().unwrap();to parse"abc"instead. What happens at runtime, and how would you make the failure recoverable instead of crashing?Solution
"abc".parse::<i64>()returnsErr(ParseIntError { kind: InvalidDigit }), and.unwrap()panics withcalled \Result::unwrap()` on an `Err` value: ParseIntError { … }. Replace.unwrap()with amatch,if let Err(e) = …, or.unwrap_or(0)` to handle the error without crashing the program. -
Write a small program with a
constat module scope and aletbinding insidemainthat both hold the value10. Try calling a non-const fninside theconstinitializer — what does the compiler say?Solution
error[E0015]: cannot call non-const function \double` in constants—constinitializers must be evaluable entirely at compile time, so onlyconst fns (or literals/const-expressions) are allowed. Alet` binding has no such restriction because it runs at runtime. -
In the destructuring example, replace
let (first, _, third) = (10, 20, 30);withlet (first, second, third) = (10, 20, 30);but never usesecond. What doesrustcreport, and how does_avoid it?Solution
warning: unused variable: \second`(with a suggestion to prefix itsecond). Naming a binding— notsecond, exactly` — tells the compiler you’re deliberately discarding that value, so it emits no warning at all, unlike an underscore-prefixed name which still binds (and drops) the value but silences the specific “unused” lint. -
Add a
static mut COUNTER: u32 = 0;and try to increment it inmainwithCOUNTER += 1;outside anunsafeblock. What error does the compiler give, and why is mutable global state special-cased this way?Solution
error[E0133]: use of mutable static is unsafe and requires unsafe function or block— accessingstatic mutrequiresunsafe { COUNTER += 1; }. Rust special-cases it because a mutable static is shared, unsynchronized global state: two threads writing to it concurrently is a data race, which Rust’s safety guarantees normally rule out at compile time —unsafeis your explicit acknowledgement that you’re taking on that responsibility yourself.