Skip to content
academia.sh

Lesson 06 / 12

Control Flow

if, match, loop, while, for — of the five control-flow forms, only three can produce a value: if and match are each an expression and require all their branches to give the same type, loop gives a value only through break, while and for never do; a labeled break can carry a value directly out of nested loops.

Published Updated

In this lesson

  • Classifies by running it which of the if, match, loop, while, for forms produce a value as an expression
  • Shows that all of if's and match's branches must give the same type, and that a labeled break can carry a value out of a nested loop
  • Proves that an if without else is also an expression, and that its value is ()
Contents

The previous lesson already used the if/else if/else chain as an expression, but it showed the state of only one of these five control-flow forms (if, match, loop, while, for). This lesson runs all five side by side and asks each of them a single question: does this form produce a value where it is used, or does it only do a piece of work?

These five forms are grouped under a single category in most languages — “control-flow constructs” — because all of them determine which lines of a program run in what order. Rust preserves this common function, but further splits the five into two: some forms also produce a value while directing flow, some forms only direct flow. This split is not arbitrary — it stands on the same axis as the question the course has asked from the start, and it is answered once and for all in this lesson across the five forms.

Five Forms, Three Answers

// control_flow.rs — how many of the five forms produce a value
fn main() {
    let number = 7;

    let if_result = if number % 2 == 0 { "even" } else { "odd" };
    println!("if: {}", if_result);

    let match_result = match number {
        0 => "zero",
        n if n % 2 == 0 => "even",
        _ => "odd",
    };
    println!("match: {}", match_result);

    let mut counter = 0;
    let loop_result = loop {
        counter += 1;
        if counter == 5 {
            break counter * 10;
        }
    };
    println!("loop: {}", loop_result);

    let mut total = 0;
    let while_result = while total < 3 {
        total += 1;
    };
    println!("while: {:?}", while_result);

    let mut list_total = 0;
    let for_result = for element in [1, 2, 3] {
        list_total += element;
    };
    println!("for: {:?}", for_result);
    println!("list_total: {}", list_total);
}
if: odd
match: odd
loop: 50
while: ()
for: ()
list_total: 6

The five forms give three separate answers. if and match each produce a value — whichever branch is selected, they give that branch’s value. loop also produces a value, but only when a value is given to break; here, once counter reaches five, break counter * 10 runs, and loop itself turns into the value 50. while and for, in turn, never produce a value — both give () in every case, whatever the loop body does. The list_total variable inside the for loop makes this clear: the loop’s own value is (), but a side effect inside its body (updating an outer variable) does the loop’s real work.

These three answers are not randomly distributed; each one’s source lies in the form’s own design. if and match are selective — they offer several branches, select exactly one, and the language’s natural choice is to count the selected branch’s value as the value of the whole expression. loop is unbounded — it does not know on its own when it will stop, so it can produce a value only when one is explicitly given to break; if break is never called, loop never ends either, and the question of its “value” does not even arise. while and for, in turn, are conditional — every turn of the loop depends on a condition (an explicit boolean expression in while, whether an iterator is exhausted in for), and the loop stops on its own when that condition is false. Which turn this natural stop happens on is not visible from the source, so it would be meaningless for the language to give these two forms a value — the source itself does not say which state counts as the “result.”

This threefold split has one more consequence: because loop is unbounded, it can actually set up the same flow as while true { } — neither stops on its own. But only loop can produce a value, while true { } cannot; because while itself, whatever its condition is, never offers break the ability to carry a value. The same unbounded flow, written with two different forms, ends up with two different capabilities.

LB15. if and match producing a value comes with one condition: all their branches must give a value of the same type. In the if_result example above, both branches give &str; in match_result, all three branches give &str. If one branch gave a different type (say, one branch &str, another i32), the compiler would reject it — because if_result or match_result must have a single type, and that type must be known at compile time, regardless of which branch is selected.

The second branch in match_result (n if n % 2 == 0 => "even") carries a match guard: the pattern n captures number’s value, and if n % 2 == 0 ensures this branch is selected only when the captured value is even. This is a broader example of pattern matching that will be taken up separately later; here it is enough to see that an additional condition can decide whether a branch is selected — its result is still that the branch produces a single value (“even”).

This restriction explains another property, also seen in match‘s branch carrying a match guard: how many branches a match carries and under which conditions the branches are selected may not be known until runtime, but the branches’ type is always known at compile time. The compiler can check the type of all branches without knowing which branch will run, because type checking looks at the whole of the source, not just the single branch that will be selected at runtime. This shows how type safety can be ensured independently of which branch is selected: each branch is checked separately, and then all of them are confirmed to be the same type — before any of them runs.

Carrying a Value Out of a Labeled Loop

loop producing a value with break raises a problem in nested loops: break breaks the nearest enclosing loop, but sometimes a value needs to be pulled out of a loop further out. Rust solves this with loop labels.

// labeled_loops.rs — a value carried out from a nested loop
fn main() {
    let target = 6;
    let mut i = 1;
    let result = 'outer: loop {
        let mut j = 1;
        loop {
            if j > 5 {
                break;
            }
            if i * j == target {
                break 'outer (i, j);
            }
            j += 1;
        }
        i += 1;
        if i > 5 {
            break (0, 0);
        }
    };
    println!("pair found: {:?}", result);
}
pair found: (2, 3)

Here two loops are nested. The inner loop has no label, and its own break breaks only itself — when j exceeds five, this inner loop ends and the outer loop continues by incrementing i. The outer loop carries the label 'outer; the line break 'outer (i, j), right from the middle of the inner loop, breaks the outer loop directly and carries the tuple (i, j) into result. When the product i=2, j=3 gives the target (6), this line runs and the loop chain ends immediately.

This pattern is familiar to any code searching a two-dimensional space: finding the first pair satisfying a given condition in a table, a grid, or a two-variable search space. If an unlabeled break were used, only the inner loop could be broken when the condition was met; the outer loop would continue, incrementing i and continuing the search — a separate flag variable to store the result and an extra check in the outer loop would be needed. A labeled break removes this extra checking and makes the search result directly the loop chain’s own value.

LB16. A labeled break does not only choose which loop is broken, it also determines which loop a value belongs to. The value of result is the value of the outermost 'outer loop — the inner loop itself never produced a value, and cannot, because the inner break; carries no value at all. The ability to return a tuple belongs only to the labeled loop; the inner loop only controls its own flow, it cannot directly touch the outer loop’s result — that is possible only with a labeled break.

This is why the two break lines in the source do different jobs even though they carry the same keyword. The unlabeled break; breaks the nearest enclosing loop — here, the inner loop — and carries no value, because the inner loop was never bound to a result. The labeled break 'outer (i, j), in turn, jumps directly to the named loop no matter how many loops lie in between, and determines that loop’s value. These two uses can stand side by side in the same lesson, in the same file, because the label only changes the target; the syntax and keyword stay the same.

Labeling applies not only to break, but also to continue — moving to the next turn of a labeled loop uses the same 'name: syntax too. This lesson measured only the value-carrying break, because the course’s axis is value production; labeling continue follows the same targeting logic, it just carries no value.

Limiting Measurement: An if Without else Is Also an Expression

Every if seen up to this point carried an else branch. An if with no else branch is also an expression — only its value is always ().

// if_without_else.rs — an if without else is also an expression, its value is ()
fn main() {
    let number = 4;
    let result = if number % 2 == 0 {
        println!("even found: {}", number);
    };
    println!("the value of an if without else: {:?}", result);
}
even found: 4
the value of an if without else: ()

result was genuinely bound to a binding — this proves if was evaluated as an expression, because Rust does not let you bind a statement to a binding. But result’s value is (), because what happens when the condition is not met was never written, and Rust fills this gap with () — as if there were an invisible else { }.

This is the else-less counterpart of the rule that if’s branches must “give the same type”: if the body of an else-less if tried to produce an i32 like number (if number % 2 == 0 { number }, with no else written at all), the compiler would reject it. Because the unwritten else branch is implicitly counted as producing (), and if the body produces i32, the two “branches” (the written body, the implicit else) would be different types — just as the two branches in the if_result example in the previous section had to be the same type. If the body of an else-less if is going to produce a value, that value can be nothing other than (); otherwise a semicolon must be added to the end of the body, silently discarding the value.

This boundary gives a shortcut when writing source: if an if is written only for a side effect (a print, an update) and there is nothing to do in the opposite case, there is no need to write an else branch — the compiler already expects (), and the body already produces (). else becomes mandatory only when both branches need to produce a different value.

This is an observation from the same family as the previous lesson’s semicolon rule. There, adding a semicolon to a function body’s last line turned the value the body produced into (); here, never writing an if’s else branch reaches the same result by a different path — the unwritten branch is implicitly counted as producing (). Both carry the same principle: in Rust, a “missing” value is never left undefined anywhere in the source; the missing place always corresponds to ().

The five forms splitting into three also offers a choice when writing source: do you need a structure that selects a computation among a fixed number of branches (if, match), or an open-ended repetition whose stopping condition is not known in advance (loop), or a repetition whose end is known in advance from a condition (while, for)? These three questions are independent of one another, and each one’s answer brings with it whether that form will produce a value. Before a programmer asks “will this loop give me a result,” it is enough to look at which loop form was chosen — the answer is hidden in the form itself.

This lesson brings together, in a single place, all the pieces this six-lesson section has gathered. The first lesson defined the three classes (expression, statement, item) and put them into numbers with a measurement core; the second lesson showed these classes hold at the project level too; the third lesson established binding; the fourth lesson established the types of bound values; the fifth lesson showed how a function body gathers these three classes into a single return value. This lesson, finally, measured how control flow itself — the five forms determining which line of a program runs when — fits the same three classes. Nothing written in the source fell outside this classification: from the smallest operator to the largest control structure, every line either produces a value, produces (), or only declares a name. This also explains why the single question asked at the start of these six lessons — does what you write in the source produce a value — did not stay confined to a single lesson but could carry an entire section: the question does not grow, only the writing it applies to widens.

Summary

  • Three of the five control-flow forms can produce a value: if and match always (by selecting a branch), loop only when a value is given to break.
  • while and for never produce a value; both give () in every case.
  • All of if’s and match’s branches must give a value of the same type; otherwise the compiler rejects it.
  • A labeled break can carry a value directly out of nested loops to the outermost labeled loop; unlabeled loops can only control their own flow.
  • An if without else is also an expression, and its value is always (); it does not compile if its body tries to produce a different type.
  • Nothing written in the source falls outside the expression / statement / item classification; this lesson showed that even control flow’s most complex forms fall into one of the same three answers.

Next Step

Across these six lessons, whether control flow produces a value was measured by looking at each form one by one. The next topic carries the same question into data: how a type is built, how many states it carries, and how those states are taken apart — a new section starting with structs and enums will take up these questions one by one. The types seen so far were the ready-made types the language itself provides (integer, floating-point, tuple); the next section takes up how new types are defined from these — fields gathered together, states counted together.

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ ↵ Esc navigate · open · close