Master Rust's approach to variables, mutability, and its rich type system.
In most languages, variables are mutable by default. Rust flips this. Every variable is immutable unless you explicitly say otherwise.
let x = 5;
x = 6; // Error: cannot assign twice to immutable variable
This fails to compile. The compiler protects you from accidentally mutating data that shouldn't change.
Think about the bugs you've chased down. How many were caused by some variable changing when you didn't expect it? Rust eliminates an entire category of bugs by making you think about mutation upfront.
Immutable variables make code easier to reason about. When you read let x = 5, you know that x will always be 5 in that scope. No surprises.
When you need mutation, add mut:
let mut x = 5;
x = 6; // Works fine
The mut keyword is a signal to both the compiler and future readers: "This value will change."
What keyword makes a variable mutable in Rust?
For values that should never change and are known at compile time, use const:
const THREE_HOURS_IN_SECONDS: u32 = 60 * 60 * 3;
Constants differ from immutable variables in important ways:
Use const for truly constant values like configuration, mathematical constants, or magic numbers. Use let for values computed at runtime, even if they won't change.
Rust lets you declare a new variable with the same name as a previous one. The new variable "shadows" the old:
fn main() {
let x = 5;
let x = x + 1; // x is now 6
{
let x = x * 2;
println!("Inner scope: {x}"); // Prints 12
}
println!("Outer scope: {x}"); // Prints 6
}
When the inner scope ends, the shadowing ends. The original x (value 6) comes back into view.
Shadowing differs from mut in a crucial way: you can change the type.
let spaces = " "; // &str
let spaces = spaces.len(); // usize - same name, different type
With mut, the compiler would reject this type change. Shadowing gives you flexibility while still requiring explicit intent at each reassignment.
Shadowing creates a new variable. The old value still exists until dropped. With mut, you're modifying the same memory location. Choose shadowing when transforming a value; choose mut when updating a value of the same type repeatedly.
Scalar types represent a single value. Rust has four: integers, floating-point numbers, booleans, and characters.
Rust's integer types are explicit about size and signedness:
| Signed | Unsigned | Size |
|---|---|---|
| i8 | u8 | 8-bit |
| i16 | u16 | 16-bit |
| i32 | u32 | 32-bit |
| i64 | u64 | 64-bit |
| i128 | u128 | 128-bit |
| isize | usize | arch-dependent |
Signed types (i) can hold negative values. Unsigned types (u) are always positive or zero.
The isize and usize types depend on your architecture: 64 bits on a 64-bit system, 32 bits on a 32-bit system. They're primarily used for indexing collections.
When you write let x = 5; without a type annotation, Rust defaults to i32. It's fast on most systems, even 64-bit ones.
Rust offers multiple ways to write integer literals:
| Format | Example |
|---|---|
| Decimal | 98_222 |
| Hex | 0xff |
| Octal | 0o77 |
| Binary | 0b1111_0000 |
| Byte (u8 only) | b'A' |
Notice the underscores. They're visual separators that make large numbers readable. 1_000_000 is easier to parse than 1000000.
Rust has two floating-point types: f32 and f64. Both are signed.
let x = 2.0; // f64 (default)
let y: f32 = 3.0; // f32
The default is f64 because on modern CPUs it's roughly the same speed as f32 but offers more precision.
let t = true;
let f: bool = false;
Booleans are one byte in size. Nothing surprising here.
let c = 'z';
let heart = '♥';
let emoji = '🦀';
Rust's char type is four bytes and represents a Unicode Scalar Value. It can hold much more than ASCII: accented letters, emojis, Chinese characters, zero-width spaces. Single quotes, not double.
A char is a single Unicode scalar value. A String is a sequence of UTF-8 encoded bytes. We'll dive into strings when we cover ownership and collections.
Compound types group multiple values. Rust has two primitives: tuples and arrays.
Tuples combine values of different types into one compound:
let tup: (i32, f64, u8) = (500, 6.4, 1);
You can destructure to extract values:
let (x, y, z) = tup;
println!("y is {y}"); // 6.4
Or access by index:
let first = tup.0; // 500
let second = tup.1; // 6.4
Tuple indices are zero-based and use dot notation, not brackets.
A tuple without values () is called the "unit type." It represents an empty value or empty return type. Functions without a return type implicitly return ().
Arrays hold multiple values of the same type with a fixed length:
let a = [1, 2, 3, 4, 5];
Arrays live on the stack because their size is known at compile time. For dynamic-length collections, you'll use Vec (covered later).
Type annotation includes both the element type and length:
let a: [i32; 5] = [1, 2, 3, 4, 5];
To create an array filled with the same value:
let zeros = [0; 5]; // [0, 0, 0, 0, 0]
Access elements with bracket notation:
let first = a[0];
let second = a[1];
Rust checks array bounds at runtime. Accessing an invalid index causes a panic, not undefined behavior. This is a safety feature. In C, out-of-bounds access could read arbitrary memory.
Here's something that might surprise you: almost everything in Rust is an expression that returns a value.
The difference between statements and expressions:
let y = {
let x = 3;
x + 1 // No semicolon - this is an expression
};
println!("{y}"); // 4
Notice x + 1 has no semicolon. Adding a semicolon turns an expression into a statement that returns ().
This means if can return a value:
let number = if condition { 5 } else { 6 };
We'll explore this more when we cover functions and control flow.
Given `let x = 5;` without a type annotation, what type does Rust infer for x?
Rust's type system is explicit and intentional:
mut when you need to change valuesThis intentionality extends throughout the language. In the next lesson, we'll see how functions and control flow leverage Rust's expression-based nature to write clean, concise code.