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 with Custom Types
- if with Initializer (C++17)
- switch with Initializer (C++17)
- Scoped Enums (enum class) and Exhaustiveness
- Range-Based For: Deep Dive
- The Spaceship Operator
<=>and Three-Way Comparison - std::variant and std::visit: Type-Safe Unions
- C++23 range-for with zip and enumerate
- Branchless Programming: Eliminating if in Hot Loops
- Frequently Asked Questions
- Key Takeaway
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:
#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:
#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=0if 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:
#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 failureswitch with Initializer (C++17)
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:
// 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 overloadRange-Based For: Deep Dive
Range-based for loops work with any type that has begin() and end() iterators — including custom containers:
#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:
#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); // truestd::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:
#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:
#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. CarolFrequently 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.
