Skip to content

Control Flow & Loops

Master all C++ control flow constructs including if/else, switch, all loop forms, range-based for, C++17 initializers, and structured bindings.

Control Flow in C++

Control flow statements determine the order in which your program executes. C++ provides a rich set of control flow constructs that go well beyond the basics inherited from C. Modern C++ (C++17 and beyond) adds features like if with initializers and constexpr if that make control flow more expressive and safer.

A key principle: minimize the scope of variables. Every variable should be declared as close to its first use as possible, and in the narrowest scope possible. C++17 features like if with initializers help you follow this principle.

if/else and switch

The if and switch statements are the primary branching mechanisms. C++17 adds initializer clauses to both:

control_flow.cpp
#include <iostream>
#include <map>
#include <string>
#include <optional>

std::optional<int> find_value(const std::string& key) {
    std::map<std::string, int> data = {{"alpha", 1}, {"beta", 2}, {"gamma", 3}};
    if (auto it = data.find(key); it != data.end()) {  // C++17 if with initializer
        return it->second;
    }
    return std::nullopt;
}

int main() {
    // C++17: if with initializer — the variable's scope is limited to the if/else
    if (auto val = find_value("beta"); val.has_value()) {
        std::cout << "Found: " << *val << '\n';
    } else {
        std::cout << "Not found\n";
    }
    // 'val' is no longer in scope here — cleaner!

    // switch with C++17 initializer
    enum class Color { Red, Green, Blue };
    Color c = Color::Green;

    switch (auto name = "unknown"; c) {  // C++17 switch with initializer
        case Color::Red:   name = "red";   std::cout << name << '\n'; break;
        case Color::Green: name = "green"; std::cout << name << '\n'; break;
        case Color::Blue:  name = "blue";  std::cout << name << '\n'; break;
    }

    // [[fallthrough]] attribute (C++17) — document intentional fallthrough
    int priority = 2;
    switch (priority) {
        case 1:
            std::cout << "Critical: ";
            [[fallthrough]];       // Tells the compiler this is intentional
        case 2:
            std::cout << "Important: ";
            [[fallthrough]];
        case 3:
            std::cout << "Handle it\n";
            break;
        default:
            std::cout << "Unknown priority\n";
    }
}

Loops: for, while, do-while, and range-based for

C++ offers multiple loop forms. The range-based for loop (C++11) is preferred when iterating over containers because it eliminates off-by-one errors and is clearer:

loops.cpp
#include <iostream>
#include <vector>
#include <string>
#include <map>

int main() {
    std::vector<std::string> names = {"Alice", "Bob", "Charlie"};

    // Traditional for loop — use when you need the index
    for (std::size_t i = 0; i < names.size(); ++i) {
        std::cout << i << ": " << names[i] << '\n';
    }

    // Range-based for loop (C++11) — preferred for iteration
    for (const auto& name : names) {        // const auto& avoids copying
        std::cout << name << '\n';
    }

    // Range-based for with structured bindings (C++17)
    std::map<std::string, int> scores = {{"Alice", 95}, {"Bob", 87}, {"Charlie", 92}};
    for (const auto& [name, score] : scores) {   // Destructure the pair
        std::cout << name << " scored " << score << '\n';
    }

    // C++20: range-based for with init-statement
    for (auto v = std::vector{1, 2, 3, 4, 5}; auto elem : v) {
        std::cout << elem << ' ';
    }
    std::cout << '\n';

    // while loop — use when the number of iterations is unknown
    int n = 1;
    while (n <= 1024) {
        std::cout << n << ' ';
        n *= 2;
    }
    std::cout << '\n';

    // do-while — body executes at least once
    int input;
    do {
        std::cout << "Enter a positive number: ";
        input = 42;  // Simulated input for example
    } while (input <= 0);
}

Structured Bindings (C++17)

Structured bindings (C++17) allow you to decompose an object into its constituent parts. They work with arrays, structs with all public members, std::pair, std::tuple, and any type that supports the structured bindings protocol.

The syntax auto [a, b] = expr; creates named references to the components of expr. You can add const and & qualifiers: const auto& [a, b] = expr; creates const references without copying.

Structured bindings are especially powerful with std::map iteration (decomposing std::pair), function returns that use std::pair or std::tuple, and the insert method's return value on associative containers.

Best Practice

Use break to exit a loop early when you have found what you need. Use continue to skip the rest of the current iteration and move to the next one. Both make loops easier to understand when used judiciously.

Prefer range-based for over index-based for when you do not need the index. It eliminates entire classes of bugs: off-by-one errors, using the wrong variable as index, and signed/unsigned comparison warnings.

When you do need the index, consider using std::size_t as the loop variable type to match the return type of .size(), or use std::ssize() (C++20) which returns a signed size.

Avoid deeply nested loops — if you find yourself nesting three or more loops, extract the inner loops into functions. This improves readability and testability.

  • Prefer range-based for loops over index-based loops when the index is not needed
  • Use const auto& in range-based for to avoid unnecessary copies
  • Use structured bindings to decompose pairs and tuples in loop variables
  • Extract deeply nested loops into named functions for clarity

constexpr if (C++17): Compile-Time Branching

if constexpr evaluates the condition at compile time and discards the branch that is not taken. This is essential in template programming where different branches may not even be valid for all types:

constexpr_if.cpp
#include <iostream>
#include <type_traits>
#include <string>

template <typename T>
void describe(T value) {
    std::cout << "Value: " << value;

    if constexpr (std::is_integral_v<T>) {
        // This branch only compiles when T is an integer type
        std::cout << " (integer, " << (value % 2 == 0 ? "even" : "odd") << ")";
    } else if constexpr (std::is_floating_point_v<T>) {
        // This branch only compiles when T is a floating-point type
        std::cout << " (floating-point)";
    } else {
        std::cout << " (other type)";
    }
    std::cout << '\n';
}

int main() {
    describe(42);           // Value: 42 (integer, even)
    describe(3.14);         // Value: 3.14 (floating-point)
    describe("hello");      // Value: hello (other type)
}
Key Takeaways
  • C++17 if/switch with initializers limit variable scope to the statement — use them to keep code clean
  • Range-based for loops eliminate off-by-one errors and signed/unsigned comparison issues
  • Structured bindings (C++17) decompose pairs, tuples, and structs into named variables
  • Use [[fallthrough]] to document intentional switch case fallthrough
  • if constexpr (C++17) evaluates conditions at compile time — the discarded branch is not compiled
  • Minimize variable scope: declare variables as close to first use as possible

Quiz — Test Your Knowledge

(15 XP)

1. What is the advantage of C++17's `if` with initializer: `if (auto x = f(); x > 0)`?

2. What does `for (const auto& [key, val] : my_map)` do?

3. What happens to the discarded branch of an `if constexpr` statement?