Skip to main content
C++ beginner Lesson 6 of 23

Control Flow in C++

Master if/else, loops, if constexpr, range-for, structured bindings in loops, and std::optional.

if / else

The basic conditional in C++ works just like in most languages, but C++17 added a useful extension: you can declare a variable in the if condition itself (an init-statement), scoping it tightly to the if/else block. This prevents the variable from leaking into the surrounding scope where it no longer has meaning.

#include <iostream>
#include <map>
#include <string>

int main() {
    int score = 85;

    // Standard if/else
    if (score >= 90) {
        std::cout << "A\n";
    } else if (score >= 80) {
        std::cout << "B\n";
    } else if (score >= 70) {
        std::cout << "C\n";
    } else {
        std::cout << "F\n";
    }

    // C++17 init-statement: variable scoped to the if block only
    // 'it' doesn't pollute the surrounding scope
    std::map<std::string, int> config = {{"timeout", 30}, {"retries", 3}};

    if (auto it = config.find("timeout"); it != config.end()) {
        std::cout << "Timeout: " << it->second << "\n";
    }
    // 'it' is not accessible here — contained to the if/else scope

    // Combining with structured bindings (C++17) — both the iterator and insertion flag
    if (auto [it2, inserted] = config.emplace("max_conn", 100); inserted) {
        std::cout << "Added max_conn = " << it2->second << "\n";
    }
}

switch

switch dispatches on an integral or enum value. It is semantically clearer than a long if/else if chain when you are matching against a fixed set of known values, and the compiler can generate a jump table that is faster than a sequence of comparisons for large cases.

#include <iostream>

enum class Direction { North, South, East, West };

void move(Direction dir) {
    switch (dir) {
        case Direction::North:
            std::cout << "Moving north\n";
            break;
        case Direction::South:
            std::cout << "Moving south\n";
            break;
        case Direction::East:
        case Direction::West:
            std::cout << "Moving east or west\n";
            break;
        default:
            std::cout << "Unknown direction\n";
    }
}

// C++17: [[fallthrough]] makes intentional fallthrough explicit and silences warnings
void process_event(int event) {
    switch (event) {
        case 1:
            std::cout << "Pre-processing\n";
            [[fallthrough]];   // intentional: falls into case 2
        case 2:
            std::cout << "Processing event\n";
            break;
        case 3:
            std::cout << "Special event\n";
            break;
    }
}

Always include break unless you intentionally fall through, and mark intentional fallthrough with [[fallthrough]] to communicate intent and silence compiler warnings.


while and do-while

while and do-while are for loops where the number of iterations is not known in advance. The distinction between them is whether the condition is checked before or after the first iteration. The do-while guarantees the body executes at least once, making it the natural choice for “get input until valid” patterns.

#include <iostream>

// while: condition checked before each iteration — may never execute
int countdown = 5;
while (countdown > 0) {
    std::cout << countdown-- << " ";
}
std::cout << "\n";   // 5 4 3 2 1

// do-while: body executes at least once — condition checked after
int input;
do {
    std::cout << "Enter a positive number: ";
    std::cin >> input;
} while (input <= 0);

// Common pattern: process lines until EOF or sentinel
std::string line;
while (std::getline(std::cin, line)) {
    if (line == "quit") break;
    std::cout << "You said: " << line << "\n";
}

for Loop

The classic three-part for loop gives you full control over initialization, condition, and update step. It is the right tool when you need an index, when you are iterating over multiple sequences simultaneously, or when the step size is not 1.

#include <vector>
#include <iostream>

// Standard indexed loop
for (int i = 0; i < 10; ++i) {
    std::cout << i << " ";
}

// Multiple variables (same type) — two-pointer pattern
for (int i = 0, j = 9; i < j; ++i, --j) {
    std::cout << i << "," << j << " ";
}

// Iterating with index when you need both position and value
std::vector<std::string> names = {"Alice", "Bob", "Carol"};
for (size_t i = 0; i < names.size(); ++i) {
    std::cout << i << ": " << names[i] << "\n";
}

Range-Based for Loop

The range-for is the idiomatic way to iterate over any container or range in modern C++. It eliminates the boilerplate of begin()/end() calls and index management. The choice of n, n&, or const n& has a direct impact on both semantics and performance.

#include <vector>
#include <map>
#include <string>
#include <iostream>

std::vector<int> nums = {10, 20, 30, 40, 50};

// Copy — modifying 'n' does not change the vector, cheap for small types
for (int n : nums) {
    std::cout << n << " ";
}

// Reference — modifying 'n' changes the vector in-place
for (int& n : nums) {
    n *= 2;
}

// Const reference — efficient read-only access without copying large objects
for (const int& n : nums) {
    std::cout << n << " ";
}

// auto& is idiomatic for non-trivial element types — avoids an implicit copy
std::vector<std::string> words = {"hello", "world", "cpp"};
for (const auto& w : words) {
    std::cout << w << "\n";
}

// Map iteration with structured bindings (C++17) — far cleaner than .first/.second
std::map<std::string, int> scores = {{"Alice", 95}, {"Bob", 87}, {"Carol", 91}};
for (const auto& [name, score] : scores) {
    std::cout << name << ": " << score << "\n";
}
// Output (sorted by key):
// Alice: 95
// Bob: 87
// Carol: 91

if constexpr (C++17)

Regular if statements are evaluated at runtime — but even the “not taken” branch must compile for every type in a template. if constexpr evaluates the condition at compile time and removes the discarded branch entirely, so it can contain code that would be ill-formed for other types. This is essential in template programming and replaces much of the painful SFINAE machinery from older C++.

#include <iostream>
#include <string>
#include <type_traits>

// Without if constexpr, every branch must compile for every T — impossible here
template <typename T>
std::string describe(T value) {
    if constexpr (std::is_integral_v<T>) {
        // Only compiled when T is an integer — can safely call to_string(int)
        return "Integer: " + std::to_string(value);
    } else if constexpr (std::is_floating_point_v<T>) {
        return "Float: " + std::to_string(value);
    } else if constexpr (std::is_same_v<T, std::string>) {
        return "String: " + value;   // string concatenation — only valid for string
    } else {
        return "Unknown type";
    }
}

int main() {
    std::cout << describe(42)         << "\n";  // Integer: 42
    std::cout << describe(3.14)       << "\n";  // Float: 3.140000
    std::cout << describe(std::string{"hi"}) << "\n";  // String: hi
}

Contrast with a regular if: even the branch that is “not taken” at runtime must still compile for every type T. if constexpr removes that constraint entirely.


break, continue, and Early Return

These control flow tools help you write flat, readable code. The early return pattern (guard clauses) is particularly valuable: instead of nesting validation logic deeper and deeper, you handle each failure condition upfront and return immediately, keeping the happy path at the lowest indentation level.

#include <vector>
#include <iostream>

// break: exit the loop immediately when the condition is met
std::vector<int> data = {3, 7, 2, 9, 4, 6};
int target = 9;
int found_index = -1;

for (int i = 0; i < static_cast<int>(data.size()); ++i) {
    if (data[i] == target) {
        found_index = i;
        break;   // no need to check the rest — exit early
    }
}

// continue: skip the rest of this iteration and proceed to the next
for (int n : data) {
    if (n % 2 == 0) continue;   // skip even numbers
    std::cout << n << " ";      // prints 3 7 9
}

// Early return (guard clauses) — flat is better than nested
bool validate_user(const std::string& name, int age) {
    if (name.empty()) return false;          // handle error upfront
    if (age < 0 || age > 150) return false;  // handle error upfront
    if (name.length() > 100) return false;   // handle error upfront
    // ... happy path at minimal indentation
    return true;
}

The early return pattern (guard clauses) keeps functions flat and readable. Avoid deep nesting by handling error conditions upfront and returning early.


std::optional (C++17)

Many functions need to express “I might not have a result” — finding an element that might not exist, parsing input that might be invalid. The old approach was to use sentinel values like -1, nullptr, or "". The problem is these are conventions, not types: nothing in the type system stops a caller from forgetting to check. std::optional<T> makes the possibility of absence explicit and enforced.

#include <optional>
#include <string>
#include <map>
#include <iostream>

// Returns the user's score, or nothing if not found — the type tells the whole story
std::optional<int> find_score(const std::map<std::string, int>& db,
                               const std::string& user) {
    auto it = db.find(user);
    if (it == db.end()) return std::nullopt;  // no value
    return it->second;                         // has value
}

int main() {
    std::map<std::string, int> scores = {{"Alice", 95}, {"Bob", 87}};

    // Check and use
    if (auto score = find_score(scores, "Alice"); score.has_value()) {
        std::cout << "Alice: " << *score << "\n";       // dereference with *
        std::cout << "Alice: " << score.value() << "\n"; // .value() throws if empty
    }

    // Provide a default with value_or — concise one-liner
    int carol_score = find_score(scores, "Carol").value_or(0);
    std::cout << "Carol: " << carol_score << "\n";   // 0

    // optional in a loop — reads naturally
    std::vector<std::string> users = {"Alice", "Dave", "Bob", "Eve"};
    for (const auto& user : users) {
        if (auto s = find_score(scores, user)) {
            std::cout << user << " scored " << *s << "\n";
        } else {
            std::cout << user << " not found\n";
        }
    }
}

[[nodiscard]]

The [[nodiscard]] attribute causes a compiler warning if the return value of a function is silently discarded. It pairs naturally with functions returning std::optional or error codes, where ignoring the return value is almost certainly a bug.

#include <optional>
#include <fstream>

// Forgetting to check this return value is a bug — [[nodiscard]] makes that a warning
[[nodiscard]] std::optional<std::string> read_config(const std::string& path) {
    std::ifstream f(path);
    if (!f) return std::nullopt;
    std::string content((std::istreambuf_iterator<char>(f)),
                         std::istreambuf_iterator<char>());
    return content;
}

// warning: ignoring return value of function declared with 'nodiscard' attribute
read_config("app.cfg");           // compiler warns — you forgot to use the result

auto cfg = read_config("app.cfg");  // correct usage

Apply [[nodiscard]] to error codes, factory functions, and any function where the caller must act on the result.

Frequently Asked Questions

When should I use if constexpr vs regular if?
Use if constexpr in templates when branches involve types that won't compile for all template parameters. Regular if is fine otherwise.
What is std::optional?
A type that may or may not contain a value. Use it instead of returning -1 or nullptr as sentinel values.