C++Foundations

C++ Control Flow: Structured Bindings, if-init, switch, Ranges & Modern Iteration (C++23)

TT
TopicTrick Team
C++ Control Flow: Structured Bindings, if-init, switch, Ranges & Modern Iteration (C++23)

C++ Control Flow: Structured Bindings, if-init, switch, Ranges & Modern Iteration (C++23)


Table of Contents


Structured Bindings: Unpacking Data Elegantly

Structured bindings (C++17) decompose composite objects into named variables — eliminating verbose .first/.second access and manual tuple index subscripting:

cpp
#include <map>
#include <tuple>
#include <string>
#include <utility>
#include <iostream>

int main() {
    // === 1. Decompose std::pair ===
    std::pair<std::string, int> p = {"temperature", 98};
    auto [metric, value] = p;  // metric="temperature", value=98
    std::cout << metric << ": " << value << '\n';
    
    // === 2. Decompose std::tuple ===
    auto stats = std::make_tuple(42.5, 10, 99.9);
    auto [mean, count, max_val] = stats;
    
    // === 3. Iterate map without .first/.second ===
    std::map<std::string, int> inventory = {
        {"sword", 3}, {"shield", 1}, {"potion", 15}
    };
    
    // OLD style (verbose, error-prone):
    for (auto& item : inventory) {
        std::cout << item.first << ": " << item.second << '\n';
    }
    
    // MODERN style (clear, self-documenting):
    for (const auto& [item_name, quantity] : inventory) {
        std::cout << item_name << ": " << quantity << '\n';
    }
    
    // === 4. Decompose structs ===
    struct Point { double x, y, z; };
    Point origin{0.0, 0.0, 0.0};
    auto [x, y, z] = origin;
    
    // === 5. Bindings to references — can modify original ===
    auto& [sx, sy, sz] = origin;  // References to members
    sx = 1.0;  // Modifies origin.x directly
    
    return 0;
}

Performance note: Structured bindings are zero-cost — they're a syntactic decomposition compiled away completely. The generated assembly is identical to manual .first/.second access.


Structured Bindings with Custom Types

Make your own types work with structured bindings by specializing std::tuple_size, std::tuple_element, and providing a get<N> function:

cpp
#include <tuple>

class RGB {
public:
    uint8_t r, g, b;
    explicit RGB(uint8_t r, uint8_t g, uint8_t b) : r(r), g(g), b(b) {}
    
    template<size_t I>
    auto get() const {
        if constexpr (I == 0) return r;
        if constexpr (I == 1) return g;
        if constexpr (I == 2) return b;
    }
};

// Required specializations in std namespace
namespace std {
    template<> struct tuple_size<RGB> : integral_constant<size_t, 3> {};
    template<size_t I> struct tuple_element<I, RGB> {
        using type = uint8_t;
    };
}

// Now RGB works with structured bindings:
RGB red{255, 0, 0};
auto [r, g, b] = red;  // r=255, g=0, b=0

if with Initializer (C++17)

Variable scope leak is a common source of bugs: a variable declared before an if statement exists throughout the entire enclosing scope even though it's only used in the if block. C++17 solves this:

cpp
#include <map>
#include <optional>

std::map<int, std::string> user_db;

// OLD: name leaks into outer scope
auto it = user_db.find(42);
if (it != user_db.end()) {
    process(it->second);
}
// 'it' still exists here — potential misuse!

// MODERN C++17: name confined to if-else block
if (auto it = user_db.find(42); it != user_db.end()) {
    process(it->second);
} else {
    // 'it' is still accessible in else — still in the if scope
    log_not_found(it); // it == user_db.end()
}
// 'it' does NOT exist here — tight scoping

// Works with std::optional too
std::optional<std::string> opt_val = try_parse("42");
if (auto val = try_parse(input); val.has_value()) {
    use(*val);
} // val not accessible here

// Works with mutex locking (lock is released when if block ends):
if (auto lock = std::unique_lock(mutex, std::try_to_lock); lock.owns_lock()) {
    modify_shared_resource();
} // Mutex released here, whether success or failure

switch with Initializer (C++17)

cpp
enum class ConnectionState { Connecting, Active, Closed, Error };

// OLD — connectionState leaks outside switch
ConnectionState get_state();
auto state = get_state();
switch (state) {
    case ConnectionState::Active: handle_data(); break;
    default: disconnect();
}
// 'state' still alive here — unnecessary

// MODERN — state scoped to switch block
switch (auto state = get_state(); state) {
    case ConnectionState::Connecting:
        start_timeout_timer();
        break;
    case ConnectionState::Active:
        handle_data();
        break;
    case ConnectionState::Closed:
    case ConnectionState::Error:
        disconnect();
        break;
    // GCC -Wswitch warns if enum values are missing!
}

Scoped Enums (enum class) and Exhaustiveness

C-style enum values leak their names into the enclosing scope and implicitly convert to integers. enum class fixes both:

cpp
// OLD C-style enum — dangerous
enum Direction { North, South, East, West }; // North leaks into global scope
int n = North; // Implicit int conversion — this should NOT be allowed

// MODERN scoped enum
enum class Direction { North, South, East, West };
// Direction::North is the only access path — no name leaking
// int n = Direction::North; // ERROR: no implicit conversion

Direction d = Direction::East;
// switch with exhaustiveness checking (with -Wswitch):
switch (d) {
    case Direction::North: move(0, 1);  break;
    case Direction::South: move(0, -1); break;
    case Direction::East:  move(1, 0);  break;
    // WARNING: 'West' not handled!
}

// Underlying type (useful for bit flags or serialization):
enum class Permissions : uint8_t {
    None    = 0,
    Read    = 1 << 0,
    Write   = 1 << 1,
    Execute = 1 << 2,
};

// Enable bitwise operations on enum class:
Permissions p = Permissions::Read | Permissions::Write; // via operator overload

Range-Based For: Deep Dive

Range-based for loops work with any type that has begin() and end() iterators — including custom containers:

cpp
#include <vector>
#include <ranges>

std::vector<int> nums = {5, 3, 8, 1, 9, 2};

// Read-only — use const auto&
for (const auto& n : nums) { /* n is const ref — no copy */ }

// Modify in place — use auto&
for (auto& n : nums) { n *= 2; }

// Copy each element — use auto (rare, usually a mistake for large objects)
for (auto n : nums) { /* n is copy */ }

// Reverse iteration (C++20 std::views::reverse)
#include <ranges>
for (const auto& n : nums | std::views::reverse) {
    std::cout << n << ' ';
}

// Enumerate with index (C++23 std::views::enumerate)
for (auto [i, n] : nums | std::views::enumerate) {
    std::cout << i << ": " << n << '\n';  // index + value
}

// Filter and transform pipeline (C++20 ranges)
auto even_squares = nums
    | std::views::filter([](int n){ return n % 2 == 0; })
    | std::views::transform([](int n){ return n * n; });

for (int x : even_squares) { std::cout << x << ' '; }
// Lazy — no intermediate vector created!

The Spaceship Operator <=> and Three-Way Comparison

Before C++20, providing full ordering for a type required six operators: <, >, <=, >=, ==, !=. The spaceship operator generates all of them from one declaration:

cpp
#include <compare>

struct Version {
    int major, minor, patch;
    
    // C++20: One declaration generates all six comparison operators
    auto operator<=>(const Version&) const = default;
    // Also generates: ==, !=, <, >, <=, >=
};

Version v1{1, 2, 3}, v2{1, 3, 0};
if (`v1 < v2`) std::cout << "v1 is older\n";  // Works via `<=>`
if (v1 == v2) std::cout << "same version\n";

// Spaceship returns a comparison category:
// std::strong_ordering  — total order (int, float, custom)
// std::weak_ordering    — some elements may be equivalent
// std::partial_ordering — some elements incomparable (NaN)
auto result = `v1 <=> v2`;      //  std::strong_ordering::less
bool is_less = (result < 0);  // true

std::variant and std::visit: Type-Safe Unions

std::variant is a type-safe union — holds exactly one of a set of types at runtime. std::visit dispatches based on the held type:

cpp
#include <variant>
#include <string>
#include <iostream>

using Token = std::variant<int, double, std::string, bool>;

// Visitor with overloaded lambdas (C++17 pattern)
struct Printer {
    void operator()(int n)               { std::cout << "Int: " << n << '\n'; }
    void operator()(double d)            { std::cout << "Double: " << d << '\n'; }
    void operator()(const std::string& s){ std::cout << "String: " << s << '\n'; }
    void operator()(bool b)              { std::cout << "Bool: " << b << '\n'; }
};

// Overloaded helper (C++17)
template<class... Ts> struct overloaded : Ts... { using Ts::operator()...; };
template<class... Ts> overloaded(Ts...) -> overloaded<Ts...>;

int main() {
    Token t1 = 42;
    Token t2 = 3.14;
    Token t3 = std::string{"hello"};
    
    // Dispatch to the correct handler based on runtime type
    std::visit(Printer{}, t1);  // "Int: 42"
    std::visit(Printer{}, t2);  // "Double: 3.14"
    
    // With overloaded lambdas:
    auto visitor = overloaded{
        [](int n)    { return n * 2; },
        [](double d) { return (int)(d * 2); },
        [](auto&&)   { return 0; },  // Catch-all
    };
    
    std::cout << std::visit(visitor, t1) << '\n'; // 84
}

C++23 Range-For Improvements

C++23 adds std::views::enumerate and std::views::zip — the Python-style conveniences missing from C++20:

cpp
#include <ranges>
#include <vector>
#include <string>

std::vector<std::string> names = {"Alice", "Bob", "Carol"};
std::vector<int> scores = {95, 82, 78};

// zip: iterate two ranges in pairs (C++23)
for (auto [name, score] : std::views::zip(names, scores)) {
    std::cout << name << ": " << score << '\n';
}

// enumerate: index + value (C++23)
for (auto [i, name] : std::views::enumerate(names)) {
    std::cout << i << ". " << name << '\n';
}
// 0. Alice
// 1. Bob
// 2. Carol

Frequently Asked Questions

Is range-based for loop always zero-overhead compared to index loops? Yes — the compiler converts for (auto& x : container) into for (auto __it = begin(container), __end = end(container); __it != __end; ++__it). There is no runtime difference vs a manual iterator loop. Common compilers vectorize both equally.

Can I break out of a range-based for early? Yes — break works normally. continue skips to the next iteration. For complex iteration patterns, std::ranges::find_if or algorithms are often cleaner than manual break.

When should I use std::variant instead of inheritance? Use std::variant when: the set of types is closed and known at compile time, you want value semantics (no heap allocation, copyable), and you want exhaustiveness checking at compile time. Use inheritance (polymorphism) when: the type set needs to be open (extended by users), or when virtual dispatch's runtime flexibility outweighs the overhead.


Key Takeaway

Modern C++ control flow features move logic from the "what" to the "why." Structured bindings declare your intent (I want the key and value from this map entry). If-initializers declare lifetime scope (this iterator only matters inside this if). enum class prevents accidental integer conversion. Every feature reduces the gap between what you intend and what the code does.

Read next: C++ Functions: Parameters, References & noexcept →


Part of the C++ Mastery Course — 30 modules from modern C++ basics to expert systems engineering.