Tech Wiki

TOPICSSERIES

[Rust Zero to Production 03] Rust Expressions, Functions, and Control Flow Without Surprises

In Rust, one semicolon can change a function's type. A final value * 2 evaluates to an i32; value * 2; discards that value, leaving the block with the unit value (). If the signature promises i32, the second version is a type error rather than a style preference.

This article is for developers who already know functions and conditions in another language but are new to Rust. The examples build small endpoint-checking policy functions around blocks, if, match, loop, while, and for. The complete crate below uses Rust 2024 and includes rustfmt, Clippy, and unit-test commands.

1. Control-Flow Scope

The finished code has no I/O. It chooses a retry delay, classifies latency, calculates backoff, and scans check results. Keeping branch logic in small pure functions gives each boundary a direct assert_eq! test.

2. Function Signatures and Boundaries

A Rust function starts with fn. Every parameter has a declared type, and a return type follows -> when the function returns a value. A definition does not have to appear before its call in the source file; it only needs to be visible in the caller's scope.

pub fn retry_delay(
    last_check_succeeded: bool,
    attempts: u8,
) -> u64 {
    if last_check_succeeded {
        0
    } else if attempts < 3 {
        5
    } else {
        30
    }
}

The body of retry_delay is a block expression. Its final if is also an expression, so the selected u64 becomes the function result. Rust also has return for an early exit, but a tail expression keeps this short function easier to follow.

3. Statements and Expressions

Statements are either declarations or expression statements. let delay = 5; is a statement that introduces a name. The literal 5, a function call, a block, if, and match are expressions that produce values. Add a semicolon where an expression is used as a statement, and Rust evaluates then discards its value.

fn doubled(
    value: i32,
) -> i32 {
    value * 2
}

Changing the last line to value * 2; removes the tail expression. The block then evaluates to (). Compiling that broken version with rustc --edition 2024 produced this diagnostic:

error[E0308]: mismatched types
 --> src/lib.rs:1:27
  |
1 | fn doubled(value: i32) -> i32 {
  |    -------                ^^^ expected `i32`, found `()`
  |    |
  |    implicitly returns `()` as its body has no tail or `return` expression
2 |     value * 2;
  |              - help: remove this semicolon to return this value

error: aborting due to 1 previous error

For more information about this error, try `rustc --explain E0308`.

A line such as println!("done"); is different: its side effect is the point, so discarding the result is natural. "Never use a semicolon on the last line" is the wrong rule. Ask whether the enclosing expression needs that value.

4. Values from if and match

An if can sit on the right side of let or be the tail expression of a function. Every branch that can complete must give the overall expression a compatible type. In retry_delay, all three branches produce integer literals that resolve to u64. The condition itself must be bool; Rust does not coerce integers or other values into truthiness.

A match reads better when the cases are discrete ranges or patterns.

pub fn latency_label(
    milliseconds: u64,
) -> &'static str {
    match milliseconds {
        0..=199 => "fast",
        200..=999 => "slow",
        _ => "very slow",
    }
}

Rust checks arms from top to bottom and selects the first matching pattern. _ catches values outside the first two ranges, making the match exhaustive for u64. Each arm produces the same &'static str type.

This example deliberately matches integers. A later installment covers enums, Option, and Result as domain-state tools rather than squeezing them into a syntax tour.

5. Loops

Use while when a condition controls repetition. Here, each iteration consumes one recorded failure and doubles the delay. saturating_mul keeps an oversized result at u64::MAX instead of wrapping around.

pub fn backoff_seconds(
    initial_seconds: u64,
    failures: u8,
) -> u64 {
    let mut delay = initial_seconds;
    let mut remaining = failures;

    while remaining > 0 {
        delay = delay.saturating_mul(2);
        remaining -= 1;
    }

    delay
}

When the task is to visit every item, for avoids manual indexing.

pub fn count_successes(results: &[bool]) -> usize {
    let mut successes = 0;

    for succeeded in results {
        if *succeeded {
            successes += 1;
        }
    }

    successes
}

Use loop when its exit condition belongs inside the body, especially when breaking should yield a value. break candidate both exits and supplies the value of the loop expression.

pub fn first_multiple_at_or_after(
    start: u64,
    divisor: u64,
) -> u64 {
    assert!(divisor > 0, "divisor must be positive");
    let mut candidate = start;

    loop {
        if candidate.is_multiple_of(divisor) {
            break candidate;
        }
        candidate += 1;
    }
}

while and for normally evaluate to (). Only loop and labeled blocks produce non-trivial loop results. To return a computed value, use break value from a loop, or finish a function with the accumulator after a while or for.

6. Boundary and Happy-Path Tests

One happy-path test can leave half the branch logic untouched. These tests cover all retry-policy outcomes and both ends of each latency range.

To reproduce the checks, start in any directory where you can create a project. These commands create a crate below that directory and enter it:

cargo new --lib --edition 2024 functions-expressions-control-flow
cd functions-expressions-control-flow
mkdir -p examples

Copy the five pub fn listings above into src/lib.rs in the order shown, then append this test module to the same file. This is the complete set of six tests that was run.

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn if_expression_selects_each_retry_policy() {
        assert_eq!(retry_delay(true, 0), 0);
        assert_eq!(retry_delay(false, 0), 5);
        assert_eq!(retry_delay(false, 2), 5);
        assert_eq!(retry_delay(false, 3), 30);
    }

    #[test]
    fn latency_match_covers_ranges_and_fallback() {
        assert_eq!(latency_label(0), "fast");
        assert_eq!(latency_label(199), "fast");
        assert_eq!(latency_label(200), "slow");
        assert_eq!(latency_label(999), "slow");
        assert_eq!(latency_label(1_000), "very slow");
    }

    #[test]
    fn while_loop_applies_bounded_backoff() {
        assert_eq!(backoff_seconds(5, 0), 5);
        assert_eq!(backoff_seconds(5, 3), 40);
        assert_eq!(backoff_seconds(u64::MAX, 1), u64::MAX);
    }

    #[test]
    fn for_loop_counts_true_values() {
        assert_eq!(count_successes(&[]), 0);
        assert_eq!(count_successes(&[true, false, true]), 2);
    }

    #[test]
    fn loop_break_returns_a_value() {
        assert_eq!(first_multiple_at_or_after(14, 5), 15);
        assert_eq!(first_multiple_at_or_after(15, 5), 15);
    }

    #[test]
    #[should_panic(expected = "divisor must be positive")]
    fn zero_divisor_is_rejected() {
        first_multiple_at_or_after(10, 0);
    }
}

For a runnable sample, save this as examples/policy.rs:

use functions_expressions_control_flow::{
    backoff_seconds, count_successes, first_multiple_at_or_after, latency_label, retry_delay,
};

fn main() {
    println!("retry delay: {}s", retry_delay(false, 2));
    println!("latency: {}", latency_label(240));
    println!("backoff: {}s", backoff_seconds(5, 3));
    println!("successes: {}", count_successes(&[true, false, true]));
    println!("next multiple: {}", first_multiple_at_or_after(14, 5));
}

Run these commands from the functions-expressions-control-flow directory. The first command normalizes the teaching-oriented line breaks; the second checks that formatting remains unchanged. The test applies to the assembled crate above, not to every teaching excerpt as a separate source file.

cargo fmt
cargo fmt --check
cargo clippy --all-targets --all-features -- -D warnings
cargo test --all-features
cargo run --quiet --example policy
running 6 tests
test tests::for_loop_counts_true_values ... ok
test tests::if_expression_selects_each_retry_policy ... ok
test tests::latency_match_covers_ranges_and_fallback ... ok
test tests::loop_break_returns_a_value ... ok
test tests::while_loop_applies_bounded_backoff ... ok
test tests::zero_divisor_is_rejected - should panic ... ok

test result: ok. 6 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

The sample prints:

retry delay: 5s
latency: slow
backoff: 40s
successes: 2
next multiple: 15

The example uses rustc 1.98.1, cargo 1.98.1, with the Rust 2024 edition. The example has no external crates.

7. Limits of Concise Control Flow

Expression-oriented syntax can remove temporary assignments. That does not make dense code automatically better. If a match arm performs several calculations and side effects, split value selection into a small function and leave file, network, or console I/O at the call site.

Input bounds are part of the contract too. The backoff example saturates on multiplication, but first_multiple_at_or_after does not promise that candidate += 1 is safe for every possible u64. Production code should validate its accepted range or represent failure with checked_add. Likewise, assert! is not a general user-input error model; here it marks a zero divisor as a programmer error.

A Rust semicolon does more than mark the end of a line. It decides whether a value continues into the enclosing expression or gets discarded. The next installment adds tuples, arrays, and destructuring patterns to these same expression rules.

Full source code

The complete runnable source for this article is available in the Chapter 03 project on GitHub.

Sources


One response

  1. […] Next articleRust Expressions, Functions, and Control Flow Without Surprises […]

Leave a Reply

Your email address will not be published. Required fields are marked *

Tech Wiki

Built with WordPress · Learn in public.