2024-07-14
HSPACE Rust Lectures #5 — Expressions
A syntax session — but the point wasn't a syntax tour so much as how being an expression language changes the shape of the code. The first assignment landed at the end of it.
An expression language
C-family languages separate statements from expressions. if is a
statement and has no value, which is why a separate ternary (? :) exists for
when you need one.
Rust has no ternary operator, because if is already a value.
// C
// int status = disaster ? 1 : 0;
// Rust
let status = if disaster { 1 } else { 0 };
Same for match:
let msg = match code {
200 => "OK",
404 => "Not Found",
_ => "Unknown",
};
The property shows up naturally in the assignment solutions:
pub fn collatz_length(mut n: i32) -> u32 {
let mut ret = 1;
while n > 1 {
n = if n % 2 == 0 { n / 2 } else { 3 * n + 1 };
ret += 1;
}
ret
}
One line: n = if ... { } else { };. In C you'd assign inside each branch or
reach for the ternary. Here the intent — this variable becomes one of these
values — lives in a single assignment.
Blocks and semicolons
This is the first genuinely confusing part. A block is an expression too, and the value of its last expression is the value of the block.
let x = {
let a = 3;
let b = 4;
a * a + b * b // no semicolon → this is the block's value
}; // x == 25
Add a semicolon and the value is discarded; the block becomes (), the unit
type.
let y = {
let a = 3;
a * a; // semicolon → value discarded
}; // y == ()
Function returns follow the same rule.
fn square(n: i32) -> i32 {
n * n // no semicolon → this is the return value
}
fn square_broken(n: i32) -> i32 {
n * n; // error: the semicolon makes this return ()
}
This was easily my most frequent compile error early on. When you see
expected i32, found (), it's a stray semicolon on the last line 99% of the
time. Once it clicked, I stopped writing return almost entirely.
Declarations
let can omit the type, and can even defer initialization.
let x; // no type, no value yet
if condition {
x = compute(); // initialized here
} else {
x = fallback();
}
println!("{x}"); // OK: initialized on every path
The compiler checks that it's initialized exactly once on every path. Miss a
branch and you get used binding is possibly-uninitialized. Reading an
uninitialized variable, the classic C mistake, is structurally impossible.
let also shadows:
let spaces = " ";
let spaces = spaces.len(); // now a usize
It rebinds the same name at a new type, so you don't have to invent awkward names
like spaces_str / spaces_len.
if and match
match is not just a switch.
It checks exhaustiveness
match code {
200 => "OK",
404 => "Not Found",
// error: the remaining i32 values aren't handled
}
You must add _ or enumerate everything. Add a variant to an enum and every
match that fails to handle it becomes a compile error — which is most of why
refactoring stops being scary.
Patterns destructure values
The RPG assignment solution is a nice example:
pub fn cast_spell(&mut self, mana_cost: u32) -> u32 {
match self.mana {
Some(ref mut mana) => {
if *mana < mana_cost {
0
} else {
*mana -= mana_cost;
2 * mana_cost
}
}
None => {
if self.health <= mana_cost {
self.health = 0;
} else {
self.health -= mana_cost;
}
0
}
}
}
Some(ref mut mana) means "mutably borrow the value inside the Option and call
it mana." Having mana means spending it; not having it means burning health
instead, and the two branches split cleanly. There's no null check followed by a
dereference — testing for presence and extracting the value happen at once.
if let is the shorthand when you only care about one branch:
if let Some(v) = maybe_value {
println!("{v}");
}
Loops
Rust has three loop forms:
while condition { } // while the condition holds
loop { } // forever
for x in collection { } // iterate
There's no C-style for (i = 0; i < n; i++); you use ranges.
for i in 0..3 { // 0, 1, 2
for i in 0..=3 { // 0, 1, 2, 3
for (i, x) in v.iter().enumerate() { // when you need the index
The matrix transpose assignment is exactly this shape:
pub fn transpose(matrix: [[i32; 3]; 3]) -> [[i32; 3]; 3] {
let mut ret = [[0; 3]; 3];
for i in 0..3 {
for j in 0..3 {
ret[i][j] = matrix[j][i];
}
}
ret
}
Loop control flow and labels
break and continue are familiar, but labels were a nice find.
'outer: for i in 0..n {
for j in 0..m {
if grid[i][j] == target {
break 'outer; // escape the outer loop directly
}
}
}
No more flag variables invented solely to escape a nested loop.
Why Rust has a separate loop
If while true works, why is there a loop keyword? This was the most
interesting bit of the session, and the answer is the compiler's flow
analysis.
fn get_value() -> i32 {
loop {
if let Some(v) = try_read() {
break v; // loop can produce a value via break
}
}
// the compiler knows control never reaches here
}
loop is known at the type-system level to never finish without a break, so
the function is accepted as returning a value even with nothing after the loop.
while true, by contrast, has a condition expression, so the compiler must
consider the path after the loop.
Also, loop can yield a value through break; while cannot.
let result = loop {
let n = next_attempt();
if n > 0 {
break n; // this becomes result
}
};
"Retry until it succeeds, then use that value" without a scratch variable.
return and the never type
return is itself an expression, of type ! (never, the divergent type).
let name = match input {
Some(s) => s,
None => return Err(Error::MissingName), // this arm produces no value
};
Both arms of a match must have the same type, and this works because return
has type !, which coerces to any type. panic!(), std::process::exit(),
and loop { } are in the same family.
That's what lets "bail out early on failure, otherwise unwrap and continue" be a single expression.
Operators
Arithmetic, bitwise, comparison and logical operators mostly match C, with differences:
- Logical operators only take
bool.if xwith an integerxis an error — you must writeif x != 0. The C habit ofif (ptr)doesn't exist - The only unary negation is
-. Bitwise NOT is!, not~: onboolit's logical negation, on integers it's bitwise inversion - Overflow checking, as covered earlier, panics in debug builds
&&and||short-circuit
Assignment has one big difference: in Rust it produces no value. Idioms
like a = b = c or while (n = read()) > 0 don't exist — which makes the classic
= versus == bug impossible.
Type casting happens only via as, always explicitly (covered last session).
Reference operators &, &mut, and dereference * are expressions too.
Fields, elements, and closures
- field access
s.field, tuplest.0, indexingv[i] - slicing
&v[1..4],&v[..3],&v[2..] - method calls
x.method()— auto-deref and auto-borrow as needed
Closures got a brief introduction at the end:
let is_even = |x: i32| x % 2 == 0;
let add = |a, b| a + b; // types inferred
They get a full session later, so here it was just "functions are values too."
Assignment #1
The first assignment came at the end of this session. Everything is "make
cargo test pass."
- Fibonacci — recursion. It asks "when will this function panic?"; the
answer is
u32overflow for largen, which panics in a debug build, tying straight back to the overflow session - Collatz Sequence —
whileplus anifexpression - Nested Arrays — transposing a
[[i32; 3]; 3], i.e. practice writing nested array types precisely - Role Playing Game — an
Optionfield andmatch - RPN Calculator — a stack and
Vec - Minesweeper — 2D indexing and boundary handling. The trap is that
usizeindices makei - 1an underflow - Stock Simulator (BOJ 31001) — a competitive-programming problem
Minesweeper caught me squarely on usize underflow. Since indices are usize,
0 - 1 doesn't wrap to a huge number — it panics in a debug build. The fix is
to compute in i32 and bounds-check before converting, or to use checked_sub.
The overflow-policy material showed up in an assignment immediately.
Takeaways
- nearly everything is an expression, and a block's last expression is its value
- one semicolon decides between "yields a value" and "discards it"
matchchecks exhaustiveness, and patterns destructure valuesloopgets special treatment in flow analysis thanks to the never type, and yields values viabreak- assignment produces no value, so
=/==confusion bugs are gone
Next: error handling — panics, Result, and the ? operator.