Skip to main content

Range sum and structured loops

Two loop forms that look similar and lower very differently: a for over a range, which is sugar over a mutable counter, and a labelled loop that carries state as a WebAssembly block parameter.

func sum_except_five(end: i32) -> i32 {
  // `var`, not `let`, because it is assigned below. `let` bindings are
  // immutable and reassigning one is a type error, not a warning.
  var total: i32 = 0;

  // Signed and end-exclusive: the guard is i32.lt_s, so this runs for
  // index = 0..=end-1, and zero times if end <= 0. Both endpoints are
  // evaluated once up front and copied into hidden locals -- writing
  // `0..expensive()` calls expensive() once, not once per iteration.
  for index in 0..end {
    if (index == 5) {
      // `continue` skips the rest of the body but NOT the increment. It
      // branches to a block that closes just before the i32.add, so the
      // counter still advances. This loop terminates.
      continue;
    }
    // `index` is an immutable binding. `index = index + 1` is a type error;
    // the compiler owns the induction variable, you only read it.
    total = total + index;
  }

  return total;
}

func retries() -> i32 {
  // A labelled loop used as an expression -- here as the operand of `return`.
  // Spec section 8 says a non-unit control expression must be consumed by
  // something; in practice the current compiler also accepts it as a bare
  // statement and emits a `drop`. Do not rely on that.
  return loop retry(attempt: i32 = 0) -> i32 {
    if (attempt == 3) {
      // `break` supplies the loop's RESULT. Branches forward to the exit
      // block, whose block type is (result i32).
      break retry(attempt);
    }
    // `continue` supplies the NEXT ITERATION'S PARAMETER. Branches backward
    // to the loop header, whose block type is (param i32).
    // Same keyword shape, opposite direction, different value slot.
    continue retry(attempt + 1);
  };
  // Note there is no fall-through path out of that body: every route ends in
  // a break or a continue. A non-unit body that can reach its closing brace
  // is rejected -- there would be no value to produce.
}

func check() -> i32 {
  if (sum_except_five(10) == 40 && retries() == 3) {
    return 0;
  }
  return 1;
}

export func check as "check";

Why for is sugar and loop is not

for index in 0..end has no WebAssembly counterpart. It expands to a counter local, a snapshot local for the upper bound, an outer block to break to, a loop, an i32.lt_s guard, an inner block to continue to, and an i32.add after the body. The snapshot is the part people get wrong when hand-writing the equivalent: because the bound is copied before the first iteration, the range is fixed at entry. That is a deliberate semantic choice, not an optimization — it means a for header can never be a source of surprise re-evaluation.

The inner continue block is the other detail worth internalizing. A naive lowering makes continue branch straight to the loop header, which skips the increment and hangs. Here continue targets a block that ends before the i32.add, so falling out of it and reaching the increment is the same path a normal body completion takes. continue and normal completion converge; only break leaves early.

loop retry(attempt: i32 = 0) -> i32 is the opposite: it is a near-direct spelling of a WebAssembly loop (param i32) (result i32) wrapped in a block (result i32). Nothing is invented. The loop parameter is how you thread state through iterations without a mutable local — the value lives on the operand stack across the back-edge rather than in a var. If you have ever written a fold as a tail-recursive helper function to avoid mutation, this is the same idea with the stack frame removed.

Why break and continue mean different things here

Both take an argument list, both name the same label, and they target opposite ends of the construct. break retry(v) branches to the exit block and v becomes the loop expression's value. continue retry(v) branches to the loop header and v becomes attempt on the next pass. The arity and types come from different signatures — the loop's result type and the loop's parameter list respectively — so a mistake here is usually caught as a type error rather than silently doing the wrong thing.

The exception is a loop whose parameter and result types coincide, as in this example, where both are a single i32. Swapping break and continue there typechecks cleanly and produces either a wrong answer or an infinite loop. There is no diagnostic that will save you; this is a genuine sharp edge of the design.

A for sits at the other extreme: it is a unit loop target, so unlabelled break and continue may target it but neither accepts values. If you need a loop that produces a result, you need the labelled form.

Expected result

check() returns 0. The range sums 0 through 9 skipping 5, giving 40; the retry loop exits at 3.