C · lesson 6 of 25
Control Flow in C
C if/else, switch, for, while and do-while loops, break, continue and goto, with real output showing dangling-else and fall-through bugs.
By SkillByExample · Updated
if / else
if runs a block when a condition is non-zero. Chaining else if tests conditions in order and stops at the first one that matches:
#include <stdio.h>
char grade(int score) {
if (score >= 90) {
return 'A';
} else if (score >= 80) {
return 'B';
} else if (score >= 70) {
return 'C';
} else if (score >= 60) {
return 'D';
} else {
return 'F';
}
}
int main(void) {
int scores[] = {95, 80, 79, 42};
for (int i = 0; i < 4; i++) {
printf("%d -> %c\n", scores[i], grade(scores[i]));
}
return 0;
}
$ gcc -Wall -Wextra -std=c17 -o grades grades.c
$ ./grades
95 -> A
80 -> B
79 -> C
42 -> F
95 also satisfies score >= 80, but the chain never gets that far, because the first true branch wins. The order of the tests is part of the logic: put >= 60 first and every passing score becomes a D.
The Dangling else
Braces are optional for a single statement, and that makes this code misleading:
#include <stdio.h>
int main(void) {
int x = -5;
if (x > 0)
if (x > 100)
printf("large\n");
else
printf("non-positive\n"); /* indentation lies */
printf("done\n");
return 0;
}
$ gcc -Wall -Wextra -std=c17 -o dangling dangling.c
dangling.c: In function 'main':
dangling.c:5:8: warning: suggest explicit braces to avoid ambiguous 'else' [-Wdangling-else]
5 | if (x > 0)
| ^
$ ./dangling
done
x is -5, yet non-positive never printed. An else binds to the nearest if (here x > 100), whatever the indentation says. Since x > 0 is false, the whole inner if/else is skipped. Put braces around the inner if and the else attaches to the outer one as intended. Always using braces makes the problem impossible.
switch
switch compares one integer expression against constant case labels. Each case normally ends with break:
#include <stdio.h>
int main(void) {
const char ops[] = {'+', '-', '*', '/', '%'};
int a = 10, b = 3;
for (int i = 0; i < 5; i++) {
char op = ops[i];
switch (op) {
case '+': printf("%d + %d = %d\n", a, b, a + b); break;
case '-': printf("%d - %d = %d\n", a, b, a - b); break;
case '*': printf("%d * %d = %d\n", a, b, a * b); break;
case '/':
if (b != 0) printf("%d / %d = %d\n", a, b, a / b);
else printf("division by zero\n");
break;
default:
printf("unknown operator: %c\n", op);
break;
}
}
return 0;
}
$ gcc -Wall -Wextra -std=c17 -o calc calc.c
$ ./calc
10 + 3 = 13
10 - 3 = 7
10 * 3 = 30
10 / 3 = 3
unknown operator: %
'%' matched no case, so default handled it. switch works only on integer types (int, char, enum). It cannot switch on strings, floating-point values, or ranges, and every case label must be a compile-time constant.
Grouping Cases, and Forgetting break
Stacking labels lets several values share one handler:
#include <stdio.h>
const char *kind(int day) {
switch (day) {
case 1: case 2: case 3: case 4: case 5:
return "weekday";
case 6: case 7:
return "weekend";
default:
return "invalid";
}
}
int main(void) {
int days[] = {3, 6, 9};
for (int i = 0; i < 3; i++) {
printf("day %d: %s\n", days[i], kind(days[i]));
}
return 0;
}
$ gcc -Wall -Wextra -std=c17 -o days days.c
$ ./days
day 3: weekday
day 6: weekend
day 9: invalid
return leaves the function, so no break is needed here. When a case has statements but no break, execution keeps going into the next case:
#include <stdio.h>
int main(void) {
int level = 1;
switch (level) {
case 1:
printf("level 1: read\n");
case 2:
printf("level 2: write\n");
break;
case 3:
printf("level 3: admin\n");
break;
}
return 0;
}
$ gcc -Wall -Wextra -std=c17 -o fallbug fallbug.c
fallbug.c: In function 'main':
fallbug.c:7:13: warning: this statement may fall through [-Wimplicit-fallthrough=]
7 | printf("level 1: read\n");
| ^~~~~~~~~~~~~~~~~~~~~~~~~
fallbug.c:8:9: note: here
8 | case 2:
| ^~~~
$ ./fallbug
level 1: read
level 2: write
A level-1 user just got write permission. case labels are only jump targets, not boundaries, so once execution enters at case 1: it runs every statement below until a break. -Wextra catches this. Without it, GCC stays silent.
for Loops
A for header puts the initializer, the condition and the update on one line:
#include <stdio.h>
int main(void) {
for (int i = 0; i < 5; i++) printf("%d ", i);
printf("\n");
for (int i = 10; i > 0; i -= 3) printf("%d ", i);
printf("\n");
for (int i = 0, j = 10; i < j; i += 2, j -= 2) printf("(%d,%d) ", i, j);
printf("\n");
int n = 1;
for (;;) { /* no condition: runs until break */
if (n > 8) break;
printf("%d ", n);
n *= 2;
}
printf("\n");
return 0;
}
$ gcc -Wall -Wextra -std=c17 -o forloops forloops.c
$ ./forloops
0 1 2 3 4
10 7 4 1
(0,10) (2,8) (4,6)
1 2 4 8
The countdown stops at 1: the next value would be -2, which fails i > 0 before the body runs. for (;;) has an empty condition, which C treats as always true, so the idiomatic infinite loop depends on a break to end. A variable declared in the header, like int i, exists only inside that loop.
Leaving Nested Loops
break leaves only the innermost loop. To stop a nested search completely, goto to a label after both loops is the cleanest option:
#include <stdio.h>
int main(void) {
int found_i = -1, found_j = -1;
for (int i = 0; i < 5; i++) {
for (int j = 0; j < 5; j++) {
if (i * j == 6) {
found_i = i;
found_j = j;
goto done; /* leaves both loops at once */
}
}
}
done:
printf("first i*j == 6 at i=%d, j=%d\n", found_i, found_j);
return 0;
}
$ gcc -Wall -Wextra -std=c17 -o nested nested.c
$ ./nested
first i*j == 6 at i=2, j=3
3 * 2 would also match, but the search stopped at the first hit. With a plain break, the outer loop would continue, and found_i/found_j would be overwritten by i=3, j=2. Moving the search into a function and using return is the other clean way out.
while Loops
while checks its condition before each pass, so the body may run zero times. It’s the natural fit for “keep going until the input runs out”:
#include <stdio.h>
int main(void) {
int ch, count = 0;
while ((ch = getchar()) != EOF) { /* assign, then compare */
if (ch >= 'a' && ch <= 'z') ch -= 32;
putchar(ch);
count++;
}
printf("[%d characters]\n", count);
return 0;
}
$ gcc -Wall -Wextra -std=c17 -o whileloop whileloop.c
$ echo "hello, c" | ./whileloop
HELLO, C
[9 characters]
The count is 9, not 8: echo adds a newline, and getchar returns it like any other character. ch must be an int, not a char, because EOF (usually -1) has to be distinguishable from all 256 possible byte values. The extra parentheses in (ch = getchar()) != EOF matter, since != binds tighter than =. Typing input interactively instead, end it with Ctrl+D (Ctrl+Z then Enter on Windows).
do-while Loops
do-while tests its condition after the body, so the body always runs at least once. Menus are the textbook case:
#include <stdio.h>
int main(void) {
int choice;
do {
printf("1) Say hello 2) Say bye 0) Quit\n");
printf("Choice: ");
if (scanf("%d", &choice) != 1) {
choice = 0; /* EOF or bad input: stop */
}
if (choice == 1) printf("Hello!\n");
if (choice == 2) printf("Bye!\n");
} while (choice != 0);
printf("Goodbye\n");
return 0;
}
$ gcc -Wall -Wextra -std=c17 -o menu menu.c
$ printf "1\n2\n0\n" | ./menu
1) Say hello 2) Say bye 0) Quit
Choice: Hello!
1) Say hello 2) Say bye 0) Quit
Choice: Bye!
1) Say hello 2) Say bye 0) Quit
Choice: Goodbye
The input was piped in rather than typed, so the choices themselves don’t appear after Choice:. Run it interactively and your typing fills those gaps. Checking scanf’s return value is what keeps the loop from spinning forever on end-of-input or on a non-number like x.
The “at least once” guarantee can change a result. Counting the digits of a number shows it:
#include <stdio.h>
int count_digits_while(unsigned int n) {
int count = 0;
while (n > 0) { count++; n /= 10; }
return count;
}
int count_digits_do(unsigned int n) {
int count = 0;
do { count++; n /= 10; } while (n > 0);
return count;
}
int main(void) {
unsigned int tests[] = {12345, 7, 0};
for (int i = 0; i < 3; i++) {
printf("%u: while=%d do-while=%d\n", tests[i],
count_digits_while(tests[i]), count_digits_do(tests[i]));
}
return 0;
}
$ gcc -Wall -Wextra -std=c17 -o digits digits.c
$ ./digits
12345: while=5 do-while=5
7: while=1 do-while=1
0: while=0 do-while=1
The two agree until the input is 0. The while version never enters its body and reports 0 digits, which is wrong, because “0” is one digit. The do-while version runs once no matter what.
break and continue
break exits the nearest enclosing loop (or switch). continue skips the rest of the current iteration:
#include <stdio.h>
int main(void) {
for (int i = 0; i < 10; i++) {
if (i == 5) break; /* leave the loop */
printf("%d ", i);
}
printf("\n");
for (int i = 0; i < 10; i++) {
if (i % 2 == 0) continue; /* skip to the next iteration */
printf("%d ", i);
}
printf("\n");
for (int i = 0; i < 3; i++) {
for (int j = 0; j < 3; j++) {
if (j == 1) break; /* only leaves the inner loop */
printf("(%d,%d) ", i, j);
}
}
printf("\n");
return 0;
}
$ gcc -Wall -Wextra -std=c17 -o breakcont breakcont.c
$ ./breakcont
0 1 2 3 4
1 3 5 7 9
(0,0) (1,0) (2,0)
In a for loop, continue still runs the update expression (i++) before re-testing the condition. That’s why the second loop doesn’t get stuck. The last loop shows break’s limit: the inner loop stops at j == 1 every time, but the outer loop carries on through all three values of i.
goto for Cleanup
The one widely accepted use of goto in C is jumping forward to a single cleanup block. This helps when a function acquires several resources and any step can fail:
#include <stdio.h>
#include <stdlib.h>
int count_lines(const char *filename) {
int result = -1;
char *buffer = NULL;
FILE *fp = fopen(filename, "r");
if (!fp) {
fprintf(stderr, "cannot open %s\n", filename);
goto cleanup;
}
buffer = malloc(256);
if (!buffer) {
fprintf(stderr, "out of memory\n");
goto cleanup;
}
result = 0;
while (fgets(buffer, 256, fp)) result++;
cleanup:
free(buffer); /* free(NULL) is a safe no-op */
if (fp) fclose(fp);
return result;
}
int main(int argc, char *argv[]) {
for (int i = 1; i < argc; i++) {
printf("%s: %d\n", argv[i], count_lines(argv[i]));
}
return 0;
}
$ printf "alpha\nbeta\ngamma\n" > notes.txt
$ gcc -Wall -Wextra -std=c17 -o cleanup cleanup.c
$ ./cleanup notes.txt missing.txt
notes.txt: 3
cannot open missing.txt
missing.txt: -1
Both paths run through the same cleanup code. For missing.txt, fopen failed, the jump skipped the malloc, and free(NULL) was harmless. Every variable the cleanup block uses is declared and initialized before the first goto. Jumping past an initialization would leave buffer holding garbage when free ran. Only ever jump forward, to a label near the end of the same function.
Loop Comparison
| Loop | Use when… |
|---|---|
for | You’re counting or stepping through a known range |
while | You loop while a condition holds, and zero passes is valid |
do-while | The body must run at least once (menus, digit counting) |
Practice
- Print FizzBuzz for 1 to 15:
Fizzfor multiples of 3,Buzzfor multiples of 5,FizzBuzzfor both, otherwise the number.
Solution
#include <stdio.h>
int main(void) {
for (int i = 1; i <= 15; i++) {
if (i % 15 == 0) printf("FizzBuzz\n");
else if (i % 3 == 0) printf("Fizz\n");
else if (i % 5 == 0) printf("Buzz\n");
else printf("%d\n", i);
}
return 0;
}
$ gcc -Wall -Wextra -std=c17 -o p1 p1.c
$ ./p1
1
2
Fizz
4
Buzz
Fizz
7
8
Fizz
Buzz
11
Fizz
13
14
FizzBuzz
The % 15 test has to come first. Put it last and 15 is caught by % 3 and prints Fizz, the same ordering lesson as the grade chain.
- Print every prime from 1 to 30, using
continueto skip non-primes.
Solution
#include <stdio.h>
int is_prime(int n) {
if (n < 2) return 0;
for (int d = 2; d * d <= n; d++) {
if (n % d == 0) return 0;
}
return 1;
}
int main(void) {
for (int n = 1; n <= 30; n++) {
if (!is_prime(n)) continue;
printf("%d ", n);
}
printf("\n");
return 0;
}
$ gcc -Wall -Wextra -std=c17 -o p2 p2.c
$ ./p2
2 3 5 7 11 13 17 19 23 29
d * d <= n stops the trial division at √n: any factor larger than that would pair with one smaller than it, which was already tested. The return 0 inside the loop acts as an early exit, just like break.
- Reverse the digits of
12345with awhileloop, using only%and/.
Solution
#include <stdio.h>
int main(void) {
int n = 12345, reversed = 0;
while (n > 0) {
reversed = reversed * 10 + n % 10;
n /= 10;
}
printf("reversed = %d\n", reversed);
return 0;
}
$ gcc -Wall -Wextra -std=c17 -o p3 p3.c
$ ./p3
reversed = 54321
Each pass peels off the last digit with % 10 and shifts it onto reversed with * 10. It’s the same peel-a-digit loop as count_digits, keeping the digits instead of counting them.
- The level-1 fall-through above is a bug, but suppose read-implies-write was intended. Keep the behavior and silence the warning.
Solution
#include <stdio.h>
int main(void) {
int level = 1;
switch (level) {
case 1:
printf("level 1: read\n");
/* fall through */
case 2:
printf("level 2: write\n");
break;
case 3:
printf("level 3: admin\n");
break;
}
return 0;
}
$ gcc -Wall -Wextra -std=c17 -o p4 p4.c
$ ./p4
level 1: read
level 2: write
GCC recognizes a /* fall through */ comment right before the next label as a statement of intent and drops the warning. C23 adds a standard spelling, [[fallthrough]];. Either way, the next reader knows the missing break is deliberate.