Every program stores values in variables, and every value has a type. Rust's take on both is stricter than Python's or JavaScript's and more explicit than Java's or C's. Variables cannot change unless you say so, number types never convert silently, integer sizes are always spelled out, and the compiler knows the type of every value before the program runs.
This lesson covers let and immutability, mut, shadowing, constants and statics, the scalar types (integers, floats, booleans and characters), what happens on integer overflow in debug and release builds, type inference, conversions with as and From/TryFrom, and the two built-in compound types, tuples and arrays. Interviewers like this material for quick "what does this print" questions: shadowing versus mut, 300 as u8, overflow in release mode, and why a char is four bytes.
Variables are immutable by default
A variable binding connects a name to a value. In Rust you create one with let. Unless you ask otherwise, the binding is immutable: once it has a value, that value cannot be replaced.
This does not compile:
fn main() {
let count = 1;
println!("count = {count}");
count = 2;
println!("count = {count}");
}
error[E0384]: cannot assign twice to immutable variable `count`
--> immut_err.rs:4:5
|
2 | let count = 1;
| ----- first assignment to `count`
3 | println!("count = {count}");
4 | count = 2;
| ^^^^^^^^^ cannot assign twice to immutable variable
|
help: consider making this binding mutable
|
2 | let mut count = 1;
| +++
Read the error from top to bottom. E0384 names the problem: assigning twice to an immutable variable. The labels show the story: the first assignment, then the illegal second one. The help: line gives the fix, which is to declare the variable as mutable with mut:
fn main() {
let mut count = 1;
println!("count = {count}");
count += 1;
println!("count = {count}");
}
Output:
count = 1
count = 2
Why make immutability the default? Most variables in real programs are assigned once and never changed. If those are immutable, then the few that are marked mut stand out, and a reader knows exactly which values can change. It also prevents a whole class of bugs where a value is changed by accident, and it makes concurrent code easier to reason about, because data that cannot change cannot be raced on.
| Language | Default | Opt-in to the other |
|---|---|---|
Python, JavaScript (let) | Mutable | No real immutable local (JavaScript has const) |
| Java | Mutable | final |
| C, C++ | Mutable | const |
| Rust | Immutable | mut |
Declare now, assign later
A let without a value is allowed as long as the variable is assigned exactly once before use, on every path. let grade; if score > 50 { grade = 'P'; } else { grade = 'F'; } compiles even without mut. Using the variable before it is definitely assigned is a compile error (E0381), so Rust never has uninitialised variables like C does.
Shadowing
Shadowing means declaring a new variable with the same name as an existing one. The new variable hides, or shadows, the old one from that point on.
fn main() {
let input = " 42 ";
let input = input.trim();
let input: i32 = input.parse().expect("not a number");
let input = input * 2;
println!("input = {input}");
let level = 1;
{
let level = level + 10;
println!("inner level = {level}");
}
println!("outer level = {level}");
}
Output:
input = 84
inner level = 11
outer level = 1
Step by step through the first part:
inputis a string slice" 42 ".- The second
let inputcreates a new variable holding the trimmed text"42". The old one is hidden. - The third creates a new variable of a different type,
i32, holding42. - The fourth creates yet another variable holding
84.
None of these variables is mutable. Each let makes a brand-new binding. This is ideal for a value that goes through a series of transformations, because you do not need names like input_str, input_trimmed and input_num.
The second part shows that shadowing respects scopes. Inside the inner block, level refers to a new variable with value 11. When the block ends, that variable disappears and the outer level, still 1, is visible again.
Shadowing versus mut
These look similar but are different mechanisms.
let mut x then x = ... | let x then let x = ... | |
|---|---|---|
| How many variables? | One, whose value changes | Two separate variables with the same name |
| Can the type change? | No | Yes |
| Is the variable mutable afterwards? | Yes | No (unless the new let says mut) |
| Effect of an inner scope | Assignment inside a block changes the outer variable | A let inside a block hides the outer one only until the block ends |
The type rule is the one people trip on. This does not compile, because a mutable variable keeps one type for its whole life:
fn main() {
let mut spaces = " ";
spaces = spaces.len();
println!("{spaces}");
}
error[E0308]: mismatched types
--> mut_type_err.rs:3:14
|
2 | let mut spaces = " ";
| ----- expected due to this value
3 | spaces = spaces.len();
| ^^^^^^^^^^^^ expected `&str`, found `usize`
spaces was inferred to be &str (a string slice) from its first value, and spaces.len() returns usize. With shadowing, let spaces = spaces.len(); compiles fine.
Common mistake
Shadowing inside a loop or an if block does not change the variable outside it. If you write let total = total + x; inside a loop body, each iteration creates a temporary inner total that vanishes at the end of the iteration, and the outer total never changes. Use let mut total outside the loop and total += x; inside.
Constants and statics
A constant is a value fixed at compile time and given a name. Constants use const, must have an explicit type, use SCREAMING_SNAKE_CASE by convention, and may be declared at the top level of a file (outside any function).
const SECONDS_PER_DAY: u32 = 24 * 60 * 60;
const MAX_LOGIN_ATTEMPTS: u8 = 3;
static GREETING: &str = "Welcome";
fn main() {
println!("{GREETING}! A day has {SECONDS_PER_DAY} seconds.");
println!("You get {MAX_LOGIN_ATTEMPTS} login attempts.");
}
Output:
Welcome! A day has 86400 seconds.
You get 3 login attempts.
24 * 60 * 60 is evaluated by the compiler, not at run time. A constant's value must be computable at compile time, so you cannot initialise one from user input or a file.
A static is similar but represents one fixed location in memory that lives for the entire run of the program. Every use of a static refers to that same address; a constant, by contrast, is conceptually copied into each place it is used.
let | const | static | |
|---|---|---|---|
| Where allowed | Inside functions | Anywhere | Anywhere |
| Type annotation | Optional | Required | Required |
| Value computed | At run time | At compile time | At compile time |
| Has one fixed address | No | No (inlined at each use) | Yes |
| Can be mutable | With mut | Never | static mut exists but every access needs unsafe; avoid it |
Prefer const for named values. Use static when you need a single shared location, for example a large lookup table you do not want duplicated, or (later) a global protected by a thread-safe wrapper.
Scalar types
A scalar type holds a single value. Rust has four kinds: integers, floating-point numbers, booleans and characters.
Integers
Integer types are named by signedness and size. i means signed (can be negative) and u means unsigned (zero or positive). The number is the width in bits.
| Bits | Signed | Range | Unsigned | Range |
|---|---|---|---|---|
| 8 | i8 | -128 to 127 | u8 | 0 to 255 |
| 16 | i16 | -32,768 to 32,767 | u16 | 0 to 65,535 |
| 32 | i32 | about plus or minus 2.1 billion | u32 | 0 to about 4.29 billion |
| 64 | i64 | about plus or minus 9.2 x 10^18 | u64 | 0 to about 1.8 x 10^19 |
| 128 | i128 | about plus or minus 1.7 x 10^38 | u128 | 0 to about 3.4 x 10^38 |
| pointer-sized | isize | depends on the machine | usize | depends on the machine |
A signed n-bit type stores values from -2^(n-1) to 2^(n-1) - 1 using two's complement, the standard binary encoding for negative numbers. An unsigned n-bit type stores 0 to 2^n - 1.
usize and isize are as wide as a memory address: 64 bits on a 64-bit machine, 32 bits on a 32-bit one. Rust uses usize for anything that counts or indexes memory: lengths, indices and sizes. That is why vec.len() returns usize and why array indices must be usize.
You can ask Rust for the sizes and limits directly:
use std::mem::size_of;
fn main() {
println!(
"i8: {} byte, range {} to {}",
size_of::<i8>(),
i8::MIN,
i8::MAX
);
println!(
"u8: {} byte, range {} to {}",
size_of::<u8>(),
u8::MIN,
u8::MAX
);
println!(
"i32: {} bytes, range {} to {}",
size_of::<i32>(),
i32::MIN,
i32::MAX
);
println!("u64: {} bytes, max {}", size_of::<u64>(), u64::MAX);
println!("usize: {} bytes on this machine", size_of::<usize>());
println!("f64: {} bytes", size_of::<f64>());
println!("bool: {} byte", size_of::<bool>());
println!("char: {} bytes", size_of::<char>());
}
Output (on a 64-bit machine):
i8: 1 byte, range -128 to 127
u8: 1 byte, range 0 to 255
i32: 4 bytes, range -2147483648 to 2147483647
u64: 8 bytes, max 18446744073709551615
usize: 8 bytes on this machine
f64: 8 bytes
bool: 1 byte
char: 4 bytes
When you write an integer literal without any other clue, Rust uses i32. Integer literals can be written in several ways:
fn main() {
let population = 1_428_627_663_u64;
let mask = 0xFF;
let perms = 0o755;
let flags = 0b1010_0001;
let letter = b'A';
println!("{population} {mask} {perms} {flags} {letter}");
}
Output:
1428627663 255 493 161 65
- Underscores are ignored and help readability:
1_428_627_663. - A type suffix such as
_u64oru8fixes the literal's type. 0xis hexadecimal,0ooctal,0bbinary.b'A'is a byte literal: the ASCII code ofAas au8, here 65.
Which integer type should I use?
Use i32 for general-purpose numbers when nothing suggests otherwise, usize for lengths and indices, u8 for raw bytes, u64 or i64 for values that can exceed about 2 billion (file sizes, timestamps in milliseconds, money in paise), and fixed-size types like u16 when a format or protocol specifies them.
Integer overflow: debug versus release
Overflow happens when an arithmetic result does not fit in the type. A u8 holds at most 255, so what is 255 + 1?
Rust's answer depends on the build profile:
- In a debug build, overflow checks are on, and overflow causes a panic: the program stops with an error message.
- In a release build, overflow checks are off, and the value wraps around using two's complement arithmetic: 255 + 1 becomes 0.
Neither profile has undefined behaviour, unlike signed overflow in C. Here is a program where the value is only known at run time:
fn main() {
let input = "255";
let stock: u8 = input.parse().unwrap();
let next = stock + 1;
println!("next = {next}");
}
Output with cargo run (debug):
thread 'main' (1670715) panicked at src/main.rs:4:16:
attempt to add with overflow
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
Output with cargo run --release:
next = 0
The same source produces a crash in one profile and a silently wrong answer in the other. That is why you should treat any possible overflow as a bug and handle it explicitly.
If the overflow is obvious at compile time, Rust catches it even earlier. This does not compile:
fn main() {
let level: u8 = 256;
println!("{level}");
}
error: literal out of range for `u8`
--> overflow_literal_err.rs:2:21
|
2 | let level: u8 = 256;
| ^^^
|
= note: the literal `256` does not fit into the type `u8` whose range is `0..=255`
= note: `#[deny(overflowing_literals)]` on by default
Handling overflow on purpose
When overflow is possible, say what you want with the explicit methods every integer type provides:
fn main() {
let a: u8 = 250;
let b: u8 = 10;
println!("checked: {:?}", a.checked_add(b));
println!("checked ok: {:?}", a.checked_add(5));
println!("wrapping: {}", a.wrapping_add(b));
println!("saturating: {}", a.saturating_add(b));
println!("overflowing: {:?}", a.overflowing_add(b));
}
Output:
checked: None
checked ok: Some(255)
wrapping: 4
saturating: 255
overflowing: (4, true)
| Method | On overflow | Typical use |
|---|---|---|
checked_add | Returns None (otherwise Some(result)) | You want to detect and handle overflow |
wrapping_add | Wraps around: 250 + 10 = 260 - 256 = 4 | Hashing, checksums, ring counters |
saturating_add | Sticks at the type's maximum (or minimum) | Volumes, progress bars, counters that should cap |
overflowing_add | Returns the wrapped result and a bool flag | You need both the value and whether it overflowed |
The same families exist for subtraction, multiplication, and more (checked_sub, wrapping_mul, saturating_sub and so on). Some and None are the two forms of Option, Rust's way of saying "a value or nothing", covered in lesson 7.
Floating-point numbers
Rust has two floating-point types, f32 (single precision) and f64 (double precision), following the IEEE 754 standard. A literal with a decimal point, such as 2.5, is f64 unless something says otherwise. On modern CPUs f64 is about as fast as f32 and far more precise, so it is the sensible default.
fn main() {
let x: f64 = 0.1 + 0.2;
println!("0.1 + 0.2 = {x}");
println!("equal to 0.3? {}", x == 0.3);
println!("close to 0.3? {}", (x - 0.3).abs() < 1e-9);
let top = 0.0_f64;
let bottom = 0.0_f64;
println!("1/0 = {}, 0/0 = {}", 1.0 / bottom, top / bottom);
println!("7 / 2 = {}, 7.0 / 2.0 = {}", 7 / 2, 7.0 / 2.0);
println!("-7 / 2 = {}, -7 % 2 = {}", -7 / 2, -7 % 2);
}
Output:
0.1 + 0.2 = 0.30000000000000004
equal to 0.3? false
close to 0.3? true
1/0 = inf, 0/0 = NaN
7 / 2 = 3, 7.0 / 2.0 = 3.5
-7 / 2 = -3, -7 % 2 = -1
Observations:
0.1 + 0.2is not exactly0.3, as in every language that uses binary floating point. Compare floats with a tolerance, never with==.- Float division by zero does not panic; it gives infinity, and
0.0 / 0.0gives NaN ("not a number"). Integer division by zero, by contrast, panics. - Integer division truncates toward zero:
7 / 2is 3 and-7 / 2is -3. Python's//rounds toward negative infinity instead and gives -4. The remainder%takes the sign of the left operand, so-7 % 2is -1 (Python gives 1). Userem_euclidwhen you need a non-negative remainder.
Floats and money
Do not store money in f32 or f64; rounding errors accumulate. Store an integer number of the smallest unit (paise, cents) in an i64, or use a decimal crate.
Booleans
The bool type has two values, true and false, and takes one byte. Conditions in if and while must be bool; Rust has no "truthy" values. if count where count is an integer is a compile error, unlike in C, Python or JavaScript. Write if count != 0 instead. The logical operators are &&, || and !, and the first two short-circuit.
Characters
Rust's char is a Unicode scalar value: any Unicode code point except the "surrogate" range used internally by UTF-16. A char is always four bytes, enough for any code point, and is written in single quotes.
fn main() {
let letters = ['a', 'Z', 'ñ', 'अ', '中'];
for c in letters {
println!(
"{c:?} is U+{:04X}, {} byte(s) in UTF-8",
c as u32,
c.len_utf8()
);
}
let word = "नमस्ते";
println!("bytes: {}, chars: {}", word.len(), word.chars().count());
}
Output:
'a' is U+0061, 1 byte(s) in UTF-8
'Z' is U+005A, 1 byte(s) in UTF-8
'ñ' is U+00F1, 2 byte(s) in UTF-8
'अ' is U+0905, 3 byte(s) in UTF-8
'中' is U+4E2D, 3 byte(s) in UTF-8
bytes: 18, chars: 6
The char type is fixed at four bytes, but text in a String or &str is stored as UTF-8, a variable-length encoding that uses one to four bytes per character. That is why the Hindi word above has 18 bytes but only 6 chars. The difference matters a lot when you slice strings, which lessons 5 and 8 return to.
A char is also not the same as what a reader thinks of as a "letter". Some visible characters, including many Devanagari syllables and accented letters, are built from more than one code point. Rust's standard library works with bytes and chars; for user-perceived characters (called grapheme clusters) you need an external crate.
C char | Java char | Python | Rust char | |
|---|---|---|---|---|
| Size | 1 byte | 2 bytes | no separate char type (a 1-length str) | 4 bytes |
| Holds | a byte | a UTF-16 code unit | a code point | a Unicode scalar value |
| Can hold any Unicode character alone? | No | No (needs surrogate pairs) | Yes | Yes |
Type inference and annotations
Rust is statically typed: every value's type is known at compile time. You rarely have to write those types, because the compiler infers them from how values are created and used. Inference looks at the whole function, not just the line where the variable is created.
Sometimes there is not enough information. parse can turn a string into many different types, so this does not compile:
fn main() {
let guess = "42".parse().expect("not a number");
println!("{guess}");
}
error[E0284]: type annotations needed
--> infer_err.rs:2:9
|
2 | let guess = "42".parse().expect("not a number");
| ^^^^^ ----- type must be known at this point
|
= note: cannot satisfy `<_ as FromStr>::Err == _`
= note: the type must also implement `FromStr`
help: consider giving `guess` an explicit type
|
2 | let guess: /* Type */ = "42".parse().expect("not a number");
| ++++++++++++
There are two ways to give the compiler the missing type. Annotate the variable, or use the turbofish syntax ::<T> to pass the type to the function directly:
fn main() {
let a: u32 = "42".parse().expect("not a number");
let b = "42".parse::<u32>().expect("not a number");
let mut scores = Vec::new();
for value in [a, b] {
scores.push(value * 10);
}
println!("{scores:?}");
}
Output:
[420, 420]
Note Vec::new() on its own line: the compiler did not know the element type at that point, but it worked it out from the later push of a u32. Inference flows backwards and forwards within a function.
Function signatures are the exception: parameters and return types must always be written out. This is deliberate. A function's signature is its contract, and requiring it keeps type errors local and readable.
Converting between types
No implicit conversions
Rust never converts between number types automatically, not even from a smaller integer to a larger one. This does not compile:
fn main() {
let items: i32 = 5;
let price: i64 = 120;
let total = items * price;
println!("{total}");
}
error[E0308]: mismatched types
--> mix_err.rs:4:25
|
4 | let total = items * price;
| ^^^^^ expected `i32`, found `i64`
error[E0277]: cannot multiply `i32` by `i64`
--> mix_err.rs:4:23
|
4 | let total = items * price;
| ^ no implementation for `i32 * i64`
|
= help: the trait `Mul<i64>` is not implemented for `i32`
The compiler reports the one problem from two angles: the right operand has the wrong type (E0308), and there is no multiplication defined between i32 and i64 (E0277). In Java, an int would be widened to long silently, and in C the usual arithmetic conversions would quietly pick a type. Rust makes you choose, because silent conversions are a classic source of truncation and sign bugs.
Casting with as
The as keyword performs primitive conversions. It never fails and never panics, which means it sometimes quietly changes the value. You need to know its rules:
fn main() {
let items: i32 = 5;
let price: i64 = 120;
println!("total = {}", items as i64 * price);
println!("300 as u8 = {}", 300_i32 as u8);
println!("-1 as u32 = {}", -1_i32 as u32);
println!("3.99 as i32 = {}", 3.99_f64 as i32);
println!("-3.99 as i32 = {}", -3.99_f64 as i32);
println!("-5.0 as u8 = {}", -5.0_f64 as u8);
println!("1e10 as i32 = {}", 1e10_f64 as i32);
let nan: f64 = "NaN".parse().unwrap();
println!("NaN as i32 = {}", nan as i32);
println!("'A' as u32 = {}", 'A' as u32);
println!("97 as char = {}", 97_u8 as char);
println!("true as i32 = {}", true as i32);
println!("u8::try_from(300) = {:?}", u8::try_from(300_i32));
println!("u8::try_from(200) = {:?}", u8::try_from(200_i32));
let wide: i64 = i64::from(items);
println!("i64::from(5) = {wide}");
}
Output:
total = 600
300 as u8 = 44
-1 as u32 = 4294967295
3.99 as i32 = 3
-3.99 as i32 = -3
-5.0 as u8 = 0
1e10 as i32 = 2147483647
NaN as i32 = 0
'A' as u32 = 65
97 as char = a
true as i32 = 1
u8::try_from(300) = Err(TryFromIntError(PosOverflow))
u8::try_from(200) = Ok(200)
i64::from(5) = 5
Working through the surprising ones:
| Cast | Result | Why |
|---|---|---|
300_i32 as u8 | 44 | Integer to smaller integer keeps the low 8 bits: 300 = 256 + 44 |
-1_i32 as u32 | 4294967295 | Same bits reinterpreted: all ones in 32 bits is 2^32 - 1 |
3.99 as i32 | 3 | Float to integer truncates toward zero |
-3.99 as i32 | -3 | Truncation toward zero, not rounding down |
-5.0 as u8 | 0 | Float to integer saturates: out-of-range values clamp to the nearest limit |
1e10 as i32 | 2147483647 | Saturates at i32::MAX |
NaN as i32 | 0 | NaN converts to 0 |
'A' as u32 | 65 | A char converts to its code point |
97_u8 as char | a | Only u8 can be cast to char with as; other integers need char::from_u32 |
true as i32 | 1 | false is 0 |
Checking the arithmetic
300 in binary is 1_0010_1100. Keeping the low eight bits gives 0010_1100, which is 32 + 8 + 4 = 44. For -1_i32, two's complement stores all 32 bits as 1, and reading that as unsigned gives 2^32 - 1 = 4,294,967,295.
Safer conversions: From and TryFrom
The standard library provides conversion traits (a trait is a named set of behaviour that types can implement; lesson 12 covers them):
From: for conversions that can never lose information.i64::from(x)works whenxis ani32, because everyi32fits in ani64. There is noi32::from(an_i64).TryFrom: for conversions that might fail.u8::try_from(300)returnsErr, andu8::try_from(200)returnsOk(200). You then decide what to do with the error.
The output above shows both. Good practice: use From (or .into()) for widening, TryFrom when the value might not fit, and as only when you have thought about truncation, or for float and char conversions where it is the standard tool.
Interview tip
If asked "what does 300 as u8 give?", answer 44 and explain why: as between integers keeps the low bits. Then add that in real code you would use u8::try_from to get an error instead of a silently wrong value. That second sentence is what separates a strong answer.
Compound types: tuples and arrays
A compound type groups several values into one. Rust has two built in.
Tuples
A tuple groups a fixed number of values that may have different types. Its type is written as a list of the element types in parentheses.
fn main() {
let student: (&str, u32, f64) = ("Kiran", 21, 8.7);
println!(
"name = {}, age = {}, cgpa = {}",
student.0, student.1, student.2
);
let (name, age, cgpa) = student;
println!("{name} is {age} with CGPA {cgpa}");
let (_, _, only_cgpa) = student;
println!("only cgpa = {only_cgpa}");
let nothing = ();
println!("unit value: {nothing:?}");
}
Output:
name = Kiran, age = 21, cgpa = 8.7
Kiran is 21 with CGPA 8.7
only cgpa = 8.7
unit value: ()
- Access elements by position with
.0,.1,.2. - Destructuring with
let (name, age, cgpa) = student;unpacks all elements into separate variables at once. _ignores an element you do not need.- The empty tuple
()is called unit. It has exactly one value, also written(). Functions that return nothing actually return(), which lesson 3 explains.
Tuples are handy for returning several values from a function. When the values have meaning beyond their position, prefer a struct with named fields (lesson 6).
Arrays
An array holds a fixed number of values of the same type, stored next to each other. Its type is [T; N]: element type T and length N, and the length is part of the type, so [i32; 3] and [i32; 4] are different types.
fn main() {
let days = ["Mon", "Tue", "Wed", "Thu", "Fri"];
let zeros = [0u8; 4];
let grid: [[i32; 3]; 2] = [[1, 2, 3], [4, 5, 6]];
println!("first day: {}, count: {}", days[0], days.len());
println!("zeros: {zeros:?}");
println!("grid[1][2] = {}", grid[1][2]);
println!("days.get(9) = {:?}", days.get(9));
let mut temps = [30, 32, 31];
temps[1] = 35;
let total: i32 = temps.iter().sum();
println!("temps = {temps:?}, total = {total}");
}
Output:
first day: Mon, count: 5
zeros: [0, 0, 0, 0]
grid[1][2] = 6
days.get(9) = None
temps = [30, 35, 31], total = 96
[0u8; 4]creates four elements, all equal to0u8.- Arrays can nest:
[[i32; 3]; 2]is two rows of three. len()gives the number of elements.get(i)returnsSome(&value)if the index is valid andNoneotherwise, instead of panicking.- Arrays live on the stack when declared as local variables. Their size cannot change; for a growable list use
Vec<T>(lesson 8).
let days = ["Mon", "Tue", "Wed", "Thu", "Fri"];
index: 0 1 2 3 4
+------+------+------+------+------+
days | Mon | Tue | Wed | Thu | Fri | type [&str; 5]
+------+------+------+------+------+
days[7] -> outside the array: panic, never a garbage read
Bounds checking
Rust checks every index against the length. If the index is known only at run time and is out of range, the program panics:
fn main() {
let days = ["Mon", "Tue", "Wed", "Thu", "Fri"];
let input = "7";
let index: usize = input.parse().unwrap();
println!("day = {}", days[index]);
}
Output:
thread 'main' (1686833) panicked at array_oob.rs:5:26:
index out of bounds: the len is 5 but the index is 7
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
In C, the equivalent reads whatever memory follows the array, which is a classic security hole. In Rust it is a clean, reported failure. If the index is a constant the compiler can see, it rejects the program outright. This does not compile:
fn main() {
let days = ["Mon", "Tue", "Wed", "Thu", "Fri"];
println!("day = {}", days[7]);
}
error: this operation will panic at runtime
--> array_oob_const.rs:3:26
|
3 | println!("day = {}", days[7]);
| ^^^^^^^ index out of bounds: the length is 5 but the index is 7
|
= note: `#[deny(unconditional_panic)]` on by default
Reading the compiler errors from this lesson
The errors you will meet most often while learning this material:
| Error | Message | Usual cause | Fix |
|---|---|---|---|
| E0384 | cannot assign twice to immutable variable | Changing a variable declared without mut | Add mut, or use shadowing if you are transforming the value |
| E0308 | mismatched types | Assigning a value of a different type to a mut variable, or mixing number types | Shadow instead, or convert with as, From or TryFrom |
| E0284 / E0282 | type annotations needed | parse, collect, Vec::new and similar with no clue about the target type | Annotate the variable or use turbofish ::<T> |
overflowing_literals | literal out of range | A literal too large for the declared type | Use a wider type |
unconditional_panic | this operation will panic at runtime | Constant index past the end of an array | Fix the index, or use get |
The last two are lints that are set to "deny" by default, so they act like errors.
Exercises
Exercise 1: average marks without overflow
You have five marks stored as [u8; 5] = [90, 85, 99, 100, 78]. Compute the total and the average to one decimal place. Why would adding them up as u8 be a problem?
Solution
The total is 452, which does not fit in a u8 (maximum 255). In a debug build the sum would panic; in release it would wrap to 452 - 256 = 196. Convert each mark to u32 with u32::from before adding.
fn main() {
let marks: [u8; 5] = [90, 85, 99, 100, 78];
let mut total: u32 = 0;
for m in marks {
total += u32::from(m);
}
let average = total as f64 / marks.len() as f64;
println!("total = {total}, average = {average:.1}");
}
total = 452, average = 90.4
452 / 5 = 90.4.
Exercise 2: predict the casts
Predict the output of each line, then check by running it.
fn main() {
println!("{}", -1_i8 as u8);
println!("{}", 1000_i32 as u8);
println!("{}", 2.7_f64 as u8);
println!("{}", (b'a' + 1) as char);
println!("{}", -129_i32 as i8);
}
Solution
255
232
2
b
127
-1_i8 as u8: all eight bits set, read as unsigned, is 255.1000 as u8: 1000 = 3 x 256 + 232, so the low byte is 232.2.7 as u8: float to integer truncates, giving 2.(b'a' + 1) as char: 97 + 1 = 98, which isb.-129_i32 as i8: the low byte of -129 in two's complement is0111_1111, which is 127. The value wrapped past the bottom of thei8range.
Exercise 3: clean up input with shadowing
Given the text " 100 ", produce a u32 equal to twice the number, using one variable name throughout and no mut.
Solution
fn main() {
let quantity = " 100 ";
let quantity = quantity.trim();
let quantity: u32 = quantity.parse().expect("not a number");
let quantity = quantity * 2;
println!("quantity = {quantity}");
}
quantity = 200
Each let quantity is a new variable; the third one changes the type from &str to u32, which mut could not do.
Exercise 4: swap a tuple
Given let pair = (1, "one");, build a new tuple with the elements swapped, then destructure it into two variables.
Solution
fn main() {
let pair = (1, "one");
let swapped = (pair.1, pair.0);
let (word, number) = swapped;
println!("{swapped:?} -> word = {word}, number = {number}");
}
("one", 1) -> word = one, number = 1
Exercise 5: overflow on purpose
A shop stores stock in a u8. It has 240 items and receives 30. Detect that the result does not fit, and also show the result capped at the maximum.
Solution
checked_add returns None on overflow, and saturating_add caps at 255. The match here is previewed in lesson 3.
fn main() {
let stock: u8 = 240;
let delivery: u8 = 30;
match stock.checked_add(delivery) {
Some(total) => println!("new stock: {total}"),
None => println!(
"too much stock: a u8 cannot hold {}",
stock as u32 + delivery as u32
),
}
println!("capped stock: {}", stock.saturating_add(delivery));
}
too much stock: a u8 cannot hold 270
capped stock: 255
Interview questions
Q1. Why are Rust variables immutable by default?
Most variables never need to change, so making immutability the default means the ones marked mut clearly signal "this value changes". It prevents accidental modification, makes code easier to read and reason about, and makes data that is shared between threads safe by default. Mutation is still easy; you just have to ask for it.
Q2. What is the difference between shadowing and mutation?
Mutation with let mut changes the value of one variable, and the type must stay the same. Shadowing declares a new variable with the same name, which can have a different type, and the new variable is immutable unless declared mut. Shadowing inside a block only lasts until the end of that block, whereas assigning to a mut variable inside a block changes the outer variable.
Q3. What is the difference between const and static?
Both need explicit types and compile-time values. A const has no fixed address; its value is effectively copied into each place it is used. A static is a single memory location that lives for the whole program, so all uses refer to the same address. static mut exists but every access is unsafe; shared mutable globals should use thread-safe types instead.
Q4. What happens on integer overflow in Rust?
In debug builds, overflow checks are on and arithmetic overflow panics. In release builds they are off by default and the result wraps around in two's complement. Neither case is undefined behaviour. When overflow is possible, code should use checked_*, wrapping_*, saturating_* or overflowing_* methods to state the intended behaviour, or enable overflow-checks in the release profile.
Q5. What is usize and when do you use it?
usize is an unsigned integer as wide as a pointer: 64 bits on 64-bit targets and 32 bits on 32-bit targets. Rust uses it for lengths, indices and memory sizes, so array and vector indexing require a usize. Using it for those purposes avoids casts and matches what the standard library returns.
Q6. Why is a Rust char 4 bytes when a String uses 1 byte per ASCII character?
A char holds any Unicode scalar value, and the largest code point needs 21 bits, so a fixed four-byte type is used. Strings, however, are stored as UTF-8, which uses one to four bytes per character depending on the character. So "abc" is 3 bytes as a string but each char taken out of it is 4 bytes.
Q7. What does 300 as u8 evaluate to, and what would you use instead?
It evaluates to 44, because as between integer types keeps the low-order bits (300 - 256 = 44). It never fails, so it can hide bugs. To detect values that do not fit, use u8::try_from(300), which returns an error, and use From/into for conversions that can never lose information.
Q8. Does Rust perform implicit numeric conversions?
No. Adding an i32 to an i64, or an integer to a float, is a type error. You must convert explicitly with as, From or TryFrom. This avoids the silent truncation and signed/unsigned surprises that implicit conversions cause in C and C++.
Q9. How does Rust's type inference differ from dynamic typing?
Inference only saves you from writing types; the types are still fixed at compile time and checked before the program runs. Python decides types at run time and lets a variable hold different types over time. In Rust, a variable has one type forever, and inference can use later lines in the same function to determine it, but function signatures must always be explicit.
Q10. What is the unit type?
Unit, written (), is the empty tuple. It has exactly one value, also (). Functions with no declared return type return (), and expressions that produce no meaningful value, such as an assignment, have type (). It plays the role that void plays in C or Java, but it is a real type with a real value.
Q11. What happens when you index an array out of bounds?
If the index is a constant the compiler can see, it rejects the program with an unconditional_panic error. Otherwise the index is checked at run time and the program panics with "index out of bounds" instead of reading other memory. To handle a possibly invalid index without panicking, use get, which returns an Option.
Key takeaways
letcreates immutable bindings; addmutto allow changes. Uninitialised variables cannot be read.- Shadowing creates a new variable with the same name and can change the type;
mutkeeps one variable and one type. constis a compile-time named value with no fixed address;staticis one fixed location for the whole program.- Integer types name their sign and size (
i32,u8,u64,usize).i32is the default;usizeis for lengths and indices. - Overflow panics in debug and wraps in release; use
checked_,wrapping_,saturating_andoverflowing_methods to be explicit. f64is the default float; never compare floats with==or store money in them.charis a four-byte Unicode scalar value, while strings are UTF-8, so byte counts and character counts differ.- Rust never converts numbers implicitly. Use
Fromfor lossless conversions,TryFromwhen a value might not fit, andasknowing its truncating and saturating rules. - Tuples group mixed types by position; arrays hold a fixed number of one type, with bounds-checked indexing.
Next lesson
Continue with Functions and control flow in Rust.

