Chapter 6: Control Flow
Control flow is how a program decides what to do next. C++ provides selection with if and switch, repetition with while, do while, and for, modern iteration with range for, and features such as initialization statements, structured bindings, and short circuiting. This chapter explores the core building blocks and the practical choices that help you write readable code that behaves correctly.
if, switch, while, do while, and for
These five constructs are the everyday tools for branching and looping. Choosing which one to use depends on whether you need a simple conditional, a multiway selection, an entry controlled loop, an exit controlled loop, or a counted loop. Start with the form that communicates intent most clearly, then refine for correctness and performance.
Using if for binary choices
Use if when you have a true or false condition and at most a small number of alternatives. Keep the condition simple; extract complex checks to well named functions for clarity.
int score = get_score();
if (score >= 50) std::cout << "Pass\n";
else std::cout << "Retry\n";
Using switch for multiway selection
switch selects among discrete integral or enumeration values. With modern compilers it often generates a jump table or similar efficient code. Remember that cases fall through unless you terminate with break or another jump.
enum class token { add, sub, mul, div, other };
token t = next_token();
switch (t) {
case token::add: std::cout << "+"; break;
case token::sub: std::cout << "-"; break;
case token::mul: std::cout << "*"; break;
case token::div: std::cout << "/"; break;
default: std::cout << "?"; break;
}
switch. Strings and floating point values are not permitted.
Entry controlled loops with while
while checks the condition before each iteration. Use it when the number of iterations is not known in advance and the loop may not run at all.
std::string line;
while (std::getline(std::cin, line)) {
if (line.empty()) break;
process(line);
}
Exit controlled loops with do while
do while guarantees at least one iteration, because the condition is checked after the body. It suits menu loops and read until patterns where a first action must occur.
int choice;
do {
show_menu();
choice = read_choice();
handle(choice);
} while (choice != 0);
Counted loops with for
for is ideal for a known range of integers or for iterators with a clear start, condition, and step. Keep all loop control in the header so the body stays focused on work.
for (int i = 0; i < 10; ++i) {
std::cout << i << " ";
}
Choosing among control structures
Pick the construct that communicates intent: if for simple branching; switch for many discrete options; while when zero iterations are possible; do while when at least one iteration is required; classic for for counted or iterator based loops.
| Goal | Best fit | Notes |
| Binary condition | if | Short and direct |
| Many discrete cases | switch | Enumerations read well |
| Unknown count, may be zero | while | Condition first |
| Unknown count, at least one | do while | Condition last |
| Known range or iterator walk | for | All control in header |
Range for and structured bindings
The range for loops over elements of a container or range by calling begin() and end(). Combine it with structured bindings to unpack tuple like elements, map entries, or custom types that expose get or tie support.
Looping by value, reference, and const reference
Choose how elements are viewed. By value copies each element; by reference allows modification; by const reference avoids copies and prevents edits. Prefer const reference for large objects when you only read values.
std::vector<std::string> names = {"Ada","Bjarne","Grace"};
for (const std::string& s : names) std::cout << s << "\n";
for (std::string& s : names) s += "!";
for (auto s : names) use_copy(s);
Structured bindings with pairs and maps
Structured bindings split an aggregate into named variables. This is valuable with associative containers where each element is a std::pair<const Key, T>. Use descriptive names that clarify intent.
std::map<std::string, int> freq = {{ "red", 3 }, { "blue", 5 }};
for (auto& [color, count] : freq) {
std::cout << color << ": " << count << "\n";
}
Unpacking tuples and custom types
Types that support tuple like access can be unpacked. For custom structs, add get functions and specialize std::tuple_size and std::tuple_element when appropriate, or simply rely on public data members.
struct point { double x; double y; };
std::vector<point> pts = {{1.0,2.0},{3.0,4.0}};
for (auto [x, y] : pts) {
std::cout << "(" << x << "," << y << ")\n";
}
auto& in structured bindings when you intend to modify elements inside a container.
Initialization statements in if and switch
An initialization statement allows you to declare and initialize a variable that is scoped to the condition and the related body. This reduces scope creep and keeps temporary values near their use.
Initializing inside if
Use this to bind the result of a lookup or parse operation, then test it. The variable remains visible in the if body and the corresponding else body, but not after the statement ends.
if (auto it = dict.find(key); it != dict.end()) {
use(it->second);
} else {
log_missing(key);
}
Initializing inside switch
This form suits computations that feed a switch. The initialized variable is available to all case labels inside the statement and remains hidden from surrounding code.
switch (int r = std::clamp(value, 0, 100); r / 10) {
case 10: case 9: std::cout << "A\n"; break;
case 8: std::cout << "B\n"; break;
case 7: std::cout << "C\n"; break;
default: std::cout << "D or lower\n"; break;
}
goto, labels, and when not to use them
goto jumps to a labeled statement within the same function. It can exit multiple nested blocks quickly, yet it harms readability and makes resource handling fragile. Prefer structured control flow and RAII; reserve goto for rare, tightly controlled cases such as breaking out of deeply nested error checks in low level code where other refactors are not possible.
Why goto is risky
Jumps bypass constructors and destructors in the skipped statements, which makes reasoning about object lifetime harder. Although destructors for fully constructed automatic objects still run when leaving scope, scattered labels and jumps often lead to subtle bugs and tangled logic.
Alternatives that read better
Replace goto with early returns, break or continue, function extraction, or a boolean flag that signals completion. These options keep the flow local and clearer.
for (auto& row : grid) {
bool found = false;
for (auto& cell : row) {
if (cell == target) { found = true; break; }
}
if (found) break;
}
Short circuiting and lazy evaluation
Logical && and || operators evaluate from left to right and stop as soon as the result is known. This behavior is called short circuiting. It allows you to guard operations that must only run when earlier checks pass, and it avoids unnecessary work.
Guarding with &&
With &&, the right operand evaluates only when the left operand is true. Use this to perform a follow up action that depends on the first condition.
ptr && ptr->do_work();
In this example, do_work() is called only when ptr is non null. This reads compactly and avoids an extra if block when the action is a single expression.
Fallbacks with ||
With ||, the right operand evaluates only when the left operand is false. This is handy for defaulting and for simple validation with a single failure handler.
valid(input) || throw std::runtime_error("invalid input");
Beware of hidden work in conditions
Keep conditions free of surprising side effects. A function that both checks and mutates state can make short circuiting hard to reason about. Prefer pure checks in the left operand and simple actions in the right operand.
Lazy views and generators
Short circuiting composes nicely with lazy ranges. When you chain filters and transforms, evaluation proceeds only as far as required to produce the next element. This can cut large problems down to just in time work.
#include <ranges>
#include <vector>
std::vector<int> v = { 1, 2, 3, 4, 5, 6 };
auto evens = v | std::views::filter([](int x){ return x % 2 == 0; })
| std::views::transform([](int x){ return x * x; });
for (int x : evens) {
if (x > 10) break;
std::cout << x << " ";
}
Only the elements required before the break are computed. This is both efficient and expressive when paired with clear predicates.