<< Home

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";
πŸ’‘ Prefer braces even for single statements in real projects; this prevents accidental errors when adding lines later.

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;
}
⚠️ Only integral types, scoped or unscoped enums, and a few related categories are valid for 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.

GoalBest fitNotes
Binary conditionifShort and direct
Many discrete casesswitchEnumerations read well
Unknown count, may be zerowhileCondition first
Unknown count, at least onedo whileCondition last
Known range or iterator walkforAll 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";
}
πŸ’‘ Use 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;
}
⚠️ Avoid heavy work inside the initialization if not required. Keep it cheap and obvious; move complex logic to a helper function that returns the needed value.

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;
}
πŸ’‘ If you feel tempted to add a label, consider extracting the loop body into a helper function that returns on success or failure. Clear names beat jumps.

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.