Procedural Programming with C · Basic · Week 10

Loops and Flow Control

Three loop forms, and they are interchangeable — any one can express what the others do. What is not interchangeable is the discipline of knowing why a loop is correct, which is the difference between code that works on your test case and code that works.

By the end of this week you can
  • Choose between for, while, and do-while for a given task.
  • State a loop's invariant and use it to argue the loop is correct.
  • Reason about half-open ranges and stop making off-by-one errors.
  • Use break and continue, and escape a nested loop cleanly.
  • Say when goto is defensible in C, and why week 27 comes back to it.

1The three forms

while — test first

while (condition) {
    body
}

Test, then run the body, then test again. If the condition is false initially, the body never runs. Use it when the number of iterations is not known in advance.

for — the counted loop

for (initialization; condition; update) {
    body
}

Exactly equivalent to:

initialization;
while (condition) {
    body
    update;
}

The value of for is that all three pieces of loop bookkeeping sit on one line where a reader can check them together. Use it whenever there is a counter.

All three clauses are optional. for (;;) is the conventional infinite loop — an empty condition is treated as true.

do-while — test after

do {
    body
} while (condition);      /* note the semicolon */

The body always runs at least once. This is the right shape for "ask, then check whether the answer was acceptable" — the input loop from week 8 is naturally a do-while. It is the least used of the three, and the trailing semicolon is easy to forget.

Declaring the counter inside

for (int i = 0; i < n; i++) { … }
/* i does not exist here */

A C99 feature, and the right default: the counter is scoped to the loop, so it cannot be accidentally reused afterwards and the name is free for the next loop. Week 2 showed this failing under -std=c89.

2Half-open ranges and off-by-one errors

The canonical C loop counts from 0 while the index is less than the count:

for (size_t i = 0; i < n; i++) { … }

This is a half-open range, written [0, n): it includes 0 and excludes n. It is not arbitrary convention — it has three properties that eliminate whole categories of mistake.

PropertyWhy it helps
The number of iterations is n - 0 = nThe count is visible without arithmetic
An empty range is i < 0, which is simply falseZero elements needs no special case
Adjacent ranges join without gaps or overlap[0,k) and [k,n) cover [0,n) exactly

Compare with the closed alternative:

for (size_t i = 0; i <= n - 1; i++) { … }   /* same idea, three defects */

It needs the reader to compute n - 1; it breaks when n is 0, because 0 - 1 on an unsigned type wraps to an enormous value (week 7); and two adjacent ranges written this way are easy to overlap by one.

The off-by-one checklist. When a loop is wrong by one, ask three questions in this order. Does the first iteration handle the first element? Does the last iteration handle the last element? Does the loop do the right thing when there are zero elements? Almost every off-by-one error is one of those three, and checking them takes ten seconds.

3Loop invariants

An invariant is a statement that is true before the loop starts, true after every iteration, and therefore true when the loop ends. Combine it with the exit condition and you have an argument that the loop is correct — not a test that it happened to work.

int maximum = values[0];
/* invariant: maximum equals the largest of values[0..i-1] */
for (size_t i = 1; i < n; i++) {
    if (values[i] > maximum) {
        maximum = values[i];
    }
}
/* loop ended with i == n, so by the invariant:
   maximum is the largest of values[0..n-1] — the whole array */

Writing the invariant as a comment is a genuine technique, not a classroom exercise. It forces the two questions that catch real bugs: is it true before the first iteration — here, that maximum is the largest of a one-element range, which is why the initialization reads values[0] and the loop starts at 1 — and does the body preserve it?

It also exposes an edge case immediately. The invariant assumes values[0] exists, so this loop is wrong for n == 0. The fix has to be a guard before the loop, and the invariant is what told you so.

Termination

An invariant shows the loop computes the right thing if it stops. Termination is a separate obligation: identify a quantity that strictly decreases and cannot go below a bound. In a counting loop it is n - i. In a while loop driven by input it is the number of remaining characters. If you cannot name that quantity, you have not shown the loop terminates.

4Nested loops

for (size_t row = 0; row < rows; row++) {
    for (size_t col = 0; col < cols; col++) {
        printf("%4zu", row * col);
    }
    putchar('\n');
}

The inner loop runs completely for each iteration of the outer one, so the body executes rows × cols times. That multiplication is worth keeping in mind: a nested loop over a thousand-element array is a million operations, and three levels is a billion. Week 33 gives this the name O(n²).

Give nested counters real names. i and j are fine one level deep; at two levels row and col prevent the single most common nesting bug, which is using the wrong index inside the inner body.

5break, continue, and goto

break

Leaves the innermost enclosing loop or switch immediately.

size_t found = n;                 /* n means "not found" */
for (size_t i = 0; i < n; i++) {
    if (values[i] == target) {
        found = i;
        break;
    }
}

continue

Skips the rest of the body and goes to the next iteration. In a for loop the update clause still runs; in a while loop it does not, which is a genuine hazard:

size_t i = 0;
while (i < n) {
    if (skip(values[i])) {
        continue;                 /* i never increments — infinite loop */
    }
    process(values[i]);
    i++;
}

This is a strong argument for preferring for whenever there is a counter: the update lives in the loop header where continue cannot bypass it.

Escaping a nested loop

break leaves only one level. The three usual options:

/* 1. A flag — works, but clutters both conditions */
bool done = false;
for (size_t r = 0; r < rows && !done; r++) {
    for (size_t c = 0; c < cols; c++) {
        if (grid[r][c] == target) { done = true; break; }
    }
}

/* 2. A function — usually the cleanest; return leaves everything */
static bool contains(int grid[][COLS], size_t rows, int target)
{
    for (size_t r = 0; r < rows; r++) {
        for (size_t c = 0; c < COLS; c++) {
            if (grid[r][c] == target) {
                return true;
            }
        }
    }
    return false;
}

/* 3. goto — the one case where most C style guides allow it for loops */
for (size_t r = 0; r < rows; r++) {
    for (size_t c = 0; c < cols; c++) {
        if (grid[r][c] == target) {
            goto found;
        }
    }
}
puts("not found");
goto done;
found:
puts("found");
done:
;

Option 2 is the right default. If the inner work deserves a name, giving it one solves the control-flow problem as a side effect.

About goto

goto jumps to a label within the same function. Its reputation comes from an era when it was the primary control structure and programs became untraceable. With if, loops, and functions available, that use is obsolete.

Two uses survive in modern C. Breaking out of nested loops, as above. And — far more important — unwinding resources on an error path, which is the dominant error-handling idiom in the Linux kernel and most serious C libraries:

FILE *in = fopen(path, "r");
if (in == NULL)          goto fail;
buf = malloc(size);
if (buf == NULL)         goto close_in;
…
close_in:
    fclose(in);
fail:
    return NULL;

That pattern is week 27's subject, once you have files and dynamic memory to unwind. Mentioning it now is deliberate: you should not leave this week thinking goto is simply forbidden.

6Worked example: a sieve and a table

Two classic loops, each with its invariant stated and its boundaries tested.

#include <stdio.h>
#include <stdlib.h>
#include <stdbool.h>

#define LIMIT 50
#define TABLE 9

/* Sieve of Eratosthenes over [2, LIMIT].
   Invariant: after processing p, every composite with a factor <= p
   has been marked false. */
static void print_primes(void)
{
    bool is_prime[LIMIT + 1];

    for (size_t i = 0; i <= LIMIT; i++) {
        is_prime[i] = true;
    }
    is_prime[0] = false;
    is_prime[1] = false;

    for (size_t p = 2; p * p <= LIMIT; p++) {
        if (!is_prime[p]) {
            continue;                 /* already crossed out */
        }
        /* Start at p*p: smaller multiples of p have a smaller
           factor and were crossed out in an earlier pass. */
        for (size_t multiple = p * p; multiple <= LIMIT; multiple += p) {
            is_prime[multiple] = false;
        }
    }

    printf("primes up to %d:\n ", LIMIT);
    size_t printed = 0;
    for (size_t i = 2; i <= LIMIT; i++) {
        if (is_prime[i]) {
            printf("%4zu", i);
            if (++printed % 10 == 0) {
                printf("\n ");
            }
        }
    }
    putchar('\n');
}

/* A multiplication table with aligned headers. */
static void print_table(void)
{
    printf("\nmultiplication table\n");

    printf("    ");
    for (size_t col = 1; col <= TABLE; col++) {
        printf("%4zu", col);
    }
    printf("\n    ");
    for (size_t col = 1; col <= TABLE; col++) {
        printf("----");
    }
    putchar('\n');

    for (size_t row = 1; row <= TABLE; row++) {
        printf("%2zu |", row);
        for (size_t col = 1; col <= TABLE; col++) {
            printf("%4zu", row * col);
        }
        putchar('\n');
    }
}

/* Linear search returning the count as "not found",
   so the caller needs no separate flag. */
static size_t find(const int values[], size_t n, int target)
{
    for (size_t i = 0; i < n; i++) {
        if (values[i] == target) {
            return i;
        }
    }
    return n;
}

int main(void)
{
    print_primes();
    print_table();

    const int data[] = { 4, 8, 15, 16, 23, 42 };
    const size_t n = sizeof data / sizeof data[0];

    printf("\nsearching %zu values\n", n);
    const int targets[] = { 15, 99 };
    for (size_t t = 0; t < 2; t++) {
        size_t at = find(data, n, targets[t]);
        if (at == n) {
            printf("  %d: not found\n", targets[t]);
        } else {
            printf("  %d: at index %zu\n", targets[t], at);
        }
    }

    /* The boundary case every search must survive. */
    printf("  empty array: %s\n",
           find(data, 0, 15) == 0 ? "correctly reports not found" : "BUG");

    return EXIT_SUCCESS;
}
gcc -std=c17 -Wall -Wextra -g -o loops loops.c
./loops

Three decisions worth examining

Why the sieve's inner loop starts at p * p. Any multiple of p smaller than has a factor smaller than p, so an earlier pass already crossed it out. The comment records the reasoning, because the code alone looks like an arbitrary optimization.

Why the outer loop stops at p * p <= LIMIT. Once p exceeds √LIMIT, is past the end and the inner loop would not execute at all. The invariant tells you the sieve is already complete at that point.

Why find returns n for "not found". There is no index n in a half-open range [0, n), so it is an unambiguous sentinel that costs no extra variable and cannot be confused with a real result. Returning −1 is the other convention, but it forces a signed return type and therefore a signed/unsigned comparison at the call site — week 7's trap.

Test the boundaries, not the middle

Change LIMIT to 1, then 2, then 3, and rebuild each time.

LIMITExpected
1no primes; the final loop must not run
2just 2; the sieve's outer loop never executes, since 2·2 > 2
32 and 3
42 and 3; the first crossing-out happens

A loop that is right in the middle and wrong at the edges is the normal failure mode. Testing 1, 2, and 3 finds more bugs than testing 1000.

7Common mistakes

MistakeWhat happensFix
for (i = 0; i <= n; i++) over an arrayReads one element past the endUse <, not <=. Half-open ranges.
i < n - 1 when n is unsigned and 0Wraps; loop runs foreverWrite i + 1 < n.
for (i = 0; i < n; i++);Empty body; the block runs onceDelete the semicolon; -Wempty-body warns.
continue in a while before the incrementInfinite loopUse for, or increment before continue.
Modifying the counter inside the bodyIteration count becomes unpredictableLet the header own the counter.
break expected to leave both loopsOnly the inner loop exitsExtract a function and return.
Forgetting the semicolon after do { } while (c)Confusing syntax errorIt is required.
Comparing floats in the loop conditionMay never terminateCount with an integer and compute the float from it.

8Check yourself

Why is i < n preferred over i <= n - 1?

Three reasons. The iteration count is visible as n with no arithmetic; the empty case n == 0 works without a special guard, whereas n - 1 on an unsigned type wraps to a huge value; and adjacent half-open ranges tile exactly, with no gap or overlap.

What is a loop invariant and what does it buy you?

A statement true before the loop and preserved by every iteration, so it is still true when the loop exits. Combined with the exit condition it yields an argument that the loop computes the right answer — a proof sketch rather than a successful test. Writing it also tends to expose the empty and single-element cases immediately.

Why can continue in a while loop cause an infinite loop but not in a for loop?

In a while loop the increment is a statement in the body, and continue jumps past it. In a for loop the update clause is part of the loop header and runs on every continue. This is a concrete reason to prefer for whenever a counter is involved.

break inside a nested loop only exits one level. What is the best fix?

Extract the nested loops into a function and use return, which leaves every level at once and usually improves the code by naming the operation. A flag works but clutters both conditions, and goto to a label after the loops is acceptable but harder to read.

Is goto forbidden in professional C?

No. Its use as general control flow is obsolete, but two patterns are standard practice: escaping nested loops, and unwinding acquired resources on an error path — goto cleanup, which is pervasive in the Linux kernel and most serious C libraries because C has no destructors. Week 27 builds that pattern properly.

9Where this leads

You now have every control structure C offers — there are no others. Week 11 turns to organizing code rather than execution: functions, what happens to the call stack when one is called, and why a function that tries to modify its argument cannot succeed. That last failure is the question week 13 answers with pointers.