Lesson overview
Program flow in compiled languages is implemented with conditional jumps. Loops encode predictable jumps, but runtime decisions still require branches. if statements are useful, but they are also among the most abused constructs in codebases: each branch multiplies potential execution paths and makes state harder to reason about, test and optimise.
Learning objectives
- Explain how an
ifstatement creates a conditional jump and multiple execution traces. - Describe why nested or repeated
ifstatements can cause state explosion. - Distinguish branches that depend on external state from branches that depend only on local state.
- Reduce unnecessary branch count using clearer control flow, lookups or dispatch structures.
- Review a function for readability, testability and deterministic control flow.
Key vocabulary before you start
Execution trace
One possible path through a function.
Branch condition
A boolean expression that selects a path.
State explosion
Rapid growth in possible states or paths as branches combine.
Guard clause
An early return or check that rejects invalid cases before the main path.
Short-circuiting
Evaluation where later operands may not be evaluated.
Decision table
A structured representation of conditions and outcomes.
Branch discipline map
A branch is a design cost. Use it when it clarifies real alternatives, and remove it when it merely hides avoidable complexity.
Branches multiply traces
Every if adds at least two possible paths through the code.
State makes paths expensive
Branches followed by state changes are harder to test because later behaviour depends on earlier decisions.
Prefer determinism
Use clearer deterministic structures when a branch is not the best expression of intent.
Branch on purpose
Keep branches for genuine runtime alternatives, especially where inputs, I/O or external state decide behaviour.
Example: one condition, two traces
A simple if already creates distinct execution paths.
// Two branches -> two traces
if (ready()) {
process();
} else {
wait();
}Trace the state
- The condition
ready()is evaluated at runtime. - If it is true, execution jumps into the
process()path. - If it is false, execution jumps into the
wait()path. - Both traces must be understood and tested.
| Line or operation | Variable | Value | State role | Invariant or check |
|---|---|---|---|---|
| Read the example before running it | Key names and calls | Known from source | Reasoning setup | Each named element should support the lesson focus: If Statements, Determinism and Safer Control Flow. |
| Trace the first meaningful operation | Primary state or boundary | Established by initialisation, call or condition | Valid-state checkpoint | Later reasoning must not use the value before this checkpoint. |
| Identify the first decision or dereference | Control or access point | Depends on current state | Failure-mode checkpoint | The condition, pointer, argument or dependency must be valid before use. |
| Record the observable result | Return value, output or mutation | Produced by the example | Evidence checkpoint | The result should match the contract explained in the lesson. |
Visual model: branch complexity map
Each condition adds traces. The design task is to keep those traces necessary, named and testable.
Identify condition sourceSeparate external input, local state, configuration and compile-time choices.
Count tracesList the paths a reviewer or test suite must understand.
Name invariantsState what must be true on each path.
Reduce accidental branchesUse guards, tables, switch or polymorphism where they clarify intent.
Test boundariesExercise true, false, edge and failure paths explicitly.
Trace-count task
Choose one nested branch and list the traces before and after a guard-clause refactor.
Compiled control flow is conditional jumps
In compiled languages, program flow is realised through jumps. Loops encode predictable jumps: repeat to the top or fall through after the loop.
Compilers can often optimise predictable loop shapes for memory locality, cache friendliness and branch prediction.
Some decisions cannot be known until runtime. Those decisions are where if statements enter the program.
Applied task
Explain one if statement as a conditional jump rather than as ordinary prose.
Why if is risky
if evaluates a boolean condition and diverges into at least two paths. Several if statements can multiply those paths quickly.
The difficulty is not only counting paths. Once each path changes state, the later program depends on which earlier path was taken.
This state explosion hurts readability, testability and performance predictability.
Nested decisions multiply paths
Two independent decisions can create four traces before the function has done any real work.
if (authenticated) {
if (hasPermission) {
run_admin_task();
} else {
report_forbidden();
}
} else {
request_login();
}Applied task
Count the possible traces in the example and identify which branches depend on external state.
Reduce branch count when there is a clearer alternative
Add branching only when necessary. A branch should communicate a meaningful runtime alternative.
Prefer structured loops with clear bounds, lookups, dispatch tables or polymorphism when they express the design more directly.
The goal is not to remove every if. The goal is to remove accidental branching so the remaining branches are worth testing.
Lookup instead of repeated branches
A simple table can sometimes communicate fixed mapping better than repeated if blocks.
std::string_view status_name(int code) {
switch (code) {
case 200: return "ok";
case 404: return "missing";
case 500: return "server error";
default: return "unknown";
}
}Applied task
Choose one branch-heavy function and decide whether a lookup, dispatch or early return would make it clearer.
Golden rules for using if
Use if only when it is necessary. Prefer deterministic structures and data-driven patterns when they communicate the design more directly.
Avoid nesting. Replace pyramids of doom with guard clauses, early returns or switch over discrete states.
Limit the number of if statements in a function. Too many branches in one block often signal missing abstraction.
Keep conditions simple. If a condition becomes a boolean salad of &&, || and !, name the sub-predicates.
Flatten with named predicates and guard clauses
The refactor keeps the same decision but makes the reasons easier to test and discuss.
// Nested and complex
if (isOpen && (user.role == Admin || (user.active && !user.banned))) {
// ...
}
// Flatten with named predicates and guard clauses
const bool canModerate = user.role == Admin || (user.active && !user.banned);
if (!isOpen) return;
if (!canModerate) return;
// ...Applied task
Take one complex condition and split it into named predicates that can be tested separately.
The classic bug: = vs == in conditions
An assignment inside an if sets a value and yields that value, which then converts to bool.
This often makes the branch behave very differently from the programmer's intention. Assigning zero or false makes the condition false; assigning a non-zero value makes it true.
Protect yourself with compiler warnings, linters, explicit comparisons and code review. Remember that the OR operator is ||, not II.
Assignment is not comparison
if (x = 0) changes x and then tests the assigned value.
int x = 1;
// Bug: uses assignment, not comparison
if (x = 0) {
// never executes: x is assigned 0, then 0 converts to false
}
// Fix: compare
if (x == 0) {
// ...
}Applied task
Explain why if (x = 0) does not mean the same thing as if (x == 0).
Prefer switch, tables or polymorphism over long if-else chains
When branching on discrete states, a switch or dispatch table is usually clearer than stacked if statements.
For behaviour that varies by type, polymorphism can move the decision to the type system and remove explicit branching from the caller.
The point is not style for its own sake. The point is to make the set of alternatives visible, bounded and easy to extend.
Switch over a discrete operation
The allowed states are visible in one place.
enum class Op { Add, Sub, Mul, Div };
double apply(Op op, double a, double b) {
switch (op) {
case Op::Add: return a + b;
case Op::Sub: return a - b;
case Op::Mul: return a * b;
case Op::Div: return b != 0 ? a / b : 0; // guard
}
return 0;
}Table-driven dispatch
A dispatch table can replace a growing ladder when operations share the same call shape.
#include <functional>
#include <unordered_map>
double add(double a, double b) { return a + b; }
double sub(double a, double b) { return a - b; }
const std::unordered_map<char, std::function<double(double, double)>> ops{
{'+', add}, {'-', sub}
};
double eval(char op, double a, double b) {
if (auto it = ops.find(op); it != ops.end()) {
return it->second(a, b);
}
return 0;
}Applied task
Find one long if-else chain and decide whether its alternatives are discrete enough for switch or dispatch.
Early returns and guard clauses: flatten the pyramid
Nested if statements often hide the main path inside several levels of indentation.
Guard clauses make failure cases explicit and let the successful path read straight down the function.
This is not merely a style preference. Guard clauses reduce the mental stack a reader must maintain while tracing the function.
Flatten nested checks
The early-exit version makes each failure condition visible.
// Deeply nested
bool handle(Request& r) {
if (r.valid()) {
if (hasAuth(r)) {
if (save(r)) {
return true;
}
}
}
return false;
}
// Early exits
bool handle(Request& r) {
if (!r.valid()) return false;
if (!hasAuth(r)) return false;
return save(r);
}Applied task
Rewrite one nested function with guard clauses and explain whether the refactor changes any trace.
Side effects and short-circuiting
&& and || short-circuit: the right-hand expression may not run if the left-hand expression already decides the result.
That behaviour is useful for guards, but risky when the skipped expression has side effects.
Keep conditions mostly observational. If an expression mutates state, consider moving it to a named statement before the branch.
Separate effects from tests
If later logic relies on init() having run, do not hide it on the right-hand side of a short-circuit condition.
// Side effects in the right-hand side can be skipped
if (isOpen() && init()) {
start();
} // if isOpen() is false, init() never runs
// Separate effects from tests
bool ok = isOpen();
if (ok) ok = init();
if (ok) start();Applied task
Find one condition with a function call inside it and decide whether the function is observational or mutating.
Advanced: compile-time branching reduces runtime traces
Where possible, move decisions to compile time using templates or if constexpr.
A compile-time branch is resolved by the compiler for the instantiated type, so the unused branch does not become a runtime trace.
This is an advanced tool. Use it when the choice really is a type-level or constant-level decision, not ordinary runtime data.
if constexpr chooses by type
For an integral T, the integral branch is compiled for that instantiation.
#include <iostream>
#include <type_traits>
template <typename T>
void print_num(T x) {
if constexpr (std::is_integral_v<T>) {
std::cout << "int: " << x << '\n';
} else {
std::cout << "other: " << x << '\n';
}
}Applied task
Identify one branch in a generic function that depends on type rather than runtime state.
Worked example: from messy ifs to clear logic
The problem version combines an assignment-in-condition bug, a complex role predicate, nested control flow and an unclear initialisation path.
The refactor fixes comparison, names the privilege rule, uses guard clauses and makes initialisation explicit before starting.
The result still has branches, but each branch explains a genuine failure reason.
Problem: hard-to-read, bug-prone branching
The assignment means the first condition does not test what it appears to test.
int status = 0;
if (config.enabled = true) { // assignment, always true
if (user.role == "admin" || user.role == "root" || user.role == "owner") {
if (attempts < 3 && !locked) {
status = start(); // may skip init()
}
}
}Refactor: guards, named predicates and explicit effects
Each guard names one failure path; init() is no longer hidden inside another condition.
bool hasPriv(const User& u) {
return u.role == "admin" || u.role == "root" || u.role == "owner";
}
int safeStart(const Config& config, const User& user, int attempts, bool locked) {
if (!config.enabled) return -1;
if (!hasPriv(user)) return -2;
if (attempts >= 3 || locked) return -3;
if (!init()) return -4;
return start();
}Applied task
Trace the problem and refactored versions, then explain which risks were removed.
Summary checklist
Minimise branches. Prefer deterministic designs; loops with clear bounds beat ad-hoc if statements.
Flatten logic. Guard clauses are usually clearer than nested if pyramids.
Name predicates. Keep conditions short and avoid boolean salad.
Never use = where you mean ==; keep conditions pure and avoid hidden side effects.
Dispatch, do not branch, when the choices are discrete. Use switch, tables or polymorphism.
Where possible, move decisions to compile time with if constexpr or templates.
Applied task
Use this checklist to review one function before and after refactoring.
Applied case lab
Case 1: Branching on external state
A function behaves differently depending on user input, file availability and network status. Mark which branches are unavoidable and which are just formatting or control-flow clutter.
Case 2: Nested validation
A validation function nests three if statements before the main work. Decide whether guard clauses would reduce indentation and improve traceability.
Case 3: Mapping codes to actions
A long chain of if statements maps command characters to handlers. Decide whether switch, a lookup table or a command object would communicate intent better.
Applied task: audit the branches
Practise counting traces, classifying branch sources and reducing accidental control-flow complexity.
Stage 1: read and classify
A. Count the traces
How many paths are visible, and which state changes on each path?
if (ready()) {
process();
} else {
wait();
}- Count the traces.
- Name the condition that chooses each trace.
- List which function is called on each path.
Reveal one possible refactor
Trace 1: `ready()` true -> `process()`.
Trace 2: `ready()` false -> `wait()`.Model reasoning
A strong answer explains why the change preserves behaviour, what failure mode it removes, and which trade-off remains acceptable.
Stage 2: trace or repair
B. Flatten nested checks
Use guard clauses where they make the main path clearer.
if (user_loaded) {
if (has_permission) {
run_task();
} else {
return forbidden();
}
} else {
return missing_user();
}- Rewrite with guard clauses.
- Identify the main path.
- Explain whether the refactor changes behaviour.
Reveal one possible refactor
if (!user_loaded) return missing_user();
if (!has_permission) return forbidden();
run_task();Model reasoning
A strong answer explains why the change preserves behaviour, what failure mode it removes, and which trade-off remains acceptable.
Stage 3: justify the design
C. Classify branch sources
Separate branches caused by external state from branches caused by local formatting or structure.
if (std::cin >> value) {
if (value > 0) {
std::cout << "positive\n";
}
}- Mark the input branch.
- Mark the local value branch.
- Decide which branch needs failure-path testing.
Reveal one possible refactor
`std::cin >> value` depends on external input and needs failure-path testing. `value > 0` depends on local state and needs boundary tests.Model reasoning
A strong answer explains why the change preserves behaviour, what failure mode it removes, and which trade-off remains acceptable.
Stage 4: test the boundary
D. Name boolean predicates
Split the boolean condition into separately testable ideas.
if (isOpen && (user.role == Admin || (user.active && !user.banned))) {
moderate();
}- Name the predicate that decides whether the user can moderate.
- Use guard clauses to flatten the control flow.
- List the boundary tests for each predicate.
Reveal one possible refactor
const bool canModerate = user.role == Admin || (user.active && !user.banned);
if (!isOpen) return;
if (!canModerate) return;
moderate();Model reasoning
A strong answer explains why the change preserves behaviour, what failure mode it removes, and which trade-off remains acceptable.
Stage 5: evaluate alternatives
E. Spot assignment in a condition
Find the bug and explain the runtime state change.
int x = 1;
if (x = 0) {
run();
}- State the value assigned to
x. - State whether the branch executes.
- Rewrite the condition as a comparison.
Reveal one possible refactor
int x = 1;
if (x == 0) {
run();
}Model reasoning
A strong answer explains why the change preserves behaviour, what failure mode it removes, and which trade-off remains acceptable.
F. Replace a long branch chain
Use switch when a discrete state is being selected.
if (op == Op::Add) return left + right;
if (op == Op::Sub) return left - right;
if (op == Op::Mul) return left * right;
if (op == Op::Div) return left / right;- Rewrite with
switch. - Identify how division by zero should be handled.
- Explain why the alternatives are bounded.
Reveal one possible refactor
switch (op) {
case Op::Add: return left + right;
case Op::Sub: return left - right;
case Op::Mul: return left * right;
case Op::Div: return right == 0.0 ? 0.0 : left / right;
}Model reasoning
A strong answer explains why the change preserves behaviour, what failure mode it removes, and which trade-off remains acceptable.
G. Replace chains with tables
A repeated branch ladder maps symbols to operations.
if (op == '+') return add(a, b);
if (op == '-') return sub(a, b);
return 0;- Convert the branch ladder to a dispatch map.
- State the shared function signature.
- Explain the default behaviour for unknown operators.
Reveal one possible refactor
const std::unordered_map<char, std::function<double(double, double)>> ops{
{'+', add}, {'-', sub}
};
if (auto it = ops.find(op); it != ops.end()) return it->second(a, b);
return 0;Model reasoning
A strong answer explains why the change preserves behaviour, what failure mode it removes, and which trade-off remains acceptable.
H. Flatten the pyramid
The main path is buried inside nested checks.
bool handle(Request& r) {
if (r.valid()) {
if (hasAuth(r)) {
if (save(r)) {
return true;
}
}
}
return false;
}- Rewrite with early returns.
- Name the failure condition at each guard.
- Explain why the successful path is easier to trace.
Reveal one possible refactor
bool handle(Request& r) {
if (!r.valid()) return false;
if (!hasAuth(r)) return false;
return save(r);
}Model reasoning
A strong answer explains why the change preserves behaviour, what failure mode it removes, and which trade-off remains acceptable.
I. Make conditions pure
The right-hand side might not run, so side effects can disappear.
if (isOpen() && init()) {
start();
}- State when
init()is skipped. - Separate the effectful call from the boolean test.
- Explain why the refactor is easier to trace.
Reveal one possible refactor
bool ok = isOpen();
if (ok) ok = init();
if (ok) start();Model reasoning
A strong answer explains why the change preserves behaviour, what failure mode it removes, and which trade-off remains acceptable.
J. Move type decisions to compile time
The branch depends on the type of T, not on ordinary runtime data.
template <typename T>
void print_num(T x) {
// TODO: print integral values differently from other values
}- Use
if constexpr. - Choose the type trait that identifies integral types.
- Explain why the unused branch is not a runtime trace for one instantiation.
Reveal one possible refactor
template <typename T>
void print_num(T x) {
if constexpr (std::is_integral_v<T>) {
std::cout << "int: " << x << '\n';
} else {
std::cout << "other: " << x << '\n';
}
}Model reasoning
A strong answer explains why the change preserves behaviour, what failure mode it removes, and which trade-off remains acceptable.
K. Clean up a messy branch block
Fix the assignment bug, extract privilege logic and make initialisation explicit.
int status = 0;
if (config.enabled = true) {
if (user.role == "admin" || user.role == "root" || user.role == "owner") {
if (attempts < 3 && !locked) {
status = start();
}
}
}- Fix the assignment-in-condition bug.
- Extract the role test into a named predicate.
- Use guard clauses and call
init()explicitly beforestart().
Reveal one possible refactor
bool hasPriv(const User& u) {
return u.role == "admin" || u.role == "root" || u.role == "owner";
}
int safeStart(const Config& config, const User& user, int attempts, bool locked) {
if (!config.enabled) return -1;
if (!hasPriv(user)) return -2;
if (attempts >= 3 || locked) return -3;
if (!init()) return -4;
return start();
}Model reasoning
A strong answer explains why the change preserves behaviour, what failure mode it removes, and which trade-off remains acceptable.
L. Eliminate the ladder
Replace this if-else ladder with a switch or dispatch table.
std::string cmd;
std::cin >> cmd;
if (cmd == "add") add();
else if (cmd == "del") del();
else if (cmd == "list") list();
else std::cout << "Unknown\n";- Map command strings to function pointers or callables.
- Keep an explicit unknown-command path.
- Compare the branch count and test cases before and after.
Reveal one possible refactor
const std::unordered_map<std::string, std::function<void()>> commands{
{"add", add},
{"del", del},
{"list", list}
};
std::string cmd;
std::cin >> cmd;
if (auto it = commands.find(cmd); it != commands.end()) {
it->second();
} else {
std::cout << "Unknown\n";
}Model reasoning
A strong answer explains why the change preserves behaviour, what failure mode it removes, and which trade-off remains acceptable.
Quick checks
1. What does a basic if statement add to a function?
2. Why do repeated or nested if statements become hard to test?
3. What is the best reason to keep an if statement?
4. What does if (x = 0) do in C++?
5. What is a good first step for taming a complex boolean condition?
6. When is switch often clearer than a long if-else chain?
7. What is the main benefit of guard clauses?
8. Why can side effects inside short-circuit conditions be risky?
9. What does if constexpr help remove?
10. Why is the safeStart refactor clearer than the nested version?
11. What should a command dispatch table still include?
AI-augmented practice notes
Use these as prompts for disciplined support, not as permission to outsource judgement.
See your control flow
- Paste a function and ask AI to sketch a control-flow graph.
- Ask it to list distinct traces and the conditions that choose them.
- Ask it to mark branches that depend on external state, such as input, I/O or network responses.
Reduce branch count
- Ask AI to count branches in a function and propose a refactor target, such as no more than three branches.
- Ask AI to extract guard clauses and early returns to flatten nesting.
- Ask AI to identify nested or repeated branches that can become guard clauses, lookups or dispatch.
- Ask it to preserve behaviour while making the main path more visible.
- Review the result yourself by comparing traces before and after.
Replace chains with tables
- Paste an
if/elseladder and ask AI to generate aswitch, dispatch map or polymorphic design. - Ask it to estimate branch reduction and test impact.
- Verify the unknown/default case yourself so the refactor does not silently change behaviour.
Flatten nesting
- Ask AI to rewrite nested
ifstatements into guard clauses. - Ask it to name intermediate predicates where conditions are still hard to read.
- Ask it to add clear failure messages at each guard when the caller needs diagnostics.
Tame boolean logic
- Ask AI to turn complex conditions into named predicates.
- Ask it to apply De Morgan's laws where that makes the false case clearer.
- Ask it to generate a truth table or test cases for each predicate.
Catch assignment in if
- Ask AI for compiler flags and linter checks that warn on assignments in conditions.
- Ask it to scan for suspicious
if (x = ...)patterns. - Review every auto-fix because some assignments in conditions are intentional, but they should be rare and explicit.
Make conditions pure
- Ask AI to find function calls with side effects used only as boolean tests.
- Ask it to separate effectful calls into statements before the condition.
- Ask it to explain which calls may be skipped by short-circuiting.
Provide a refactor patch
- Ask AI to produce a patch that fixes
=vs==, extracts predicates, splits effects and adds guards. - Ask it to generate tests covering both branches of each predicate.
- Review the patch by comparing traces before and after.
From ladder to table
- Ask AI to generate a
std::unordered_map<std::string, std::function<void()>>solution. - Ask it to compare readability and test impact against the original ladder.
- Check the unknown-command behaviour explicitly.
Turn runtime branches into compile-time
- Ask AI which branches can be
constexpror templated. - Ask it to refactor a small sample with
if constexpr. - Check that the decision is genuinely type-level or constant-level before accepting the refactor.
Branch test matrix
- Ask AI to produce a small test matrix for each branch.
- Make sure every true, false and boundary condition has a named test.
- Use the matrix to decide whether the branch is justified.
Assessment tasks
- Draw a control-flow graph for one function with at least two
ifstatements. - Count the traces in a nested branch and identify which state changes on each trace.
- Refactor one nested validation block into guard clauses and compare readability.
- Replace one accidental branch chain with a clearer lookup, switch or dispatch design.
- Convert one repeated operation branch ladder into a dispatch table and test its unknown-operator case.
- Flatten one nested function with guard clauses and document each failure exit.
- Find one short-circuit condition with a function call and classify whether it is observational or mutating.
- Separate one effectful function call from a short-circuit condition and compare the trace.
- Refactor one type-dependent branch with
if constexprand explain which branch is compiled. - Clean up one messy branch block by fixing assignment, naming predicates and using guards.
- Replace one command
if-elseladder with a dispatch table and test the unknown-command path. - Ask for a refactor patch and a branch test matrix, then manually verify the trace comparison.
- Extract named predicates from one complex boolean condition and write a truth table for them.
- Find one assignment-in-condition risk and document the compiler/linter warning that would catch it.
Judgement questions
Why is if useful but dangerous?
Discuss runtime choice, trace growth, state explosion and testing cost.
What does determinism mean in this lesson?
Focus on predictable control flow, visible state and reduced accidental branching.
When should you keep a branch?
Use examples where runtime input or external state genuinely decides behaviour.
