3. Control Flow
Control flow is how a program decides what to do next: which statements run, how many times, and in what order. C offers a compact set of constructs — two selection forms, three loop forms, and four jumps — that map almost directly onto the conditional branches and jumps a CPU actually executes. This chapter covers each one precisely, including the traps (dangling else, switch fallthrough, continue in a while loop) that turn small syntactic slips into silent logic bugs.
<a id="31-selection"></a>
3.1 Selection
Selection means choosing between alternative paths based on a value. C gives you two constructs — if for arbitrary conditions and switch for dispatching on a single integer — and knowing when each is the right tool matters more than the syntax.
if and else
Theory
Every program needs to make decisions, and if is the primitive that expresses one. Its form is:
if (expression)
statement;
else
statement;
The crucial thing to understand is what "true" means here. C has no requirement that the condition be a boolean. The expression is evaluated, and the branch is taken if the result compares unequal to zero. That is the entire rule. The condition may be an int, a double, a pointer, or a character — anything with a scalar type.
if (5) /* true: 5 != 0 */
if (0) /* false */
if (-1) /* true: -1 != 0 */
if (0.0) /* false */
if (ptr) /* true if ptr is not a null pointer */
if ('a') /* true: 'a' is 97 */
This flexibility is why the idiomatic C null check is if (p) rather than if (p != NULL), and why if (n) reads as "if n is nonzero."
Three structural rules follow from the grammar, and each is a bug source:
1. if takes exactly one statement. To run several, you need a block. Without braces only the first statement is conditional, and indentation lies to you:
if (x > 0)
printf("positive\n");
printf("and nonzero\n"); /* ALWAYS runs -- not part of the if */
2. The else binds to the nearest unmatched if. This is the "dangling else" problem:
if (a)
if (b)
f();
else /* binds to 'if (b)', NOT 'if (a)', despite the indentation */
g();
3. A stray semicolon creates an empty body.
if (x > 0); /* the null statement IS the body */
printf("positive\n"); /* runs unconditionally */
All three disappear if you always use braces. That is why essentially every C style guide mandates them.
One more trap deserves separate mention: because assignment is an expression (Chapter 2.3), if (x = 5) is legal — it assigns 5 and tests the result, which is always true. Compilers warn under -Wall; some codebases still write if (5 == x) ("Yoda conditions") to make the mistake a syntax error.
Examples
#include <stdio.h>
#include <stdlib.h>
int main(void)
{
int n = 7;
/* Braces make the body unambiguous, even for one statement. */
if (n > 0) {
printf("%d is positive\n", n);
} else {
printf("%d is zero or negative\n", n);
}
/* Idiomatic truthiness: any nonzero scalar is true. */
int *p = malloc(sizeof *p);
if (p) { /* same as: if (p != NULL) */
*p = 42;
printf("allocated, *p = %d\n", *p);
free(p);
} else {
fprintf(stderr, "allocation failed\n");
return EXIT_FAILURE;
}
/* The dangling-else trap, written unambiguously. */
int a = 1, b = 0;
if (a) {
if (b) {
puts("a and b");
} else {
puts("a but not b"); /* clearly attached to 'if (b)' */
}
}
/* Assignment vs comparison. */
int x = 0;
if (x = 5) { /* WARNING under -Wall: always true */
printf("x is now %d\n", x);
}
x = 0;
if (x == 5) puts("never");
else puts("x is not 5");
/* if without else: the common guard-clause shape. */
if (n % 2 != 0) {
printf("%d is odd\n", n);
}
return 0;
}
Control-flow shape:
+-----------------+
| evaluate cond |
+-----------------+
| |
!= 0 | | == 0
v v
+-----------+ +-----------+
| then-body | | else-body | (else is optional)
+-----------+ +-----------+
| |
+-----+-----+
v
continue after
Key Takeaways
- The condition is any scalar expression; the branch is taken when it compares unequal to zero.
ifgoverns exactly one statement — use braces to attach more, and use them always.elsebinds to the nearest unmatchedif, regardless of indentation.- A stray
;afterif (...)silently makes the body empty. if (x = 5)assigns rather than compares;-Wallwarns, and extra parenthesesif ((x = f()))mark a deliberate assignment.
🧪 Practice
- Write the brace-less
ifbug above, confirm both lines execute, then fix it. Repeat with the stray-semicolon version.- Write a function
const char *sign(int n)returning"negative","zero", or"positive"usingif/else.- Construct a dangling-
elseexample where the indentation suggests one binding and the compiler chooses the other. Verify with a test value, then disambiguate with braces.- Interview-style: "Is
if (0.1 + 0.2 - 0.3)true or false?" Hint: the condition is not a comparison — ask what value the expression actually has (see Chapter 2.4).
Nested and Chained Conditions
Theory
Real decisions rarely have two outcomes. C gives you two ways to express more, and they mean different things.
Chaining (else if) selects one branch from several mutually exclusive alternatives. It is not a separate construct — it is an if statement appearing as the else body, which the grammar allows because an if is itself a statement:
if (a) { ... }
else if (b) { ... } /* really: else { if (b) { ... } else { ... } } */
else { ... }
Conditions are tested top to bottom, and evaluation stops at the first match. Two consequences follow: order matters, and later conditions may assume earlier ones were false. A classic bug is writing overlapping ranges in the wrong order:
if (score >= 60) return 'D'; /* catches 95 too -- everything below is dead */
else if (score >= 90) return 'A'; /* unreachable */
Nesting expresses conjunction — conditions that must all hold, often where the inner test is only meaningful when the outer one passed:
if (file != NULL) {
if (file->size > 0) { ... } /* only valid once file is known non-NULL */
}
When the inner condition has no such dependency, && is usually clearer than nesting, and short-circuit evaluation (Chapter 2.3) makes the dependent case safe too:
if (file != NULL && file->size > 0) { ... } /* equivalent and flatter */
Deep nesting is the main readability hazard. The standard remedy is the guard clause: handle the failure cases first and return early, so the successful path stays at one indentation level.
/* Nested "arrow" shape Guard-clause shape */
if (a) { if (!a) return ERR_A;
if (b) { if (!b) return ERR_B;
if (c) { if (!c) return ERR_C;
do_work(); do_work();
}
}
}
Finally, when many branches test the same variable against constants, an else if ladder is the wrong tool — that is what switch is for.
Examples
#include <stdio.h>
/* Chained conditions: ordered so each range is reachable. */
char grade(int score)
{
if (score >= 90) return 'A';
else if (score >= 80) return 'B'; /* implies score < 90 */
else if (score >= 70) return 'C';
else if (score >= 60) return 'D';
else return 'F';
}
typedef struct { const char *name; long size; } File;
/* Guard clauses: failures exit early, the happy path stays flat. */
int process(const File *f, int max_size)
{
if (f == NULL) return -1; /* handle each failure and leave */
if (f->name == NULL) return -2;
if (f->size <= 0) return -3;
if (f->size > max_size) return -4;
printf("processing %s (%ld bytes)\n", f->name, f->size);
return 0; /* only one indentation level */
}
int main(void)
{
int scores[] = {95, 85, 72, 65, 30};
for (int i = 0; i < 5; i++)
printf("%3d -> %c\n", scores[i], grade(scores[i]));
File good = { "data.bin", 1024 };
File empty = { "empty.bin", 0 };
printf("\nprocess(good) = %d\n", process(&good, 4096));
printf("process(empty) = %d\n", process(&empty, 4096));
printf("process(NULL) = %d\n", process(NULL, 4096));
/* Nesting vs && -- both safe, thanks to short-circuit evaluation. */
const File *f = NULL;
if (f != NULL && f->size > 0) /* f->size never evaluated when f is NULL */
puts("non-empty");
else
puts("null or empty");
return 0;
}
Choosing between the forms:
| Situation | Use |
|---|---|
| Several mutually exclusive outcomes | else if chain |
| Conditions that must all hold, independent | && in one if |
| Inner test only valid if outer passed | && (short-circuits) or nesting |
| Many failure cases before real work | Guard clauses with early return |
| One variable compared to many constants | switch |
Key Takeaways
else ifis anifinside anelse; branches are tested in order and the first match wins.- Order overlapping range tests from most to least restrictive, or later branches become unreachable.
&&with short-circuit evaluation replaces most two-level nesting and reads flatter.- Guard clauses (validate and return early) keep the main logic at one indentation level.
- An
else ifladder testing one variable against constants is better written as aswitch.
🧪 Practice
- Rewrite
gradewith the comparisons in ascending order and demonstrate the resulting bug with a score of 95.- Take a three-level nested
iffrom your own code and convert it to guard clauses. Count the indentation levels before and after.- Write a function classifying a character as digit, uppercase, lowercase, whitespace, or other, using a chain. Then compare with the
ctype.hfunctions.- Interview-style: "When would you deliberately nest instead of using
&&?" Hint: think about needing a distinctelseaction for the outer condition alone.
switch, case, and default
Theory
When you compare one integer against many constants, an else if ladder does N comparisons in sequence. switch expresses the same intent but tells the compiler that all branches test the same value against distinct constants, which lets it generate a jump table — a single indexed jump — instead of a chain of comparisons.
switch (expression) {
case constant1:
statements
break;
case constant2:
statements
break;
default:
statements
}
The restrictions are strict, and all follow from the jump-table model:
| Requirement | Detail |
|---|---|
| Controlling expression | Must have integer type (including char and enum) — never float, double, a string, or a struct |
case labels | Must be integer constant expressions, known at compile time — no variables, no function calls |
| Duplicate labels | A compile error |
| Ranges | Not standard C (case 1 ... 5: is a GCC extension) |
default | Optional; may appear anywhere, though convention puts it last |
Because you cannot switch on a string, comparing strings requires strcmp in an if chain, or mapping the string to an enum first.
Two subtleties are worth knowing.
case labels are labels, not blocks. A switch body is one statement (usually a compound statement) into which the labels are jump targets. That is why a case that declares a variable can be a problem: the declaration's scope is the whole switch body, but jumping to a later case skips its initialization.
switch (n) {
case 1:
int x = 5; /* C23 allows this; earlier standards need a label
followed by a statement, not a declaration */
break;
case 2:
printf("%d", x); /* x is in scope but UNINITIALIZED -- init was skipped */
}
The fix is to wrap the case body in its own braces, which is good practice whenever a case declares anything:
case 1: {
int x = 5;
break;
}
default catches everything unmatched, and including it is usually right. The exception: when switching over an enum and handling every enumerator, omitting default lets -Wswitch warn you if a new enumerator is added later — a genuinely useful safety net.
Examples
#include <stdio.h>
#include <string.h>
typedef enum { OP_ADD, OP_SUB, OP_MUL, OP_DIV } Op;
double apply(Op op, double a, double b)
{
switch (op) {
case OP_ADD: return a + b; /* 'return' also leaves the switch */
case OP_SUB: return a - b;
case OP_MUL: return a * b;
case OP_DIV: return b != 0 ? a / b : 0.0;
}
/* No default: -Wswitch warns if a new Op is added and not handled. */
return 0.0;
}
const char *day_type(int day)
{
switch (day) {
case 0:
case 6:
return "weekend"; /* two labels, one body */
case 1: case 2: case 3: case 4: case 5:
return "weekday";
default:
return "invalid"; /* catches everything else */
}
}
int main(void)
{
printf("3 + 4 = %g\n", apply(OP_ADD, 3, 4));
printf("3 * 4 = %g\n", apply(OP_MUL, 3, 4));
for (int d = -1; d <= 7; d += 3)
printf("day %d: %s\n", d, day_type(d));
/* A case that declares a variable needs its own block. */
int code = 2;
switch (code) {
case 1: {
int retries = 3; /* scoped to this block only */
printf("retrying %d times\n", retries);
break;
}
case 2: {
char buf[16];
snprintf(buf, sizeof buf, "code-%d", code);
printf("%s\n", buf);
break;
}
default:
puts("unknown code");
}
/* You CANNOT switch on a string; use strcmp or map to an enum. */
const char *cmd = "stop";
if (strcmp(cmd, "start") == 0) puts("starting");
else if (strcmp(cmd, "stop") == 0) puts("stopping");
else puts("unknown command");
return 0;
}
How the two forms compile, conceptually:
else-if ladder switch with dense labels
cmp n, 1 cmp n, 5
je case1 ja default (bounds check)
cmp n, 2 jmp [table + n*8] (one indexed jump)
je case2
cmp n, 3 table:
je case3 .quad case0
... .quad case1
(O(N) comparisons) .quad case2 ... (O(1) dispatch)
The compiler chooses: dense label sets become jump tables, sparse ones become comparison chains or binary searches. You do not control this, but the switch form is what makes the optimization possible.
Key Takeaways
switchrequires an integer controlling expression and compile-time integer constantcaselabels.- Duplicate labels are an error; ranges are a GNU extension, not standard C; strings cannot be switched on.
- Multiple labels may share one body — the idiomatic way to group cases.
- Wrap a
casebody in braces whenever it declares a variable; otherwise a jump can skip the initialization. - Omitting
defaultin an exhaustiveenumswitch enables-Wswitchto warn when a new enumerator appears.
🧪 Practice
- Write a
switchthat maps a single-character operator (+ - * /) to an operation, with adefaultfor unknown input.- Try
switchon adoubleand on achar *, and record the compiler errors for each.- Declare a variable directly after a
caselabel without braces, then jump to a later case that uses it. Compile with-Wall -Wextraand explain the diagnostic.- Interview-style: "When is a
switchmeasurably faster than anif/elsechain, and when is it not?" Hint: think about how densely packed the case values are.
Fallthrough Behavior
Theory
A case label marks a jump target, not a self-contained branch. Once control jumps there, execution continues straight through subsequent labels until it hits a break, a return, a goto, or the closing brace. This is fallthrough, and it is C's most notorious default.
switch (2) {
case 1: puts("one");
case 2: puts("two"); /* execution enters here ... */
case 3: puts("three"); /* ... and keeps going */
case 4: puts("four");
}
/* prints: two three four */
It is inherited from the language's origins: switch was designed as computed-goto machinery, and adding an implicit break would have made shared case bodies impossible. The cost is that a forgotten break produces a silent, plausible-looking bug.
There are two legitimate uses:
1. Grouping labels with an empty body. Stacked labels are not really fallthrough in the risky sense, and every reader recognizes the idiom:
case 'a': case 'e': case 'i': case 'o': case 'u':
vowels++;
break;
2. Deliberate cumulative processing, where each case genuinely should do its own work and then everything below it. This is rare and must be commented.
Modern C gives you tools for the accidental case:
-Wimplicit-fallthrough(in-Wextraon GCC) warns on any fallthrough with a non-empty case body.- The warning is silenced by a recognized comment (
/* fallthrough */), by GCC/Clang's__attribute__((fallthrough)), or — in C23 — by the standard attribute[[fallthrough]];.
The rule to adopt: every non-empty case ends with break, return, or an explicit fallthrough marker. No exceptions, so a missing terminator is always a bug.
Note that break inside a switch exits the switch, not an enclosing loop. To leave a loop from inside a switch you need goto, a flag, or a return.
Examples
#include <stdio.h>
/* Accidental fallthrough: the classic bug. */
void buggy(int n)
{
switch (n) {
case 1:
puts("one");
/* missing break -- falls into case 2 */
case 2:
puts("two");
break;
default:
puts("other");
}
}
/* Intentional, marked fallthrough. */
void permissions(int level)
{
switch (level) {
case 3:
puts("+ admin");
/* fallthrough */ /* recognized by -Wimplicit-fallthrough */
case 2:
puts("+ write");
/* fallthrough */
case 1:
puts("+ read");
break;
default:
puts("no access");
}
}
/* Grouped labels: empty bodies, no warning, universally understood. */
int count_vowels(const char *s)
{
int n = 0;
for (; *s; s++) {
switch (*s) {
case 'a': case 'e': case 'i': case 'o': case 'u':
case 'A': case 'E': case 'I': case 'O': case 'U':
n++;
break;
default:
break; /* explicit, even when empty */
}
}
return n;
}
int main(void)
{
puts("buggy(1):"); buggy(1); /* prints "one" AND "two" */
puts("\npermissions(3):"); permissions(3);
puts("\npermissions(1):"); permissions(1);
printf("\nvowels in \"Hello World\" = %d\n", count_vowels("Hello World"));
return 0;
}
Execution trace for buggy(1):
switch (n) with n == 1
|
v
case 1: puts("one"); <- entry point
| (no break)
v
case 2: puts("two"); <- FALLS THROUGH into this body
|
break <- finally exits
|
v
after the switch
Key Takeaways
- Control falls through from one
caseinto the next unless stopped bybreak,return, orgoto. - Stacked labels with empty bodies are the safe, idiomatic way to share one body among several values.
- Deliberate fallthrough must be marked:
/* fallthrough */,__attribute__((fallthrough)), or C23's[[fallthrough]];. - Enable
-Wimplicit-fallthrough(in-Wextra) so an accidental omission becomes a diagnostic. breakinside aswitchexits only theswitch; it does not break out of an enclosing loop.
🧪 Practice
- Run
buggy(1)and confirm it prints two lines. Add the missingbreakand re-run.- Compile a switch with an unmarked non-empty fallthrough using
-Wextra, then silence it correctly with a/* fallthrough */comment and with[[fallthrough]];under-std=c23.- Write a
switchinside awhileloop wherebreakis intended to exit the loop. Observe that it does not, then fix it with a flag and withgoto.- Interview-style: "Give a case where fallthrough is the clearest way to express the logic." Hint: think about cumulative permission or privilege levels.
<a id="32-iteration"></a>
3.2 Iteration
Loops repeat work. C has three forms that are formally interchangeable, so the real skill is choosing the one whose shape matches the problem — and writing a termination condition that is provably correct.
while Loop
Theory
while is the simplest loop: test a condition, and if it is true, run the body and test again.
while (expression)
statement;
It is entry-controlled: the test happens before each iteration, including the first. If the condition is false initially, the body never runs at all. That property is what makes while the right choice when the number of iterations is unknown and possibly zero — reading until end of file, walking a linked list, waiting for a flag.
Compare the mental models:
while— "keep going as long as this holds." Condition-driven.for— "do this a known number of times." Counter-driven.do-while— "do it, then decide whether to repeat." At-least-once.
Every while loop needs three things, and omitting the third is the classic beginner bug:
- An initialization before the loop.
- A condition that can eventually become false.
- Something inside the body that changes the condition.
int i = 0;
while (i < 10) {
printf("%d\n", i);
/* forgetting i++ here loops forever */
}
Two idioms are worth memorizing because you will read them constantly.
Assignment inside the condition. Because assignment is an expression, you can read and test in one step. The extra parentheses are required for correct precedence and also signal to the compiler (and reader) that the assignment is deliberate:
int c;
while ((c = getchar()) != EOF) { ... } /* read, assign, then compare */
Without the inner parentheses, c = getchar() != EOF would assign the comparison's 0-or-1 result to c.
Pointer walking.
while (*s != '\0') s++; /* or simply: while (*s) s++; */
Examples
#include <stdio.h>
#include <stdlib.h>
typedef struct Node { int value; struct Node *next; } Node;
int main(void)
{
/* 1. Counting: works, but a for loop expresses this better. */
int i = 0;
while (i < 5) {
printf("%d ", i);
i++; /* MUST change the condition */
}
printf("\n");
/* 2. Zero iterations: the condition is false on entry. */
int n = 10;
while (n < 5) {
puts("never printed");
}
/* 3. Unknown count: walk a linked list until NULL. */
Node c = {3, NULL}, b = {2, &c}, a = {1, &b};
Node *p = &a;
while (p != NULL) { /* idiomatically: while (p) */
printf("%d -> ", p->value);
p = p->next; /* advance, or loop forever */
}
puts("NULL");
/* 4. Assign-and-test: the canonical input idiom. */
printf("\ncounting digits in \"a1b22c333\": ");
const char *s = "a1b22c333";
int digits = 0;
char ch;
while ((ch = *s++) != '\0') { /* parentheses are required */
if (ch >= '0' && ch <= '9') digits++;
}
printf("%d\n", digits);
/* 5. Digit extraction: a loop whose count depends on the value. */
int num = 9375, sum = 0;
while (num > 0) {
sum += num % 10; /* take the last digit */
num /= 10; /* drop it -- guarantees termination */
}
printf("digit sum of 9375 = %d\n", sum);
return 0;
}
Execution order:
+--> evaluate condition
| |
| true| false
| v \
| +--------+ \
| | body | +--> exit loop
| +--------+
| |
+--------+
Note: the condition is checked BEFORE the first iteration,
so a body of zero executions is possible.
Key Takeaways
whiletests the condition before every iteration, so the body may run zero times.- Prefer it when the iteration count is unknown or data-driven; prefer
forwhen counting. - The body must change something the condition depends on, or the loop never terminates.
while ((c = getchar()) != EOF)needs the inner parentheses — assignment has lower precedence than!=.while (p)andwhile (*s)are idiomatic tests for non-null pointers and non-terminator characters.
🧪 Practice
- Write a
whileloop that reverses the digits of an integer (1234 becomes 4321). Explain why it terminates.- Implement
size_t my_strlen(const char *s)using awhileloop and pointer walking, without indexing.- Write
while (c = getchar() != EOF)(missing parentheses) and explain whatcactually holds on each iteration.- Interview-style: "Write a loop that counts the set bits in an unsigned integer." Hint:
n &= n - 1clears the lowest set bit — how many iterations does that take?
do-while Loop
Theory
do-while is the mirror image of while: the body runs first, and the condition is tested afterward.
do
statement;
while (expression); /* note the mandatory semicolon */
It is exit-controlled, which gives it one defining property: the body always executes at least once. That is the entire reason it exists, and it makes the construct the right choice whenever the test depends on work the body must do first.
The canonical shapes:
- Menus — display options, read a choice, repeat if invalid. You must show the menu before you can validate anything.
- Input validation — prompt, read, re-prompt while the value is out of range.
- Retry loops — attempt an operation, retry while it failed and attempts remain.
- Number processing — printing the digits of
0requires one pass even though0 > 0is false.
That last case is a good illustration of the difference:
int n = 0;
while (n > 0) { putchar('0' + n % 10); n /= 10; } /* prints NOTHING */
do { putchar('0' + n % 10); n /= 10; } while (n > 0); /* prints "0" */
Two practical notes. The semicolon after the closing while (...) is required — the statement is not complete without it, and omitting it produces a confusing error at the next line. And do-while is the least-used of the three loops; when you find yourself reaching for it, check whether the at-least-once property is genuinely what you want, because reviewers will read while faster.
One more use has nothing to do with looping at all: do { ... } while (0) is the standard idiom for wrapping a multi-statement macro into a single statement. It is covered in Chapter 8.3, but the reason it works is here — the construct is one statement that runs its body exactly once and swallows a trailing semicolon cleanly.
Examples
#include <stdio.h>
#include <stdlib.h>
int main(void)
{
/* 1. The at-least-once property, demonstrated. */
int n = 0;
printf("while with n=0: ");
while (n > 0) { printf("ran "); n--; } /* body never runs */
printf("(nothing)\n");
n = 0;
printf("do-while with n=0: ");
do { printf("ran "); n--; } while (n > 0); /* runs once */
printf("\n");
/* 2. Printing digits: 0 must still produce one character. */
int value = 0;
char buf[16];
int len = 0;
do {
buf[len++] = (char)('0' + value % 10);
value /= 10;
} while (value > 0);
printf("\ndigits of 0: ");
while (len > 0) putchar(buf[--len]); /* reverse the buffer */
putchar('\n');
/* 3. Retry with a bounded attempt count. */
int attempt = 0, success = 0;
do {
attempt++;
success = (attempt == 3); /* pretend it works on try 3 */
printf("attempt %d: %s\n", attempt, success ? "ok" : "failed");
} while (!success && attempt < 5);
/* 4. Input validation shape (input simulated so the example runs). */
int inputs[] = {-1, 99, 7}; /* stand-in for scanf */
int idx = 0, choice;
do {
choice = inputs[idx++];
printf("read %d\n", choice);
} while ((choice < 1 || choice > 10) && idx < 3);
printf("accepted: %d\n", choice);
return 0;
}
Comparison of the two condition-tested loops:
while do-while
+--> test +--> body
| | true | |
| v | v
| body | test
| | | | true
+-----+ +-----+
| false | false
v v
exit exit
0 or more iterations 1 or more iterations
while | do-while | |
|---|---|---|
| Test position | Before body | After body |
| Minimum iterations | 0 | 1 |
| Trailing semicolon | No | Yes, required |
| Typical use | Data-driven, may be empty | Menus, validation, retries |
Key Takeaways
do-whileruns the body before testing, guaranteeing at least one iteration.- Use it when the condition depends on something the body must compute or read first.
- The closing
while (expression);requires a semicolon. - Prefer
whileunless the at-least-once property is genuinely needed — it is the more familiar shape. do { ... } while (0)is the standard multi-statement macro wrapper (see Chapter 8.3).
🧪 Practice
- Write a number-guessing loop with a hard-coded sequence of guesses that keeps looping until the guess is correct, using
do-while.- Convert an integer to a string manually with
do-while, and confirm the input0produces"0".- Omit the semicolon after
while (cond)in ado-whileand record the compiler error. Note that it points at the wrong line.- Interview-style: "Rewrite any
do-whileas awhileloop." Hint: you will need to duplicate the body or introduce a flag — explain the trade-off.
for Loop
Theory
for packs the three parts of a counted loop — setup, test, and advance — into one header, so everything governing the iteration is visible in a single line.
for (initialization; condition; increment)
statement;
It is exactly equivalent to:
initialization;
while (condition) {
statement;
increment; /* with one exception, noted below */
}
The order of evaluation is:
1. initialization (once, before anything else)
2. condition --false--> exit
|true
3. body
4. increment
then back to step 2
All three clauses are optional. Omitting the condition means "always true," so for (;;) is the idiomatic infinite loop.
Since C99 you can declare the loop variable in the initialization clause, and its scope is the loop itself:
for (int i = 0; i < n; i++) { ... }
/* i does not exist here */
This is strictly better than declaring i outside: it cannot collide with anything, it cannot be accidentally used after the loop, and the compiler has more freedom. Use it unless you need the final value afterward.
The comma operator (Chapter 2.3) lets a single clause do several things — this is its main legitimate use:
for (int i = 0, j = n - 1; i < j; i++, j--) { ... } /* walk from both ends */
Two points of care:
The while equivalence breaks with continue. In a for loop, continue jumps to the increment clause; in the hand-written while version, it jumps to the condition and skips the increment — producing an infinite loop. This is covered in 3.3.
Do not modify the loop variable inside the body unless that is the whole point of the loop. A for header is a contract with the reader about how the variable advances; breaking it inside the body is how off-by-one and skipped-element bugs appear.
Examples
#include <stdio.h>
#include <string.h>
int main(void)
{
/* 1. The standard counted loop, with C99 scoped declaration. */
for (int i = 0; i < 5; i++)
printf("%d ", i);
printf("\n");
/* printf("%d", i); ERROR: i is out of scope here */
/* 2. Counting down. */
for (int i = 5; i > 0; i--)
printf("%d ", i);
printf("\n");
/* 3. Any step size. */
for (int i = 0; i <= 20; i += 5)
printf("%d ", i);
printf("\n");
/* 4. Two variables via the comma operator: reverse in place. */
char s[] = "control flow";
for (size_t i = 0, j = strlen(s) - 1; i < j; i++, j--) {
char t = s[i]; s[i] = s[j]; s[j] = t;
}
printf("reversed: %s\n", s);
/* 5. Iterating a pointer instead of an index. */
int data[] = {10, 20, 30, 40};
for (int *p = data; p < data + 4; p++)
printf("%d ", *p);
printf("\n");
/* 6. Clauses are optional. */
int k = 0;
for (; k < 3; ) /* no init, no increment -- legal but unusual */
k++;
printf("k = %d\n", k);
/* 7. Empty body: the work happens in the header. */
const char *str = "measure me";
size_t len;
for (len = 0; str[len] != '\0'; len++)
; /* deliberate empty body, on its own line */
printf("length = %zu\n", len);
/* 8. Idiomatic array traversal with size_t. */
size_t count = sizeof data / sizeof data[0];
for (size_t i = 0; i < count; i++)
printf("data[%zu]=%d ", i, data[i]);
printf("\n");
return 0;
}
When to choose which loop:
| Pattern | Best form |
|---|---|
| Known iteration count | for |
| Array or range traversal | for |
| Two indices moving together | for with comma operator |
| Condition-driven, may run zero times | while |
| Must run at least once | do-while |
| Intentionally infinite | for (;;) |
Key Takeaways
forgroups initialization, condition, and increment in one header; all three clauses are optional.- Evaluation order is init once, then condition → body → increment, repeating.
- Declare the loop variable in the init clause (C99) to scope it to the loop.
- The comma operator allows multiple init and increment expressions — its principal legitimate use.
forandwhileare equivalent except undercontinue, which still runs aforloop's increment clause.
🧪 Practice
- Print a multiplication table for 1–5 using a
forloop, then rewrite it as an equivalentwhileloop.- Write a loop that visits every second element of an array from the end toward the start.
- Convert
for (int i = 0; i < n; i++) { if (i % 2) continue; work(i); }into awhileloop that behaves identically. Explain what you had to be careful about.- Interview-style: "What is the scope and lifetime of
iinfor (int i = 0; ...)?" Hint: consider whether an outeriwith the same name is visible inside the body.
Nested Loops
Theory
A loop inside a loop is how you traverse two-dimensional structure: rows and columns of a matrix, every pair drawn from a set, or a search that scans a window at each position.
The mechanics are simple — for every single iteration of the outer loop, the inner loop runs to completion:
outer i=0: inner j=0,1,2
outer i=1: inner j=0,1,2
outer i=2: inner j=0,1,2
total: 3 x 3 = 9 iterations
The cost multiplies. Two nested loops over n elements is O(n²); three is O(n³). At n = 1,000 that is a million and a billion operations respectively — the difference between instant and unusable. Whenever you write a triple nest, it is worth asking whether the algorithm is right.
Two practical concerns matter beyond the mechanics.
Memory layout. C arrays are stored row-major: m[0][0], m[0][1], m[0][2], then m[1][0], and so on. Iterating with the row index outermost walks memory sequentially and uses the cache well; swapping the loops strides across memory and can be several times slower on large matrices, even though both produce identical results.
for (i...) for (j...) sum += m[i][j]; /* fast: sequential access */
for (j...) for (i...) sum += m[i][j]; /* slow: strided access */
Exiting from the inside. break leaves only the innermost loop. Escaping several levels at once requires one of three techniques:
| Technique | Notes |
|---|---|
| Flag variable | Portable, but adds a test to every level |
goto past the loops | The clearest option in C; widely accepted for exactly this |
Extract into a function and return | Usually the best refactor |
Finally, keep inner and outer loop variables distinct (i, j, k or meaningful names). Reusing the same name shadows the outer one and produces baffling behavior; -Wshadow catches it.
Examples
#include <stdio.h>
#include <stdbool.h>
#define ROWS 3
#define COLS 4
/* Extracting the search into a function makes 'return' the exit mechanism. */
static bool find_value(int m[ROWS][COLS], int target, int *out_r, int *out_c)
{
for (int r = 0; r < ROWS; r++)
for (int c = 0; c < COLS; c++)
if (m[r][c] == target) {
*out_r = r; *out_c = c;
return true; /* leaves BOTH loops at once */
}
return false;
}
int main(void)
{
int m[ROWS][COLS] = {
{ 1, 2, 3, 4},
{ 5, 6, 7, 8},
{ 9, 10, 11, 12}
};
/* 1. Row-major traversal: outer = row, inner = column. */
for (int r = 0; r < ROWS; r++) {
for (int c = 0; c < COLS; c++)
printf("%3d", m[r][c]);
putchar('\n'); /* between rows, not columns */
}
/* 2. A triangular nest: inner bound depends on the outer variable. */
puts("\nmultiplication triangle:");
for (int i = 1; i <= 4; i++) {
for (int j = 1; j <= i; j++) /* j <= i, not a fixed bound */
printf("%2d ", i * j);
putchar('\n');
}
/* 3. Escaping nested loops with goto. */
int target = 7;
for (int r = 0; r < ROWS; r++) {
for (int c = 0; c < COLS; c++) {
if (m[r][c] == target) {
printf("\nfound %d at (%d,%d)\n", target, r, c);
goto done; /* breaks out of both loops */
}
}
}
puts("\nnot found");
done:
/* 4. The same search with a flag -- more code, more state. */
bool found = false;
int fr = -1, fc = -1;
for (int r = 0; r < ROWS && !found; r++) { /* extra test here ... */
for (int c = 0; c < COLS; c++) {
if (m[r][c] == 11) { found = true; fr = r; fc = c; break; }
}
}
if (found) printf("flag search: 11 at (%d,%d)\n", fr, fc);
/* 5. The cleanest version: a function that returns. */
int r2, c2;
if (find_value(m, 12, &r2, &c2))
printf("function search: 12 at (%d,%d)\n", r2, c2);
return 0;
}
Row-major memory layout:
int m[3][4] in memory (one contiguous block of 12 ints):
+----+----+----+----+----+----+----+----+----+----+----+----+
|m00 |m01 |m02 |m03 |m10 |m11 |m12 |m13 |m20 |m21 |m22 |m23 |
+----+----+----+----+----+----+----+----+----+----+----+----+
^--------- row 0 -------^--------- row 1 -------^-- row 2 --^
for r { for c { m[r][c] } } walks left to right -> cache-friendly
for c { for r { m[r][c] } } jumps by 4 ints each -> cache-hostile
Key Takeaways
- The inner loop completes fully for each single iteration of the outer loop; total iterations multiply.
- Two nested loops over n items is O(n²) — check the algorithm before nesting three deep.
- C arrays are row-major; put the row index in the outer loop for sequential memory access.
breakexits only the innermost loop; usegoto, a flag, or extraction into a function to leave several.- Give each level a distinct loop variable and compile with
-Wshadow.
🧪 Practice
- Print a right-triangle pattern of asterisks with 5 rows, then invert it.
- Write a function that transposes a square matrix in place. Determine which index range the inner loop needs to avoid swapping twice.
- Time a 2000x2000 matrix sum with the loops in both orders and report the difference.
- Interview-style: "How do you break out of three nested loops?" Hint: name three approaches and say which one you would defend in code review.
Infinite Loops and Termination Conditions
Theory
An infinite loop is one whose condition never becomes false. Sometimes that is exactly what you want; more often it is a bug. Telling the two apart, and reasoning about why a loop must stop, is a core skill.
Deliberate infinite loops are the normal structure for programs that run until told to stop:
- Server accept loops and GUI event loops.
- Embedded
mainfunctions — a microcontroller has no OS to return to. - Interpreter read-eval-print loops.
Two spellings exist. for (;;) is the traditional C idiom: the condition clause is empty, which the standard defines as true, and it produces no warning. while (1) is equally common and clearer to some readers; older compilers occasionally warned about the constant condition, which is why for (;;) persists.
Such loops always contain an internal exit — a break, return, exit(), or goto.
Accidental infinite loops come from a handful of recurring mistakes:
| Cause | Example | Fix |
|---|---|---|
| Missing update | while (i < 10) printf(...); | Increment inside the body |
| Wrong comparison | for (i = 0; i != 10; i += 3) — skips 10 | Use < rather than != |
| Unsigned counting down | for (unsigned i = n; i >= 0; i--) | i >= 0 is always true — use a signed type or while (i-- > 0) |
| Float accumulation | for (double x = 0; x != 1.0; x += 0.1) | Never hits 1.0 exactly — drive with an integer |
| Modifying the counter in the body | Body resets or decrements i | Keep the update in the header |
| Overflow | for (int i = 1; i > 0; i *= 2) | Signed overflow is UB; the "wrap" is not guaranteed |
| Stray semicolon | while (cond); | The body is the null statement |
The systematic defense is to state a loop variant: a quantity that strictly decreases (or increases) toward a bound on every iteration. In while (n > 0) n /= 10; the variant is n, which shrinks every pass, so termination is guaranteed. If you cannot name such a quantity, you cannot claim the loop terminates.
For loops whose termination depends on external conditions (network reads, retries, hardware polling), add a bounded fallback — a maximum iteration count or a timeout — so a stuck peer cannot hang the program.
Examples
#include <stdio.h>
#include <limits.h>
int main(void)
{
/* 1. Deliberate infinite loop with an internal exit. */
int count = 0;
for (;;) { /* idiomatic "loop forever" */
count++;
if (count >= 3) break; /* the exit lives inside */
}
printf("for(;;) ran %d times\n", count);
/* 2. while (1) is equivalent. */
count = 0;
while (1) {
if (++count >= 3) break;
}
printf("while(1) ran %d times\n", count);
/* 3. BUG: unsigned counter can never go below zero. */
printf("\nunsigned countdown (bounded so it terminates):\n");
unsigned u = 3;
int guard = 0;
while (u >= 0) { /* ALWAYS true -- u wraps to UINT_MAX */
printf(" u = %u\n", u);
u--;
if (++guard > 5) { puts(" ...would run forever"); break; }
}
/* Correct forms: */
printf("correct: ");
for (unsigned k = 3; k-- > 0; ) /* test then decrement */
printf("%u ", k);
printf("\n");
/* 4. BUG: != with a step that skips the target. */
printf("\nfor (i = 0; i != 10; i += 3) would skip 10: ");
for (int i = 0; i < 12; i += 3) printf("%d ", i); /* use < instead */
printf("\n");
/* 5. Termination argument via a loop variant. */
int n = 987654;
int steps = 0;
while (n > 0) {
n /= 10; /* VARIANT: n strictly decreases */
steps++; /* so the loop must end */
}
printf("\ndigits: %d\n", steps);
/* 6. External condition with a bounded fallback. */
int attempts = 0, connected = 0;
while (!connected && attempts < 5) { /* bound prevents hanging */
attempts++;
connected = (attempts == 4); /* simulated */
}
printf("connected=%d after %d attempts\n", connected, attempts);
return 0;
}
Checklist for any loop you write:
1. What is initialized before the loop?
2. What must be true for the body to run?
3. What changes each iteration? <- the variant
4. Does that change move toward the exit? <- termination argument
5. Is there a bound if the data is hostile or the peer never responds?
Key Takeaways
for (;;)andwhile (1)are the idiomatic deliberate infinite loops; both need an internalbreak,return, orexit.- The most common accidental causes are a missing update,
!=with a stepping counter, unsigned countdowns, and float accumulation. unsigned i >= 0is always true — a countdown with an unsigned counter never ends.- Justify termination by naming a variant: a quantity that strictly moves toward the exit condition every iteration.
- Loops depending on external state should carry a maximum attempt count or timeout.
🧪 Practice
- Write four loops that fail to terminate for four different reasons from the table, then fix each one.
- Write a countdown from
nto 0 using anunsignedcounter, correctly. Give two working forms.- For a binary-search loop, state the variant that proves it terminates and give the maximum iteration count for n elements.
- Interview-style: "How would you make a retry loop robust against a server that never responds?" Hint: consider an attempt cap and how the delay between attempts should change.
<a id="33-jumps"></a>
3.3 Jumps
Jump statements transfer control unconditionally. Three of them refine loops and functions in everyday code; the fourth, goto, carries a reputation that deserves a precise, unsentimental treatment.
break and continue
Theory
break and continue are loop refinements: they let you exit early or skip ahead without contorting the loop condition.
break terminates the innermost enclosing loop or switch immediately. Control resumes at the statement after it. This is what makes search loops natural — stop as soon as you find what you were looking for, instead of scanning the whole array and testing a flag afterward.
continue skips the rest of the current iteration and moves to the next one. It is the "filter" statement: reject uninteresting items at the top of the body so the real work is not buried inside an if.
The single most important detail is where continue jumps to, and it differs by loop type:
| Loop | continue jumps to |
|---|---|
for | The increment clause, then the condition |
while | The condition directly |
do-while | The condition at the bottom |
That difference is a real bug generator. In a for loop the counter still advances; in a hand-written while equivalent it does not:
int i = 0;
while (i < 10) {
if (i % 2 == 0) continue; /* i never increments -- INFINITE LOOP */
printf("%d ", i);
i++;
}
The for version of the same logic is safe, because the increment is in the header:
for (int i = 0; i < 10; i++) {
if (i % 2 == 0) continue; /* i++ still runs */
printf("%d ", i);
}
Two further points. break inside a switch that sits inside a loop exits the switch only — a frequent surprise, covered in 3.1. And neither statement escapes more than one level; nested loops need goto, a flag, or a function boundary.
Used with restraint, both improve clarity. Overused — a loop body with five continues and three breaks — they scatter the exit logic and make the loop hard to reason about.
Examples
#include <stdio.h>
#include <string.h>
int main(void)
{
int data[] = {4, 8, 15, 16, 23, 42};
size_t n = sizeof data / sizeof data[0];
/* 1. break: stop as soon as the target is found. */
int target = 16, found_at = -1;
for (size_t i = 0; i < n; i++) {
if (data[i] == target) {
found_at = (int)i;
break; /* no reason to keep scanning */
}
}
printf("found %d at index %d\n", target, found_at);
/* 2. continue: filter, keeping the real work unindented. */
printf("odd values: ");
for (size_t i = 0; i < n; i++) {
if (data[i] % 2 == 0) continue; /* reject and move on */
printf("%d ", data[i]);
}
printf("\n");
/* 3. The while/continue trap, made safe with a guard. */
printf("while + continue (bounded): ");
int i = 0, guard = 0;
while (i < 10) {
if (i % 2 == 0) {
i++; /* MUST advance before continuing */
continue;
}
printf("%d ", i);
i++;
if (++guard > 20) break;
}
printf("\n");
/* 4. break in a switch exits the switch, not the loop. */
printf("switch inside loop: ");
for (size_t k = 0; k < n; k++) {
switch (data[k] % 3) {
case 0:
printf("[%d div3] ", data[k]);
break; /* leaves the switch only */
default:
break;
}
/* loop continues here */
}
printf("\n");
/* 5. Skipping empty tokens while parsing. */
const char *fields[] = {"alpha", "", "beta", "", "gamma"};
printf("non-empty fields: ");
for (size_t f = 0; f < 5; f++) {
if (fields[f][0] == '\0') continue;
printf("%s ", fields[f]);
}
printf("\n");
return 0;
}
Where each statement sends control:
for (init; COND; INCR) {
...
continue; ------> jumps to INCR, then COND
...
break; ------> jumps out, past the whole loop
...
}
<-- break lands here
while (COND) {
...
continue; ------> jumps to COND (INCR does not exist!)
...
}
Key Takeaways
breakexits the innermost enclosing loop orswitch;continueskips to the next iteration.- In a
forloopcontinuestill runs the increment clause; in awhileloop it does not — a classic infinite-loop cause. breakinside aswitchnested in a loop exits only theswitch.- Neither statement escapes more than one level of nesting.
- Use
continueas an early filter to keep the main work at low indentation; avoid scattering many exits through one body.
🧪 Practice
- Write a loop that sums an array but stops at the first negative value, using
break. Then rewrite it with a flag and compare.- Write the
while+continueinfinite loop deliberately, confirm it hangs (with a guard or Ctrl-C), then fix it two different ways.- Write a loop over a string that counts letters, skipping digits and punctuation with
continue.- Interview-style: "What is the difference between
breakandcontinueinside aswitchthat is inside aforloop?" Hint: one of them is not valid for the switch at all — which, and what does it act on?
goto and Labels
Theory
goto transfers control unconditionally to a labeled statement in the same function.
goto label;
/* ... */
label:
statement;
Labels have function scope: a label is visible throughout the function that contains it, so goto can jump into or out of blocks within that function — but never into another function. (Crossing function boundaries requires setjmp/longjmp, covered in Chapter 9.3.)
goto has a reputation, and the history is worth knowing: Dijkstra's 1968 letter "Go To Statement Considered Harmful" argued that unrestricted jumps make it impossible to reason about a program's state, because you cannot tell how control arrived at any given point. That argument is correct, and it is why the structured constructs exist.
But the blanket prohibition it inspired is too strong for C, because C lacks the alternatives that other languages provide. There are two situations where goto is the clearest available tool, and both are standard practice in professional C — the Linux kernel uses the first extensively.
1. Cleanup on error paths. A function that acquires several resources must release exactly the ones it acquired, in reverse order, from any failure point. Without goto you get either deep nesting or duplicated cleanup code:
if (!(a = acquire_a())) goto fail;
if (!(b = acquire_b())) goto fail_a;
if (!(c = acquire_c())) goto fail_b;
/* success path */
return 0;
fail_b: release_b(b);
fail_a: release_a(a);
fail: return -1;
Each label releases what was acquired up to that point. Adding a fourth resource means adding one line and one label — the structure scales.
2. Breaking out of nested loops, as covered in 3.2, where break reaches only one level.
The discipline that makes goto safe:
- Jump forward, never backward. Backward jumps create loops that the loop constructs express better.
- Stay within one function and keep the jump distance short enough to see on one screen.
- Use descriptive labels (
cleanup,fail_unlock,done), notL1. - Never jump into a block, past a declaration with an initializer, or into the scope of a variable-length array — the last is a constraint violation, and the others leave objects uninitialized.
Examples
#include <stdio.h>
#include <stdlib.h>
/* The canonical error-cleanup ladder. */
int load_records(const char *path, int **out, size_t *count)
{
FILE *f = NULL;
int *buf = NULL;
int rc = -1; /* pessimistic default */
f = fopen(path, "r");
if (f == NULL) goto fail; /* nothing acquired yet */
buf = malloc(128 * sizeof *buf);
if (buf == NULL) goto fail_close; /* the file IS open -- close it */
for (size_t i = 0; i < 128; i++) {
if (fscanf(f, "%d", &buf[i]) != 1) {
if (i == 0) goto fail_free; /* both resources held */
*count = i;
break;
}
*count = i + 1;
}
*out = buf; /* success: ownership transfers out */
fclose(f);
return 0;
fail_free: /* release in REVERSE order */
free(buf);
fail_close:
fclose(f);
fail:
return rc;
}
int main(void)
{
/* Escaping nested loops -- goto is the clearest option in C. */
int grid[4][4] = {
{1,2,3,4},{5,6,7,8},{9,10,11,12},{13,14,15,16}
};
int want = 11;
for (int r = 0; r < 4; r++) {
for (int c = 0; c < 4; c++) {
if (grid[r][c] == want) {
printf("found %d at (%d,%d)\n", want, r, c);
goto search_done; /* leaves both loops */
}
}
}
printf("%d not found\n", want);
search_done:
/* What NOT to do: a backward goto reimplementing a loop. */
int i = 0;
loop_start: /* write 'while (i < 3)' instead */
if (i < 3) {
printf("i=%d ", i);
i++;
goto loop_start;
}
printf("\n");
int *records = NULL;
size_t n = 0;
if (load_records("missing.txt", &records, &n) != 0)
puts("load failed cleanly (no leak)");
else
free(records);
return 0;
}
Why the ladder beats the alternatives:
Nested-if version goto ladder
if (a = acq_a()) { if (!(a = acq_a())) goto fail;
if (b = acq_b()) { if (!(b = acq_b())) goto fail_a;
if (c = acq_c()) { if (!(c = acq_c())) goto fail_b;
work(); work();
rel_c(); rel_c(); rel_b(); rel_a();
} return 0;
rel_b();
} fail_b: rel_b();
rel_a(); fail_a: rel_a();
} fail: return -1;
depth grows with resources flat; one line per resource
cleanup duplicated per path cleanup written exactly once
Key Takeaways
gotojumps to a label within the same function; labels have function scope.- Its two defensible uses in C are error-cleanup ladders and escaping nested loops.
- Always jump forward, keep the jump short and visible, and name labels for what they do.
- Release resources in reverse acquisition order, with one label per acquired resource.
- Never jump into a block, over an initialization, or into the scope of a variable-length array.
🧪 Practice
- Write a function that opens two files and allocates a buffer, with a
gotocleanup ladder handling failure at each of the three steps.- Rewrite that same function using nested
ifstatements instead, and compare the line count and maximum indentation.- Attempt a
gotothat jumps forward past an initialized declaration and compile with-Wall -Wextra. Record the result.- Interview-style: "Your team's style guide bans
gotoentirely. What do you propose for multi-resource cleanup?" Hint: consider a single-exit function with a status variable, or splitting acquisition into a helper.
return
Theory
return ends the current function immediately and hands control back to the caller, optionally carrying a value.
return; /* only in a void function */
return expression; /* value converted to the function's return type */
Two rules matter for correctness:
The value is converted to the declared return type. return 3.7; from a function declared int returns 3 — the truncation happens silently, exactly as in an assignment.
Reaching the end of a non-void function without returning a value is undefined behavior if the caller uses the result. The one exception is main, where falling off the end has meant return 0; since C99. Compile with -Wreturn-type (included in -Wall) to catch missing returns.
There is a long-running style debate between single exit (one return at the bottom, control reaching it via flags) and multiple exits (return as soon as the answer is known). Single exit came from languages without automatic cleanup, where one exit point meant one place to release resources. In modern C the multiple-exit style — guard clauses that validate and return early — usually produces flatter, more readable code. The exception is exactly the case goto addresses: when resources must be released, funnel failures to one cleanup block instead of duplicating free/fclose at every exit.
Two errors are worth calling out explicitly.
Returning a pointer to a local variable. The object's lifetime ends when the function returns, so the caller receives a dangling pointer:
int *bad(void) {
int x = 42;
return &x; /* x is gone the moment we return -- UB */
}
Valid alternatives: return by value, return a pointer to malloc'd memory (documenting who frees it), take a caller-provided output buffer, or return a pointer to a static object (with the thread-safety caveat that all callers share it).
Returning a struct by value is fine. Unlike arrays, structs are copied on return, so return (Point){x, y}; is correct and idiomatic.
Examples
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
typedef struct { double x, y; } Point;
/* Multiple exits: answer as soon as it is known. */
int classify(int n)
{
if (n < 0) return -1;
if (n == 0) return 0;
return 1;
}
/* Guard clauses: validate, then do the work at one indentation level. */
int safe_divide(int a, int b, int *out)
{
if (out == NULL) return -1;
if (b == 0) return -2;
*out = a / b;
return 0;
}
/* Returning a struct BY VALUE is correct -- it is copied. */
Point midpoint(Point p, Point q)
{
return (Point){ (p.x + q.x) / 2, (p.y + q.y) / 2 };
}
/* BUG: the local dies when the function returns. */
/* int *dangling(void) { int x = 42; return &x; } */
/* Correct alternative 1: heap allocation, caller frees. */
int *make_array(size_t n)
{
int *a = malloc(n * sizeof *a); /* caller must free() this */
if (a == NULL) return NULL;
for (size_t i = 0; i < n; i++) a[i] = (int)i;
return a;
}
/* Correct alternative 2: caller supplies the buffer. */
int fill_array(int *dst, size_t n)
{
if (dst == NULL) return -1;
for (size_t i = 0; i < n; i++) dst[i] = (int)(i * i);
return 0;
}
/* void function: bare 'return' is an early exit. */
void report(const char *msg)
{
if (msg == NULL) return; /* no value after 'return' */
printf("[report] %s\n", msg);
}
int main(void)
{
printf("classify(-5)=%d classify(0)=%d classify(9)=%d\n",
classify(-5), classify(0), classify(9));
int q;
printf("safe_divide(10,2) -> rc=%d q=%d\n", safe_divide(10, 2, &q), q);
printf("safe_divide(10,0) -> rc=%d\n", safe_divide(10, 0, &q));
Point m = midpoint((Point){0,0}, (Point){4,6});
printf("midpoint = (%g, %g)\n", m.x, m.y);
int *heap = make_array(5);
if (heap) {
for (int i = 0; i < 5; i++) printf("%d ", heap[i]);
printf("\n");
free(heap); /* ownership was transferred to us */
}
int local[5];
fill_array(local, 5);
for (int i = 0; i < 5; i++) printf("%d ", local[i]);
printf("\n");
report(NULL);
report("done");
/* Implicit conversion on return. */
printf("classify returns int; 3.7 would become %d\n", (int)3.7);
return 0; /* main: 0 means success */
}
Ways to get data out of a function:
| Technique | Ownership / lifetime |
|---|---|
| Return by value (scalar or struct) | Copied — always safe |
Return malloc'd pointer | Caller must free; document it |
| Caller-supplied output buffer | Caller owns the storage; pass its size too |
Return pointer to static | Valid but shared by all callers; not thread-safe |
| Return pointer to a local | Undefined behavior — never do this |
Key Takeaways
returnexits the function immediately; the value is converted to the declared return type.- Falling off the end of a non-
voidfunction is undefined behavior if the caller uses the result;mainis the exception. - Early returns (guard clauses) generally read better than a single exit reached through flags.
- When a function holds resources, funnel failures to one cleanup block rather than duplicating releases at each
return. - Never return the address of a local; return by value, allocate, use an output parameter, or use
static.
🧪 Practice
- Write a function with four validation checks in guard-clause style, then rewrite it with a single exit and a status variable. Compare readability.
- Write
int *dangling(void)returning the address of a local, call it, and print through the pointer. Then run it under-fsanitize=addressand read the report.- Write the same "return an array" operation three ways: heap allocation, caller-supplied buffer, and a
staticarray. State the ownership rule for each.- Interview-style: "Is it safe to return a struct by value from a C function?" Hint: contrast what happens with a struct against what happens with an array.
Structured vs Unstructured Control Flow
Theory
Structured programming is the discipline of building all control flow from three composable patterns:
- Sequence — statements executed in order.
- Selection —
if,switch. - Iteration —
while,do-while,for.
The Böhm–Jacopini theorem (1966) proved that these three suffice: any computable function can be expressed with them alone, without arbitrary jumps. The practical payoff is single-entry, single-exit blocks. When every construct is entered at the top and left at the bottom, you can reason about a block in isolation — you know what was true on entry, so you can determine what is true on exit. Unrestricted goto destroys that property, because control can arrive at a statement from anywhere.
Unstructured control flow means jumps that break this discipline: backward gotos forming ad-hoc loops, jumps into the middle of blocks, and multiple entry points.
C is a structured language with escape hatches. It provides all three structured constructs, plus break, continue, return, and goto, which are disciplined deviations — each has a well-defined target and does not permit arbitrary entry:
| Construct | Structured? | Assessment |
|---|---|---|
if, switch, loops | Yes | Always fine |
return (early) | Mostly | Single-exit purists object; generally clearer |
break, continue | Mostly | Fine in moderation; many per loop hurts |
Forward goto (cleanup) | Deviation | Accepted C idiom for resource release |
Backward goto | No | Almost always a loop written badly |
setjmp/longjmp | No | Non-local jump; use sparingly, see Chapter 9.3 |
A useful measure is cyclomatic complexity: the number of linearly independent paths through a function, which equals the number of decision points plus one. Each if, case, loop, &&, and || adds one. Values above roughly 10–15 signal a function that is hard to test (you need at least that many test cases for path coverage) and usually one that should be split.
When control flow genuinely is complex — a protocol parser, a lexer — the answer is not more nesting but a different representation: an explicit state machine with a switch over states inside a loop. Each state's transitions are then local and readable, and adding a state does not deepen any nesting.
Examples
#include <stdio.h>
#include <ctype.h>
/* Unstructured: control arrives at labels from several places.
Hard to say what is true at any point. */
int messy_classify(const char *s)
{
int i = 0, digits = 0, letters = 0;
top:
if (s[i] == '\0') goto done;
if (isdigit((unsigned char)s[i])) { digits++; i++; goto top; }
if (isalpha((unsigned char)s[i])) { letters++; i++; goto top; }
i++;
goto top;
done:
return digits * 100 + letters;
}
/* Structured: the same logic as sequence + iteration + selection. */
int clean_classify(const char *s)
{
int digits = 0, letters = 0;
for (int i = 0; s[i] != '\0'; i++) {
if (isdigit((unsigned char)s[i])) digits++;
else if (isalpha((unsigned char)s[i])) letters++;
}
return digits * 100 + letters;
}
/* Genuinely complex flow, expressed as an explicit state machine.
Each state is local and self-contained; adding one adds no nesting. */
typedef enum { S_START, S_WORD, S_NUMBER, S_DONE } State;
void tokenize(const char *s)
{
State st = S_START;
int i = 0;
while (st != S_DONE) {
char c = s[i];
switch (st) {
case S_START:
if (c == '\0') st = S_DONE;
else if (isalpha((unsigned char)c)) { printf("WORD("); st = S_WORD; }
else if (isdigit((unsigned char)c)) { printf("NUM("); st = S_NUMBER; }
else i++; /* skip separator */
break;
case S_WORD:
if (isalpha((unsigned char)c)) { putchar(c); i++; }
else { printf(") "); st = S_START; }
break;
case S_NUMBER:
if (isdigit((unsigned char)c)) { putchar(c); i++; }
else { printf(") "); st = S_START; }
break;
case S_DONE:
break;
}
}
putchar('\n');
}
int main(void)
{
const char *s = "abc123 x9";
printf("messy = %d, clean = %d\n", messy_classify(s), clean_classify(s));
tokenize("hello 42 world7");
return 0;
}
State-machine shape:
+---------+ alpha +--------+
+-----> | START |--------> | WORD |--+
| +---------+ +--------+ | non-alpha
| | | digit ^ |
| other | +---------+ +-------+
+---------+ |
v
+----------+
| NUMBER |--+
+----------+ | non-digit
^ |
+-------+
Every transition is one line in one 'case'. No nesting grows.
Key Takeaways
- Structured programming builds all control flow from sequence, selection, and iteration; Böhm–Jacopini proved these suffice.
- Single-entry, single-exit blocks are what make local reasoning about state possible.
- C's
break,continue,return, and forwardgotoare disciplined deviations with well-defined targets — not arbitrary jumps. - Backward
gotois almost always a loop written badly; rewrite it with a loop construct. - Cyclomatic complexity above ~10–15 signals a function to split; genuinely complex flow belongs in an explicit state machine.
🧪 Practice
- Rewrite
messy_classifyyourself before readingclean_classify, then compare your version.- Count the cyclomatic complexity of a function in your own code (decision points + 1). Split it if the count exceeds 12.
- Extend the
tokenizestate machine with aS_STRINGstate that captures text between double quotes.- Interview-style: "Give a concrete case where
gotoproduces more maintainable code than the structured alternative." Hint: the answer involves resources acquired in sequence, and what happens when a fifth one is added later.