Lesson 09 / 13
Control Flow
Which code path a branch follows being determined by data produced at runtime; switch fall-through, loop forms, goto's limited place, and the distinction between which branches exist and which one is chosen.
Published Updated
In this lesson
- Measures that which code path a branch follows depends on data produced at runtime
- Distinguishes switch fall-through, loop forms and goto's limited use with examples
- Shows the difference between which branches exist and which one runs, through the preprocessor and runtime stages
Contents
The Operators lesson measured what determines an expression’s result, and the answer was always the same: the types and structure written in the source were already known before a run ever started. Control flow reverses this order. Which branch a program follows, how many times a loop runs — these can no longer be told in advance by reading the source; they are known only once the program runs and produces the data.
The Programming Fundamentals course (M01/K02) already established if, while, and
similar control structures with Python; there is a superficial resemblance between that
syntax and this one, but this lesson measures not syntax but at which stage a decision
is made: whether a branch is chosen or not depends on data, while which branches exist
in the source can depend on a separate stage entirely.
This lesson is a turning point for M11’s measurement axis. The Data Types, Variables, and Operators lessons all pointed in the same direction: the types and structure written in the source made the decision before a run ever began. In control flow this direction reverses — whether a branch is chosen is now known not by reading the source but by running the program and producing the data. But this reversal is not absolute: as will be seen below, which branches even exist in the source can still be decided at a stage before the compiler, at the preprocessor. Control flow is therefore a subject that sits not in a single class, but at the intersection of two.
Data-Dependent Branching
The excerpt below is part of the core introduced in the Compilation Stages lesson: an array’s sum is computed, and a branch is chosen by looking at that sum.
// stage.c — data-dependent branching (excerpt)
#include <stdio.h>
#include <stddef.h>
int main(void) {
int data[] = {4, 8, 15, 16};
int sum = 0;
for (size_t i = 0; i < sizeof data / sizeof data[0]; i++) {
sum += data[i];
}
const char *verdict = (sum > 40) ? "large" : "small";
printf("%s %s\n", "data-dependent branch", verdict);
return 0;
}
data-dependent branch large
BS7. Whether the verdict variable will be "large" or "small" is not written
anywhere in the source — it cannot be known until the four numbers in the data array
are summed. Because sum comes out 43, the "large" branch is chosen, but the result
could change if a single element were added to the array or one element changed — with
the source code itself never changing. This is a decision diametrically opposed to the
operator choice measured in the previous lesson: there, types determined the result and
it was known before a run started; here, data determines the result and it can only be
known once the loop has completed.
This difference also shows up in the ?: conditional operator itself. Which branch of
(sum > 40) ? "large" : "small" produces a value depends on the run, but that the
expression itself produces a value is already known at compile time — the compiler
derives a common result type from the two branches (here both are the same, so const char *), and it knows one of the two will be chosen, only not which one. This shows a
general property of control flow: the source fixes at compile time which branches exist
and which type of result each branch will produce; only which one runs, decided by
looking at the data, is left to runtime. The same distinction holds for if bodies and
switch cases: every branch in the source is compiled and takes its place in the binary,
but which one actually executes on a given run can only be told after that run has
finished.
Switch Fall-Through
In a switch statement, consecutive case labels fall through into the same body
unless a break separates them — flow passes from one label to the next without being
interrupted. Fall-through here corresponds to what was closed in M06/K05 — this usage
is entirely separate from that lesson’s drop sense; C has no such concept as drop at
all.
// fallthrough.c — switch fall-through binds consecutive labels to the same body
#include <stdio.h>
static void print_level(int score) {
switch (score / 10) {
case 10:
case 9:
printf("%s\n", "level: A");
break;
case 8:
case 7:
/* deliberate fall-through: 8 and 7 fall into a shared body */
printf("%s\n", "level: B");
break;
default:
printf("%s\n", "level: C");
break;
}
}
int main(void) {
print_level(95);
print_level(88);
print_level(72);
print_level(40);
return 0;
}
level: A level: B level: B level: C
When score / 10 gives 10, flow enters the bodiless case 10 label, finds no body,
and falls through into case 9, running the body there; when it gives 9, it jumps
directly into case 9. The two labels thus share a single body. The same arrangement
applies to case 8 and case 7. Flow continuing when no break is written is C’s
default behavior; every case that wants to exit a body must state so explicitly
with break. The four calls above confirm this: 95 matches the bodied case 9 and
never falls through at all; 88 matches the bodiless case 8 and falls through into
case 7’s body.
Fall-through itself is always present in the source regardless of which case matches —
the compiler produces a path flowing from every label without a break to the next one,
and this path sits in the binary. But whether this path is followed is determined at
runtime, purely by the value at the head of the switch statement: when 95 is entered,
flow does not start from case 10 at all, it jumps directly to case 9, because
switch leaps to a matching label the instant it finds one and runs only from that point
onward, in sequence. Fall-through therefore does not mean “every label is tried in
sequence” — switch first finds the matching label, and fall-through only passes through
the labels after that point, bodiless, if no break separates them.
Loop Forms: When the Condition Is Tested
C carries three basic loop forms, and the difference between them is whether the condition is tested before or after the loop body — and this leads to a data-dependent difference in behavior.
// loops.c — while and do-while interpret the same condition with a different run count
#include <stdio.h>
int main(void) {
int counter = 5;
int while_run_count = 0;
while (counter < 5) {
while_run_count++;
counter++;
}
printf("%s %d\n", "while run count", while_run_count);
int do_while_run_count = 0;
do {
do_while_run_count++;
} while (counter < 5);
printf("%s %d\n", "do-while run count", do_while_run_count);
return 0;
}
while run count 0 do-while run count 1
Because counter starts at 5 and while (counter < 5) tests its condition before
the body, the condition is false from the very start and the body never runs at all:
while_run_count stays at zero. do { ... } while (counter < 5), by contrast, runs the
body first and tests the condition after; even though the condition is again false,
the body has already run once, and do_while_run_count comes out 1. Both loops carry
the same condition and run on the same data, but because the testing order differs,
their results diverge — this difference depends not on where the condition is written in
the source, but on which form the loop is declared in, and a for loop tests its
condition before the body just like while does.
All three forms have their body written only once in the source, but how many times it
runs depends on the data at runtime — if counter‘s starting value were different, the
same three loops would run a different number of times. The difference between while
and for is purely a matter of notation: for (start; condition; step) gathers a
while loop’s three parts onto a single line, and both follow the same testing order
(condition first). do-while is the one form that differs: when a loop’s body must run
at least once regardless of the condition — showing a menu, prompting for input — this
form expresses that directly, without needing a separate flag variable or writing the
body twice.
Goto: A Limited and Accepted Use
goto jumps directly to a label from anywhere in the source, and its general use is
discouraged because it makes flow harder to follow. But in a narrow scenario like exiting
nested loops in a single step, goto offers a widely accepted solution, because break
can only break the nearest loop.
// search.c — goto is a limited and accepted use for exiting a nested loop in one step
#include <stdio.h>
int main(void) {
int grid[3][3] = {{1, 2, 3}, {4, 5, 6}, {7, 8, 9}};
int target = 6;
int found_i = -1, found_j = -1;
for (int i = 0; i < 3; i++) {
for (int j = 0; j < 3; j++) {
if (grid[i][j] == target) {
found_i = i;
found_j = j;
goto found;
}
}
}
found:
printf("%s %d\n", "found row", found_i);
printf("%s %d\n", "found column", found_j);
return 0;
}
found row 1 found column 2
The value of target is found at grid[1][2], and goto found; leaves both loops in a
single line. Code trying to achieve the same result with break would have to exit the
inner loop, then check a flag variable to break the outer loop separately as well —
goto does the same job here without needing an extra flag variable. That the found
label sits outside the loops, near the end of the function, is also part of the accepted
usage: goto only jumps forward, to a point where the function’s natural flow ends, and
does not build a complex web of jumps in the middle of the source.
Whether the found label is actually jumped to is also a data-dependent decision: if the
value of target were never found inside grid, both loops would run to completion and
goto would never run at all, flow reaching the label only in sequence, at the loops’
natural end. In the source, the goto found; line always sits in the same place, but
whether that line runs — and if it does, at which loop step — is determined at runtime
by looking at grid’s contents and target‘s value. goto therefore does not depart
from control flow’s general rule: the jump itself is fixed in the source, whether the
jump is triggered depends on the data.
The Bounding Measurement
Which code path a branch follows depended on data, but which branches exist in the
source is not always determined by runtime — sometimes the preprocessor, before the
compiler ever sees the source, has already decided which cases will even exist.
// boundary.c — which branches exist is settled at the preprocessor, which one is chosen at runtime
#include <stdio.h>
#define EXTRA_CASE
static void evaluate_code(int code) {
switch (code) {
case 1:
printf("%s\n", "case 1 ran");
break;
#ifdef EXTRA_CASE
case 2:
printf("%s\n", "case 2 ran (preprocessor confirmed its existence)");
break;
#endif
default:
printf("%s\n", "unrecognized case");
break;
}
}
int main(void) {
for (int code = 1; code <= 3; code++) {
evaluate_code(code);
}
return 0;
}
case 1 ran case 2 ran (preprocessor confirmed its existence) unrecognized case
Because the EXTRA_CASE macro is defined, the case 2 label is included into the text
by the preprocessor before the compiler ever sees the source. Had EXTRA_CASE not been
defined, that case block would have been erased from the source entirely — the compiler
would never see it, and flow would fall straight to default when code == 2 arrived.
In this measurement, case 2 exists because EXTRA_CASE is defined, and as code
advances from 1 to 3 in the loop, which case is chosen is determined at runtime at
every step by looking at the data (the loop counter). The two decisions are entirely
independent of each other: whether a branch exists is decided at the preprocessor,
whether that branch is chosen is decided at runtime — and if a branch never exists at
all, no data can choose it.
The order of these two stages cannot be reversed. The preprocessor runs first and either
erases #ifdef blocks from the source entirely or leaves them entirely; the compiler
already knows which cases exist in the text that reaches it and never makes a decision
like “let this case exist for this data.” Runtime, in turn, works under the opposite
constraint: it cannot change which branches already exist in the binary it has, it can
only choose among the ones that exist. This distinction produces a practical consequence
when tracking down a bug: “this branch never ran” and “this branch does not exist in the
source at all” are two separate possible causes, and where each is checked also differs —
one is looked for in the runtime output, the other in the source’s preprocessor
directives.
Summary
- Which code path a branch follows is determined by looking at data produced at runtime; the source code itself does not make this decision in advance — class runtime.
caselabels inside aswitchwith nobreakfall through into the next body; this fall-through is C’s default behavior unlessbreakis written explicitly in the source.while’s condition is tested before the body,do-while’s after; even with the same condition and the same data, this difference in order can change the run count.goto, in a narrow and accepted scenario like exiting nested loops in one step, does the same job without requiring an extra flag variable.- Which branches exist in the source can be decided at the preprocessor, and which of
the existing branches is chosen can be decided at runtime — both decisions can
coexist within the same
switchand are independent of each other. - The
?:conditional operator fixes at compile time that it produces a value and that value’s type; the type is derived from the two branches, only which branch actually produces the value is determined at runtime.
Next Step
The branches measured in this lesson all stayed within the body of a function already
known — which branch was chosen depended on data, but which function was called was
always fixed in the source. The Functions lesson tests this last assumption: which
definition a call is bound to is resolved only at the linking step, after two units have
been compiled separately. A function like evaluate_code above was always defined in the
same file; in the next lesson, a function will be called in one unit while its definition
sits in another — and what determines the link between the two, and at which stage, will
be settled there. In this lesson, the result of a test like sum > 40 depended only on
the data array; in the next lesson, a call’s result will also depend on which
definition the called function reaches — a name written in the source cannot correspond
to two different bodies in two different units, but only the linker knows which body it
is bound to.
To keep your progress and take notes, Log in
My notes
Log in to take notes.