Programming glossary 59 terms mentioned in this lesson
Select any term for a clear definition. The return button brings you back to the exact term link you used.
Lesson: If Statements, Determinism & Safer Control Flow (C++)
Program flow in compiled languages is implemented with conditional jumps. Loops encode predictable jumps
(reiterate to the top or fall through after the loop), which compilers can often optimise for memory locality and cache
friendliness. For-loops are typically more deterministic than while/do-while, so optimisers have an easier job.
Still, some branching can only be decided at runtime — and that’s where if statements enter the picture.
But if is also the most abused construct in many codebases. Every if doubles the number of potential execution
paths (traces), and subsequent state changes multiply those paths further — a state explosion that makes programs hard
to reason about and test. The guiding principle: strive for determinism and use if only when there’s no clearer alternative.
Programming Insight (AI) — See Your Control-Flow
1) What an if Does (and why it’s risky)
An if evaluates a boolean condition and jumps accordingly. Each time the condition is reached, execution can diverge
into at least two paths. Combine several ifs (especially nested), and paths explode. This hurts readability, testability,
and performance predictability.
// Two branches → two traces
if (ready()) {
process();
} else {
wait();
}
Add branching only when necessary. Prefer designs that reduce branching: structured loops with clear bounds, look-ups, or dispatch through tables/polymorphism.
Programming Insight (AI) — Reduce Branch Count
- Ask AI to count branches in a function and propose a refactor target (e.g., ≤ 3 branches).
- Have AI extract guard clauses and early returns to flatten nesting.
2) Golden Rules for Using if
- Use only if necessary. Prefer deterministic structures and data-driven patterns.
- Avoid nesting. Replace pyramids of doom with guard clauses or
switchon discrete states. - Limit per function. Too many
ifs in one block signals missing abstraction. - Keep conditions simple. Avoid complex boolean salads with
&&,||, and!; name sub-predicates.
// ❌ Nested & complex
if (isOpen && (user.role == Admin || (user.active && !user.banned))) {
// ...
}
// ✅ Flatten with named predicates & guard clauses
bool canModerate = user.role == Admin || (user.active && !user.banned);
if (!isOpen) return;
if (!canModerate) return;
// ...
Programming Insight (AI) — Tame Boolean Logic
- Ask AI to turn complex conditions into named predicates and apply De Morgan’s laws where useful.
- Have AI generate a truth table or test cases for each predicate.
3) 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 condition always true (unless you assign zero/false).
int x = 1;
// ❌ Bug: uses assignment, not comparison
if (x = 0) { // assigns 0 to x, then tests 0 → false branch always taken
// never executes
}
// ✅ Fix: compare
if (x == 0) { /* ... */ }
Protect yourself with compiler warnings and style: compare against literals on the left (if (0 == x)) or prefer explicit
comparisons and linters. (Note: the OR operator is ||, not “II”.)
Programming Insight (AI) — Catch Assignment-in-if
- Ask AI for compiler flags and linter checks that warn on assignments in conditions and suggest rewrites.
- Have AI scan a file for suspicious
if (x = ...)patterns and auto-fix to==where appropriate.
4) Prefer switch / Tables / Polymorphism over Long if-else Chains
When branching on discrete states, a switch or a dispatch table is clearer and scales better than stacked ifs.
For behaviour that varies by type, polymorphism sidesteps branching entirely.
enum class Op { Add, Sub, Mul, Div };
// ✅ switch over a closed set
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
#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;
}
Programming Insight (AI) — Replace Chains with Tables
- Paste an if/else ladder; ask AI to generate a
switchor dispatch map/polymorphic design. - Have AI estimate branch reduction and test impact.
5) Early Returns & Guard Clauses (Flatten the Pyramid)
// ❌ 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);
}
Programming Insight (AI) — Flatten Nesting
- Ask AI to rewrite nested
ifs into guard clauses and to name intermediate predicates. - Have AI add clear failure messages at each guard.
6) Side Effects & Short-Circuiting
&& and || short-circuit: the right-hand side may not run. Don’t hide side effects inside conditions you rely on later.
// ❌ side effects in RHS 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();
Programming Insight (AI) — Make Conditions Pure
- Ask AI to find function calls with side effects used only as boolean tests; separate them into statements.
7) Advanced: Compile-Time Branching Reduces Runtime Traces
Where possible, move decisions to compile time using templates or constexpr if (C++17+). This removes runtime branches.
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';
}
}
Programming Insight (AI) — Turn Runtime Branches into Compile-Time
- Ask AI which branches can be
constexpror templated, and have it refactor a sample.
8) Worked Example — From Messy ifs to Clear Logic
Problem: hard-to-read, bug-prone branching:
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: guard clauses, named predicates, fixed comparison:
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; // fix: comparison
if (!hasPriv(user)) return -2;
if (attempts >= 3 || locked) return -3;
if (!init()) return -4; // separate side effect
return start();
}
Programming Insight (AI) — Provide a Refactor Patch
- Ask AI to produce a patch: fix
=vs==, extract predicates, split effects, add guards. - Have AI generate tests to cover both branches of each predicate.
9) Mini Exercise — Eliminate the Ladder
Task: 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";
Hint: map command strings to function pointers or use an enum + switch.
Programming Insight (AI) — From Ladder to Table
- Ask AI to generate a
std::unordered_map<std::string, std::function<void()>>solution and compare readability.
Summary Checklist
- Minimise branches. Prefer deterministic designs; loops with clear bounds beat ad-hoc
ifs. - Flatten logic. Guard clauses > nested
ifs. - Name predicates. Keep conditions short; avoid boolean salad.
- Never use
=where you mean==; keep conditions pure (no hidden side effects). - Dispatch, don’t branch. Use
switch, tables, or polymorphism for discrete choices. - Where possible, move decisions to compile time with
constexpr if/templates.
Optional: embed your video of Graham discussing if statements here.
Advanced perspective: decisions create paths to justify
An if statement selects behaviour from current state
An if statement is easy to recognise, but recognition is a poor test of a decision. The real work is to define the states admitted by each condition, the behaviour selected for those states and the evidence that competing branches are excluded where the requirement demands one outcome. Code that reaches the expected branch for one friendly input has proved very little.
The condition is evaluated when control reaches the statement and is contextually converted to bool. If the result is true, the associated statement executes. If it is false, that statement is skipped and an associated else, if present, is selected. Nothing here is probabilistic. The current state and the expression determine the path.
Begin with the value that the condition actually produces
| Condition source | Conversion or result | Question to ask |
|---|---|---|
A comparison such as health < 25 | The comparison directly produces true or false. | Does the boundary belong on this side of the comparison? |
| An integer expression | Zero converts to false; a nonzero value converts to true. | Was a truth test intended, or has a calculation been used accidentally? |
| A pointer expression | A null pointer converts to false; a non-null pointer converts to true. | Does non-null establish everything the later operation requires? |
An object with an appropriate conversion to bool | The object's defined conversion supplies the condition result. | What state does that conversion represent? |
This is why if (x = 0) can be well-formed C++. The assignment changes x to zero, the assignment expression yields a value and that value converts to false. With if (x = 5), x becomes 5 and the condition is true. A compiler warning is valuable evidence that the code deserves inspection, but the repair is not automatically to replace every assignment with equality. First decide whether mutation belongs in the condition at all. If comparison was intended, write ==; if deliberate acquisition or mutation was intended, make that purpose unmistakable.
Independent questions and exclusive choices are different programs
Separate if statements ask separate questions. More than one body may execute. An if, else if, else chain asks for one selection in order; once a condition is true, later conditions in that chain are not evaluated. The two shapes can contain the same predicates and still express different requirements.
| Score | if (score >= 50) | Separate if (score >= 70) | else if (score >= 70) |
|---|---|---|---|
| 40 | False | False | Evaluated and false, so neither branch is selected |
| 60 | True | False | Not evaluated after the first branch |
| 80 | True | True, so both independent bodies can run | Not evaluated after the first branch |
If the requirement is to record every property that holds, independent tests may be correct. If the requirement is to assign exactly one grade, the predicates must be ordered or written so that one category is selected. Merely changing if to else if can suppress required behaviour, while leaving independent tests can execute incompatible actions. Syntax follows the decision model.
An else is associated with the nearest preceding unmatched if permitted by the grammar. Indentation does not change that association. Braces make the intended statement boundaries visible and protect the structure when another line is added later. They are cheap evidence against a very expensive misunderstanding.
Predicate order defines the classification
#include <iostream>
const char* describe_health(int health) {
if (health <= 0) {
return "defeated";
}
if (health < 25) {
return "critical";
}
return "active";
}
int main() {
std::cout << describe_health(80) << '\n';
std::cout << describe_health(10) << '\n';
std::cout << describe_health(0) << '\n';
}
The function contains two separate if statements, yet at most one label is returned. Each successful branch leaves the function immediately, so later tests cannot alter that result. This is a guard-clause form: handle a decisive case, return, then reason about the reduced set of states that remains.
health | health <= 0 | health < 25 | Returned label |
|---|---|---|---|
| -1 | True | Not evaluated | defeated |
| 0 | True | Not evaluated | defeated |
| 1 | False | True | critical |
| 24 | False | True | critical |
| 25 | False | False | active |
| 80 | False | False | active |
Move health < 25 above health <= 0 and the program still compiles. It also classifies zero and every negative value as critical, because the broader predicate returns before the exceptional case is reached. The order is therefore part of the contract, not a formatting choice.
The table also exposes a requirement question the code cannot answer for us: is negative health a valid state that means defeated, or evidence of an earlier error? The source currently chooses the first policy. If the requirement chooses the second, another outcome and its evidence are needed. Do not smuggle that decision into a test case after the code has already been accepted.
Logical operators are ordered computations
The operators && and || short-circuit from left to right. For left && right, a false left operand decides the whole expression, so the right operand is not evaluated. For left || right, a true left operand decides the expression and the right operand is skipped.
if (pointer != nullptr && *pointer > 0) {
// Dereference occurs only when the pointer is not null.
}
The first operand is a precondition for the second. If pointer is null, the comparison is false and dereferencing is skipped. Reverse the operands and the dereference is attempted before the null check can protect it. The same facts appear in the expression, but their order changes whether the computation is valid.
Short-circuiting can also skip a function call with side effects. Sometimes that is deliberate, as when acquisition should occur only after validation. Sometimes it hides work a programmer assumed would always happen. Read a compound predicate as an execution trace: which operand runs first, what result admits the next operand and what state may change along the way?
More branches increase obligations, not by a simple formula
It is tempting to count each if and declare that the number of paths has doubled. Real control flow is constrained by returns, mutually exclusive predicates, state dependencies and loops. A branch count can draw attention to complexity, but it cannot replace analysis of reachable states and meaningful outcomes.
Tests should cover the equivalence classes and boundaries created by the predicates. For the health function, -1, 0, 1, 24 and 25 provide stronger evidence than five arbitrary positive values. They exercise both sides of each boundary and the ordering overlap. The ordinary value 80 remains useful, but it does not test the point at which behaviour changes.
Guard clauses can make the same decisions easier to inspect by disposing of failed preconditions early. They do not automatically remove testing obligations or prove equivalence to nested code. For a refactor, trace each original outcome and show that the same input reaches the same effect and return value afterwards. Reduced indentation is a benefit only when behaviour is preserved.
Programming Insight (AI): require the literal decision table
Ask an AI system to turn a branch sequence into a table containing each literal predicate, its evaluation order, boundary values and selected outcome. Reject a friendly paraphrase of what the function was probably meant to do. Add an overlap case, a case where no condition is true and any state that makes an operand unsafe. Then compare every row with the source. A polished summary can conceal an unreachable branch or a broader predicate placed too early.
Change the requirement, then earn the new branch
Extend the health requirement so that values above 100 are reported as invalid, while the existing three labels retain their present ranges. Before editing, write the complete decision table for -1, 0, 1, 24, 25, 100 and 101. Decide where the new predicate must appear and explain why a more general predicate cannot consume its state first.
After implementing the change, compile and run those boundary cases. Then replace the guard-clause version with one explicit if, else if, else chain and repeat the table. The task is not to prefer one shape. It is to demonstrate equivalent selection, evaluation order and returned labels. If one row changes, the refactor changed the program, however tidy it looks.
Reveal answer
The complete classification is: values above 100 are invalid, values at or below zero are defeated, values from 1 to 24 are critical, and values from 25 to 100 are active.
health | health > 100 | health <= 0 | health < 25 | Result |
|---|---|---|---|---|
| -1 | False | True | Not evaluated | defeated |
| 0 | False | True | Not evaluated | defeated |
| 1 | False | False | True | critical |
| 24 | False | False | True | critical |
| 25 | False | False | False | active |
| 100 | False | False | False | active |
| 101 | True | Not evaluated | Not evaluated | invalid |
A guard-clause implementation can make the new exceptional range visible first:
const char* describe_health(int health) {
if (health > 100) {
return "invalid";
}
if (health <= 0) {
return "defeated";
}
if (health < 25) {
return "critical";
}
return "active";
}
The new guard does not strictly have to be the first test because its range does not overlap the two existing guards. It must, however, appear before the general final return. If control reaches return "active" first, 101 is consumed by a broader default and can no longer be reported as invalid.
The explicit chain expresses the same ordered selection:
const char* describe_health(int health) {
if (health > 100) {
return "invalid";
} else if (health <= 0) {
return "defeated";
} else if (health < 25) {
return "critical";
} else {
return "active";
}
}
Running the seven boundary cases must reproduce the same table for both functions. That equality of every selected label, including the two edges at 100 and 101, is the evidence that the change of shape preserved the program.