</>PatchNote
Back to posts

2024-07-14

HSPACE Rust Lectures #5 — Expressions

RustHSPACE 2024ExpressionsControl Flow

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 x with an integer x is an error — you must write if x != 0. The C habit of if (ptr) doesn't exist
  • The only unary negation is -. Bitwise NOT is !, not ~: on bool it'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, tuples t.0, indexing v[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."

  1. Fibonacci — recursion. It asks "when will this function panic?"; the answer is u32 overflow for large n, which panics in a debug build, tying straight back to the overflow session
  2. Collatz Sequence — while plus an if expression
  3. Nested Arrays — transposing a [[i32; 3]; 3], i.e. practice writing nested array types precisely
  4. Role Playing Game — an Option field and match
  5. RPN Calculator — a stack and Vec
  6. Minesweeper — 2D indexing and boundary handling. The trap is that usize indices make i - 1 an underflow
  7. 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"
  • match checks exhaustiveness, and patterns destructure values
  • loop gets special treatment in flow analysis thanks to the never type, and yields values via break
  • assignment produces no value, so =/== confusion bugs are gone

Next: error handling — panics, Result, and the ? operator.