Skip to main content
C++ · lesson 6 of 24
C++ beginner Lesson 6 of 24

Control Flow in C++

C++ control flow with real output: if with initializers, switch and [[fallthrough]], loops over stdin, if constexpr, break/continue, and std::optional.

By SkillByExample · Updated

if / else

if works as in most languages. C++17 adds an init-statement — if (init; condition) — that scopes a variable to the if/else alone.

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

char grade(int score) {
    if (score >= 90) return 'A';
    else if (score >= 80) return 'B';
    else if (score >= 70) return 'C';
    else return 'F';
}

int main() {
    for (int s : {95, 85, 70, 69}) std::cout << s << " -> " << grade(s) << "\n";

    std::map<std::string, int> config = {{"timeout", 30}, {"retries", 3}};
    if (auto it = config.find("timeout"); it != config.end())   // C++17 init-statement
        std::cout << "timeout: " << it->second << "\n";
    // 'it' does not exist here

    for (int attempt = 0; attempt < 2; ++attempt) {
        if (auto [pos, inserted] = config.emplace("max_conn", 100); inserted)
            std::cout << "added max_conn = " << pos->second << "\n";
        else
            std::cout << "max_conn already " << pos->second << "\n";
    }
}
$ g++ -std=c++20 -Wall -Wextra -o grades grades.cpp
$ ./grades
95 -> A
85 -> B
70 -> C
69 -> F
timeout: 30
added max_conn = 100
max_conn already 100

The else if chain checks top to bottom and stops at the first match, so 85 is a B even though it’s also >= 70. The iterator it exists only inside the if. The second loop reruns the same emplace: the first time inserted is true, the second time the key already exists, nothing is inserted, and pos points at the existing entry.

switch

switch dispatches on an integer or enum value. Cases fall through into the next one unless you break.

#include <iostream>

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

void move(Direction dir) {
    switch (dir) {
        case Direction::North: std::cout << "north\n"; break;
        case Direction::South: std::cout << "south\n"; break;
        case Direction::East:                     // two labels, one body
        case Direction::West:  std::cout << "east or west\n"; break;
    }
}

void process_event(int event) {
    std::cout << "event " << event << ":";
    switch (event) {
        case 1:
            std::cout << " pre-process";
            [[fallthrough]];                      // intentional: run case 2 too
        case 2:
            std::cout << " process";
            break;
        default:
            std::cout << " ignored";
    }
    std::cout << "\n";
}

int main() {
    move(Direction::North);
    move(Direction::West);
    for (int e : {1, 2, 3}) process_event(e);
}
$ g++ -std=c++20 -Wall -Wextra -o switch switch.cpp
$ ./switch
north
east or west
event 1: pre-process process
event 2: process
event 3: ignored

East and West share one body by stacking labels. Event 1 prints both “pre-process” and “process” because [[fallthrough]] lets execution continue into case 2; event 3 matches no case and hits default. move has no default, deliberately — every Direction is handled, so -Wall would warn if someone added a fifth enumerator.

Forget a break without the attribute and -Wextra catches it:

#include <iostream>

int main() {
    int level = 1;
    switch (level) {
        case 1:
            std::cout << "debug\n";     // forgot break
        case 2:
            std::cout << "info\n";
            break;
    }
}
$ g++ -std=c++20 -Wall -Wextra -o forgot_break forgot_break.cpp
forgot_break.cpp: In function 'int main()':
forgot_break.cpp:7:26: warning: this statement may fall through [-Wimplicit-fallthrough=]
    7 |             std::cout << "debug\n";     // forgot break
      |                          ^~~~~~~~~
forgot_break.cpp:8:9: note: here
    8 |         case 2:
      |         ^~~~
$ ./forgot_break
debug
info

The program still compiles and runs, printing both lines — the warning is the only thing standing between you and the bug.

while and do-while

while checks before each pass; do-while checks after, so its body runs at least once. Here the program reads from a pipe instead of the keyboard.

#include <iostream>
#include <string>

int main() {
    int countdown = 3;
    while (countdown > 0) std::cout << countdown-- << " ";
    std::cout << "liftoff\n";

    int input;
    do {                                       // body runs at least once
        std::cout << "positive number? ";
        std::cin >> input;
        std::cout << "got " << input << "\n";
    } while (input <= 0);

    std::string line;
    std::getline(std::cin, line);              // eat the rest of the number's line
    while (std::getline(std::cin, line)) {     // stops at end of input
        if (line == "quit") break;
        std::cout << "you said: " << line << "\n";
    }
}
$ g++ -std=c++20 -Wall -Wextra -o loops loops.cpp
$ printf '%s\n' -5 0 7 hello quit ignored | ./loops
3 2 1 liftoff
positive number? got -5
positive number? got 0
positive number? got 7
you said: hello

The prompt appears three times because do-while keeps asking until the number is positive. The typed input doesn’t echo because it came from printf, not a terminal. Two details matter: after std::cin >> input the newline is still in the buffer, so one getline discards it — without that, the first line read would be empty. And break on quit means ignored is never read; if the input ran out first, getline would return false and end the loop just the same.

for Loops

The three-part for gives full control over the index; the range-for iterates a container directly.

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

int main() {
    for (int i = 0; i < 5; ++i) std::cout << i << " ";
    std::cout << "\n";

    for (int i = 0, j = 9; i < j; i += 2, j -= 2)     // two indices, moving inward
        std::cout << "(" << i << "," << j << ") ";
    std::cout << "\n";

    std::vector<std::string> names = {"Alice", "Bob", "Carol"};
    for (std::size_t i = 0; i < names.size(); ++i)
        std::cout << i << ": " << names[i] << "\n";

    std::vector<int> nums = {10, 20, 30};
    for (int n : nums) n += 1;                         // copy: no effect
    for (int& n : nums) n *= 2;                        // reference: modifies
    for (const int& n : nums) std::cout << n << " ";   // read-only, no copy
    std::cout << "\n";

    for (int i = 3; i >= 0; --i) std::cout << i << " ";  // counting down with int
    std::cout << "\n";
}
$ g++ -std=c++20 -Wall -Wextra -o for_loops for_loops.cpp
$ ./for_loops
0 1 2 3 4 
(0,9) (2,7) (4,5) 
0: Alice
1: Bob
2: Carol
20 40 60 
3 2 1 0 

The comma operator lets one for update two indices. In the range-fors, int n is a copy (the += 1 did nothing), int& n modifies in place (10, 20, 30 doubled), and const int& reads without copying — the default for anything bigger than a number.

The counting-down loop used a signed int. With std::size_t it breaks:

#include <iostream>
#include <vector>

int main() {
    std::vector<int> v = {1, 2, 3};
    int printed = 0;
    for (std::size_t i = v.size() - 1; i >= 0; --i) {   // i >= 0 is always true
        std::cout << "i = " << i << "\n";
        if (++printed == 5) break;                      // safety valve for the demo
    }
}
$ g++ -std=c++20 -Wall -Wextra -o unsigned_down unsigned_down.cpp
unsigned_down.cpp: In function 'int main()':
unsigned_down.cpp:7:42: warning: comparison of unsigned expression in '>= 0' is always true [-Wtype-limits]
    7 |     for (std::size_t i = v.size() - 1; i >= 0; --i) {   // i >= 0 is always true
      |                                        ~~^~~~
$ ./unsigned_down
i = 2
i = 1
i = 0
i = 18446744073709551615
i = 18446744073709551614

i >= 0 is always true for an unsigned type, and GCC says so. After i = 0, --i wraps to 18446744073709551615 and the loop would run (and index out of bounds) forever; the break after five lines is only there to stop the demo.

if constexpr (C++17)

In a template, both branches of a regular if must compile for every T. if constexpr discards the branch that doesn’t apply at compile time.

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

template <typename T>
std::string describe(T value) {
    if constexpr (std::is_integral_v<T>)
        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;          // to_string(value) wouldn't compile here
    else
        return "unknown type";
}

int main() {
    std::cout << describe(42) << "\n";
    std::cout << describe(3.14) << "\n";
    std::cout << describe(std::string{"hi"}) << "\n";
    std::cout << describe("hi") << "\n";    // const char*, not std::string
    std::cout << describe('x') << "\n";     // char is an integral type
}
$ g++ -std=c++20 -Wall -Wextra -o describe describe.cpp
$ ./describe
integer: 42
float: 3.140000
string: hi
unknown type
integer: 120

std::to_string(value) would not compile for a std::string, and "string: " + value would not compile for an int — but each only exists in the instantiation where it’s valid. The last two lines are the surprises: "hi" is a const char*, not a std::string, so it falls to “unknown type”; and char is an integral type, so 'x' prints as its character code, 120.

break, continue and Early Return

break leaves the loop, continue skips to the next iteration, and guard clauses return before the happy path.

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

bool valid_user(const std::string& name, int age) {
    if (name.empty()) return false;              // guard clauses: fail fast,
    if (age < 0 || age > 150) return false;      // keep the happy path flat
    return true;
}

int main() {
    std::vector<int> data = {3, 7, 2, 9, 4, 9};
    int found = -1;
    for (int i = 0; i < static_cast<int>(data.size()); ++i) {
        if (data[i] == 9) { found = i; break; }  // first match only
    }
    std::cout << "first 9 at index " << found << "\n";

    for (int n : data) {
        if (n % 2 == 0) continue;                // skip evens
        std::cout << n << " ";
    }
    std::cout << "\n" << std::boolalpha
              << valid_user("ada", 36) << " " << valid_user("", 36) << " "
              << valid_user("bob", 200) << "\n";
}
$ g++ -std=c++20 -Wall -Wextra -o jumps jumps.cpp
$ ./jumps
first 9 at index 3
3 7 9 9 
true false false

break stops at the first 9 (index 3) even though there’s another at index 5. continue skips 2 and 4, so both 9s print. valid_user rejects the empty name and the age of 200 in one-line guards, keeping the success path at the bottom without nesting.

std::optional (C++17)

std::optional<T> makes “maybe no result” part of the type, instead of a sentinel like -1.

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

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;
    return it->second;
}

int main() {
    std::map<std::string, int> scores = {{"Alice", 95}, {"Bob", 0}};
    for (const auto& user : {"Alice", "Bob", "Dave"}) {
        if (auto s = find_score(scores, user))       // true if it holds a value
            std::cout << user << " scored " << *s << "\n";
        else
            std::cout << user << " not found\n";
    }
    std::cout << "Carol: " << find_score(scores, "Carol").value_or(-1) << "\n";
    try {
        find_score(scores, "Carol").value();         // empty: throws
    } catch (const std::bad_optional_access& e) {
        std::cout << "value() threw: " << e.what() << "\n";
    }
}
$ g++ -std=c++20 -Wall -Wextra -o optional optional.cpp
$ ./optional
Alice scored 95
Bob scored 0
Dave not found
Carol: -1
value() threw: bad optional access

Bob’s score is 0 but he’s still found — an optional distinguishes “present and zero” from “absent”, which a sentinel of 0 or -1 can’t. if (auto s = ...) tests for presence and *s reads the value; value_or(-1) supplies a default. Calling value() on an empty optional throws std::bad_optional_access, while *s on an empty one is undefined behavior — so dereference only after checking.

[[nodiscard]]

[[nodiscard]] makes the compiler warn when a caller ignores a return value.

#include <optional>
#include <string>

[[nodiscard]] std::optional<std::string> read_config(const std::string& path) {
    if (path.empty()) return std::nullopt;
    return "port=8080";
}

int main() {
    read_config("app.cfg");               // result dropped on the floor
    auto cfg = read_config("app.cfg");    // fine
    return cfg ? 0 : 1;
}
$ g++ -std=c++20 -Wall -Wextra -o nodiscard nodiscard.cpp 2>&1 | grep warning
nodiscard.cpp:10:27: warning: ignoring return value of 'std::optional<std::__cxx11::basic_string<char> > read_config(const std::string&)', declared with attribute 'nodiscard' [-Wunused-result]

Only the first call warns; the second uses the result. Put [[nodiscard]] on functions whose return value is the whole point — optionals, error codes, factory functions.

Practice

  1. Rewrite grade with a switch on score / 10, using stacked labels for 10 and 9.

    Solution
    char grade(int score) {
        switch (score / 10) {
            case 10:
            case 9:  return 'A';
            case 8:  return 'B';
            case 7:  return 'C';
            default: return 'F';
        }
    }

    Integer division maps 95 to 9, 85 to 8 and 69 to 6. A return ends the case, so no break is needed.

  2. Fix unsigned_down.cpp so it prints 2, 1, 0 while keeping std::size_t.

    Solution
    for (std::size_t i = v.size(); i-- > 0;)
        std::cout << "i = " << i << "\n";

    The test i-- > 0 compares first and decrements second, so the body sees 2, 1, 0 and the loop stops when i was 0 — it never needs a negative value.

  3. Remove the extra std::getline in loops.cpp and run it with the same input. What changes?

    Solution

    An extra you said: line with nothing after it appears first. cin >> input left the newline after 7 in the buffer, and the first getline reads that empty remainder of the line.

  4. Add a bool overload to describe so describe(true) prints bool: true instead of integer: 1.

    Solution
    if constexpr (std::is_same_v<T, bool>)
        return std::string("bool: ") + (value ? "true" : "false");
    else if constexpr (std::is_integral_v<T>)
        return "integer: " + std::to_string(value);

    bool is an integral type, so the bool check must come before the integral one.

  5. Write std::optional<int> parse_int(const std::string& s) that returns std::nullopt instead of throwing when std::stoi fails.

    Solution
    std::optional<int> parse_int(const std::string& s) {
        try {
            std::size_t used = 0;
            int n = std::stoi(s, &used);
            if (used != s.size()) return std::nullopt;   // trailing junk like "42abc"
            return n;
        } catch (const std::exception&) {
            return std::nullopt;
        }
    }

    Checking used also rejects "42abc", which plain stoi would accept as 42.

Frequently Asked Questions

When should I use if constexpr instead of a regular if?
Use if constexpr inside templates when a branch contains code that only compiles for some types, such as calling std::to_string on a number. The discarded branch is never instantiated. Outside templates, a regular if is the right tool.
What is std::optional used for?
std::optional<T> represents a value that may be absent, replacing sentinel values like -1, nullptr or an empty string. The caller must check it (if (opt), has_value() or value_or) before using the value, and value() throws std::bad_optional_access when it is empty.
What does [[fallthrough]] do in a switch?
It marks that execution is meant to continue from one case into the next. With -Wextra, GCC warns about any case that falls through without it, which catches the common forgotten-break bug while letting deliberate fallthrough compile quietly.
Why does my loop over an unsigned index never end?
An unsigned value is never negative, so a condition like i >= 0 is always true; decrementing past zero wraps to the maximum value instead. Count down with a signed index, or loop with for (std::size_t i = n; i-- > 0;).
What does the C++17 if init-statement do?
if (init; condition) declares a variable scoped to the if and its else branches, such as an iterator from map::find. It keeps a variable that is only meaningful to the check from leaking into the rest of the function.