Conditional Statements
A program that cannot choose is a calculator. Conditionals are how a program decides — and in C they carry a handful of sharp edges that have each caused real, expensive failures.
- Write
if,else if, andelsechains that are correct and readable. - Explain what C actually treats as true, and why
if (x)works. - Use the conditional operator where it clarifies and avoid it where it does not.
- Write a
switchcorrectly, including deliberate fall-through. - Recognize the dangling
else, the stray semicolon, and the missingbreakon sight.
1if, else if, else
if (temperature > 30) {
puts("hot");
} else if (temperature > 20) {
puts("warm");
} else if (temperature > 10) {
puts("mild");
} else {
puts("cold");
}The conditions are tested in order and at most one branch runs. As soon as one test succeeds, the rest are skipped entirely. That ordering is what makes the chain above correct with such simple tests: by the time the second condition is reached, the program already knows the temperature is not above 30.
Write the same chain in the wrong order and it silently produces nonsense:
if (temperature > 10) {
puts("mild"); /* 35 degrees reports "mild" */
} else if (temperature > 30) {
puts("hot"); /* unreachable */
}The compiler will not warn. Ordering the tests from most specific to least specific is a habit worth forming now.
There is no elseif keyword
else if is not special syntax. It is an if statement that happens to be the single statement belonging to an else. C allows any statement there, and an if is a statement. Writing the braces out makes this visible:
else {
if (temperature > 20) { … }
}Nobody writes it that way, but knowing that is what it means explains the dangling-else problem in section 4.
2What C considers true
There is no boolean requirement. A condition is any expression that yields a number, and the rule is the one from week 6: zero is false, everything else is true.
if (count) /* true when count is not zero */
if (!count) /* true when count is zero */
if (pointer) /* true when pointer is not NULL */
if (-1) /* true — non-zero, even though negative */These idioms are pervasive in real C, so you must be able to read them. Whether to write them is a style question. if (count != 0) states the intent; if (count) is shorter. For pointers, if (p) is nearly universal and worth adopting; for counts, the explicit comparison usually reads better.
One place the shorthand is genuinely wrong: comparing floating-point values. if (x) on a double asks whether it is exactly zero, which week 7 warned against.
= versus ==, again. if (status = 0) assigns zero and then tests zero — the branch never runs, and status has been destroyed. It is legal C, so only -Wall saves you. This mistake caused a widely publicised security flaw in Apple's TLS code in 2014; it is not a beginner-only problem.
3The conditional operator
C's only ternary operator chooses between two expressions:
int larger = (a > b) ? a : b;
printf("%d item%s\n", n, (n == 1) ? "" : "s");It is an expression, so it can appear where a statement cannot — inside a function call, in an initializer, in a return. That is its real value. Exactly one of the two branches is evaluated, like && and ||.
It stops being helpful when nested:
/* Do not do this */
char *grade = (s >= 90) ? "A" : (s >= 80) ? "B" : (s >= 70) ? "C" : "F";An if/else if chain expresses the same logic in a form that can be read, debugged line by line, and extended. Use the conditional operator for a single either-or choice; reach for if beyond that.
4Three classic traps
The stray semicolon
if (x > 0);
{
puts("positive"); /* runs unconditionally */
}The semicolon is an empty statement, and it is the entire body of the if. The block that follows is just a block, executed always. -Wextra emits suggest braces around empty body, which is another reason that flag is not optional.
The dangling else
if (a > 0)
if (b > 0)
puts("both positive");
else
puts("a is not positive"); /* WRONG: binds to the inner if */An else attaches to the nearest unmatched if, regardless of how you indent. Here it belongs to if (b > 0), so the message prints when a is positive and b is not — the opposite of what the layout suggests. Braces remove the ambiguity permanently:
if (a > 0) {
if (b > 0) {
puts("both positive");
}
} else {
puts("a is not positive");
}Omitted braces
if (error)
log_error();
return -1; /* always runs — not part of the if */Indentation is not syntax in C. The return executes unconditionally. This is the shape of Apple's "goto fail" bug, and the reason many style guides — including the Linux kernel's, which otherwise permits brace-free single statements — now require braces without exception.
Use braces always. Even for a single statement. It costs two characters, it survives every future edit that adds a second statement, and it eliminates two of the three traps above outright. Let clang-format from week 3 enforce it.
5switch
switch compares one integer expression against a list of constant values:
switch (command) {
case 'a':
add_item();
break;
case 'd':
delete_item();
break;
case 'q':
quit();
break;
default:
fprintf(stderr, "unknown command '%c'\n", command);
break;
}The rules
- The controlling expression must be an integer type —
int,char, an enumeration. Not adouble, not a string. - Each
caselabel must be a constant expression, known at compile time.case x:wherexis a variable does not compile. - Labels must be distinct.
defaultis optional and may appear anywhere, though the end is conventional.
Fall-through
This is the part that surprises everyone. A case label is a jump target, not a block. Execution enters at the matching label and then continues straight through every following label until it hits a break or the closing brace.
switch (grade) {
case 'A':
puts("excellent");
/* no break — falls through */
case 'B':
puts("good");
break;
}An 'A' prints both lines. Forgetting a break is one of the most common C bugs, and -Wimplicit-fallthrough exists specifically to catch it.
Deliberate fall-through is genuinely useful for grouping labels:
switch (c) {
case 'a': case 'e': case 'i': case 'o': case 'u':
vowels++;
break;
default:
consonants++;
break;
}When you fall through on purpose across statements, say so. C23 provides an attribute; a comment has long been the convention and GCC recognizes it:
case 'A':
bonus += 10;
[[fallthrough]]; /* C23 */
case 'B':
passed = true;
break;Declaring a variable inside a switch
switch (n) {
case 1:
int x = 5; /* error in C: a label cannot precede a declaration */
break;
}Wrap the case body in braces, which also scopes the variable properly:
case 1: {
int x = 5;
…
break;
}6switch or if?
Use switch when | Use if when |
|---|---|
| Testing one value against several constants | Testing ranges, or several different variables |
| The value is an integer or enumeration | The value is a string, a float, or a pointer |
| The cases are a closed, enumerable set | The conditions are arbitrary expressions |
You want the compiler to warn about an unhandled enum value | Order of evaluation matters |
That last row is a real advantage. With an enumeration and no default, -Wswitch warns when you add a new enumerator and forget to handle it — a small piece of static checking the language rarely offers. Week 22 relies on it.
The performance argument you may read — that switch compiles to a jump table and is therefore faster — is real but almost never decisive. The compiler builds a jump table only when the labels are dense, and for a handful of cases the difference is unmeasurable. Choose on clarity.
7Worked example: the same classifier, twice
A program that reports a letter grade and a short description. Written first with if, then with switch, so the trade-off is concrete rather than theoretical.
#include <stdio.h>
#include <stdlib.h>
#include <stdbool.h>
/* Version 1: ranges. An if chain is the natural fit. */
static char grade_from_score(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';
}
}
/* Version 2: a fixed set of discrete values. switch is the natural fit. */
static const char *describe(char grade)
{
switch (grade) {
case 'A':
return "excellent";
case 'B':
return "good";
case 'C':
return "satisfactory";
case 'D':
return "marginal";
case 'F':
return "failing";
default:
return "unknown";
}
}
/* Deliberate fall-through, grouping labels that share one action. */
static bool is_passing(char grade)
{
switch (grade) {
case 'A':
case 'B':
case 'C':
case 'D':
return true;
default:
return false;
}
}
int main(void)
{
const int scores[] = { 95, 83, 71, 64, 42, 100, 0 };
const size_t count = sizeof scores / sizeof scores[0];
printf("%-7s %-7s %-15s %s\n", "score", "grade", "description", "passing");
for (size_t i = 0; i < count; i++) {
char g = grade_from_score(scores[i]);
printf("%-7d %-7c %-15s %s\n",
scores[i], g, describe(g), is_passing(g) ? "yes" : "no");
}
return EXIT_SUCCESS;
}gcc -std=c17 -Wall -Wextra -g -o grades grades.c
./gradesWhy each construct fits where it does
grade_from_score tests ranges. A switch cannot express score >= 90; you would need ninety-one labels. And the chain depends on ordering — each test is written assuming the earlier ones already failed, which is why score >= 80 does not need an upper bound.
describe maps a small closed set of values to results. Every branch does the same kind of thing with a different constant, which is exactly the shape switch was designed for. Written as an if chain it would repeat grade == five times.
is_passing uses fall-through the way it is meant to be used: four labels sharing one action, no statements between them, no comment needed because the intent is unmistakable.
Now introduce the bugs and watch the compiler
Make these four changes one at a time and rebuild.
/* 1. drop a break */
case 'A':
return "excellent";
case 'B':
puts("good"); /* change return to puts, remove break */
case 'C':
puts("satisfactory");warning: this statement may fall through [-Wimplicit-fallthrough=]/* 2. stray semicolon */
if (score >= 90);
return 'A';warning: suggest braces around empty body in an 'if' statement [-Wempty-body]/* 3. assignment in a condition */
if (score = 90) { … }warning: suggest parentheses around assignment used as truth value [-Wparentheses]/* 4. reordered chain — the silent one */
if (score >= 60) return 'D';
else if (score >= 90) return 'A'; /* unreachable */(no warning at all)Three of the four are caught for free. The fourth — wrong ordering — is perfectly valid C and produces wrong answers silently. That is the category of bug tests exist for, and it is why week 35 spends a session on testing rather than trusting the compiler to find everything.
8Common mistakes
| Mistake | What happens | Fix |
|---|---|---|
if (x = 5) | Assigns, then always true | Use ==; never ignore -Wparentheses. |
if (x > 0); | Empty body; block runs unconditionally | Delete the semicolon; heed -Wempty-body. |
| Omitting braces on a single statement | A later edit silently falls outside the if | Always use braces. |
Dangling else | else binds to the inner if | Braces on every nested if. |
Missing break in a switch | Falls through to the next case | break in every case; mark intentional fall-through. |
switch on a double or a string | Compile error | Use if, or strcmp for strings — week 14. |
Ordering an else if chain loosest-first | Later branches unreachable; no warning | Order most specific first, and test the boundaries. |
if (0.1 + 0.2 == 0.3) | False | Compare with a tolerance — week 7. |
9Check yourself
What does if (count) test?
Whether count is non-zero. C has no boolean requirement for conditions: any numeric expression works, zero is false and every other value — including negatives — is true. It is equivalent to if (count != 0).
Which if does an else belong to when the nesting is ambiguous?
The nearest preceding if that does not already have an else, regardless of indentation. Indentation has no syntactic meaning in C, so code that looks correct can bind the opposite way. Braces on every nested if remove the question entirely.
Why does a case without break continue into the next one?
Because case is a label — a jump target — not a block. switch transfers control to the matching label and then execution proceeds normally through whatever follows, including subsequent labels, until a break or the closing brace. This allows label grouping, at the cost of making the missing break an easy mistake.
When does switch give you a safety guarantee that if does not?
When switching on an enumeration with no default label: -Wswitch then warns about any enumerator you failed to handle. Add a new value to the enum later and the compiler points at every switch that needs updating. An if chain offers no equivalent check.
Your grade chain returns 'D' for a score of 95 and the compiler said nothing. What happened?
The tests are ordered from loosest to strictest, so score >= 60 matches first and the later branches are unreachable. This is valid C — the compiler has no way to know the intended semantics — so nothing is reported. Order conditions most specific first, and write tests that check the boundary values.
10Where this leads
A program can now choose. Week 10 gives it the ability to repeat, which is where loop invariants, off-by-one errors, and the third use of goto appear. Together, conditionals and loops are all the control flow C has — everything after week 10 is about organizing data rather than organizing execution.