01 · Modern C++ Features (17/20)¶
C++17 and C++20 changed what idiomatic C++ looks like more than any release
since C++11. Code that used to need an out-parameter, a bool success flag, a
hand-rolled tagged union, or four lines of iterator boilerplate now says what it
means in one line. This module covers the features that show up constantly in
modern codebases, with the emphasis on why each one exists — every one of them
replaces a specific, familiar pain.
Everything here compiles with -std=c++20 on GCC 11+, Clang 14+, or MSVC
19.30+. Where a feature is C++17 it is marked, because plenty of production code
is still pinned there.
Structured bindings (C++17)¶
Unpacking a pair, tuple, or plain struct used to mean .first, .second, or
std::get<0>. Now it means naming the parts:
#include <iostream>
#include <map>
#include <string>
#include <tuple>
struct Point { int x; int y; };
std::tuple<std::string, int, double> lookup() { return {"widget", 42, 9.99}; }
int main() {
Point p{3, 4};
auto [x, y] = p; // works on any public-fields struct
std::cout << "point: " << x << "," << y << "\n";
auto [name, qty, price] = lookup(); // and on tuples
std::cout << "row: " << name << " x" << qty << " @ " << price << "\n";
std::map<std::string, int> stock{{"bolts", 10}, {"nuts", 5}};
for (const auto& [key, count] : stock) // and on map elements -- the big win
std::cout << " " << key << " = " << count << "\n";
}
The map loop is where this pays off daily. for (const auto& kv : stock) forced
readers to remember that kv.second is the count; [key, count] states it.
if/switch with initializer (C++17)¶
if (auto it = stock.find("bolts"); it != stock.end())
std::cout << it->second << " bolts\n";
// `it` does not exist here -- its scope ends with the if/else
The initializer runs, then the condition is evaluated, and the variable is
scoped to the if and its else. This is the same lifetime discipline RAII
gives you for resources, applied to lookup handles.
std::optional — "a value, or nothing" (C++17)¶
The old ways to say "this might fail" were all bad: a sentinel value that a
valid input might legitimately produce, an out-parameter plus a bool, or a
pointer that the caller might forget to check.
#include <optional>
#include <string_view>
std::optional<int> parsePort(std::string_view s) {
int value = 0;
for (char c : s) {
if (c < '0' || c > '9') return std::nullopt;
value = value * 10 + (c - '0');
}
return s.empty() ? std::nullopt : std::optional<int>{value};
}
int main() {
if (auto port = parsePort("8080"); port)
std::cout << "port " << *port << "\n";
std::cout << "bad port -> " << parsePort("80x0").value_or(-1) << "\n";
}
*port and port-> access the value, value_or(fallback) handles the empty
case inline, and value() throws std::bad_optional_access instead of being
undefined. The type itself now documents the contract, so a caller cannot
silently ignore the failure case the way they can with a returned -1.
std::variant — a type-safe union (C++17)¶
#include <variant>
using Field = std::variant<int, double, std::string>;
std::string describe(const Field& f) {
return std::visit([](const auto& v) -> std::string {
using T = std::decay_t<decltype(v)>;
if constexpr (std::is_same_v<T, int>) return "int " + std::to_string(v);
else if constexpr (std::is_same_v<T, double>) return "double " + std::to_string(v);
else return "string \"" + v + "\"";
}, f);
}
int main() {
for (const Field& f : std::vector<Field>{7, 2.5, std::string("hi")})
std::cout << describe(f) << "\n";
}
A union cannot hold a std::string safely — it has a non-trivial destructor,
and the union has no idea which member is live. std::variant tracks the active
alternative, runs the right destructor, and std::visit is checked at compile
time: add a fourth alternative to Field and the if constexpr chain fails to
compile until you handle it. That is exhaustiveness checking, and it is the
whole reason to prefer variant over a hand-rolled tag-plus-union.
if constexpr (also C++17) is what makes the generic lambda work: the branches
that don't apply to T are discarded before instantiation, so
"string \"" + v never has to be valid for int.
Concepts (C++20)¶
Template errors used to be the worst diagnostics in programming — a page of
instantiation backtrace pointing at a line inside <algorithm>. Concepts move
the check to the call site and name the requirement:
#include <concepts>
#include <numeric>
#include <vector>
template <typename T>
concept Numeric = std::integral<T> || std::floating_point<T>;
template <Numeric T>
T mean(const std::vector<T>& v) {
return std::accumulate(v.begin(), v.end(), T{}) / static_cast<T>(v.size());
}
int main() {
std::cout << "mean int: " << mean(std::vector<int>{1,2,3,4}) << "\n";
std::cout << "mean double: " << mean(std::vector<double>{1.5, 2.5, 3.0}) << "\n";
}
(mean of {1,2,3,4} is 2, not 2.5, because T is int and the division
truncates — a good reminder that a generic function inherits the semantics of
its type parameter.)
Now call it with the wrong type, mean(std::vector<std::string>{"a","b"}):
error: no matching function for call to 'mean'
note: candidate template ignored: constraints not satisfied [with T = std::string]
note: because 'std::string' does not satisfy 'Numeric'
note: because 'std::string' does not satisfy 'integral'
note: because 'is_integral_v<std::string>' evaluated to false
Five lines, in your own vocabulary, pointing at your own call. Compare that to the SFINAE-era alternative from Level 3 Module 1.
Ranges (C++20)¶
The iterator-pair interface leaks: std::sort(v.begin(), v.end()) names the
container twice and lets you mismatch two containers' iterators. Ranges take the
container itself, and views compose lazily.
#include <algorithm>
#include <ranges>
struct Employee { std::string name; int age; double salary; };
int main() {
std::vector<Employee> staff{
{"Ada", 36, 120000}, {"Grace", 45, 150000},
{"Linus", 28, 95000}, {"Bjarne", 52, 180000}, {"Ken", 31, 110000}};
auto seniorNames = staff
| std::views::filter([](const Employee& e) { return e.age >= 35; })
| std::views::transform([](const Employee& e) { return e.name; });
std::cout << "seniors:";
for (const auto& n : seniorNames) std::cout << " " << n;
std::cout << "\n";
std::vector<int> nums{5, 3, 9, 1, 7, 3};
std::ranges::sort(nums); // no .begin()/.end()
std::cout << "found 7: " << std::boolalpha
<< std::ranges::binary_search(nums, 7) << "\n";
for (int n : nums | std::views::drop(2) | std::views::take(3))
std::cout << "window " << n << "\n";
}
seniorNames allocates nothing and computes nothing at the point of definition.
The filter and transform run one element at a time, only as the for loop pulls
them. That laziness is why chaining views does not create an intermediate vector
per stage the way a chain of std::copy_if/std::transform calls would.
The spaceship operator (C++20)¶
Writing ==, !=, <, <=, >, >= by hand for one class is six functions
that must agree with each other. operator<=> generates all of them:
struct Version {
int major, minor, patch;
auto operator<=>(const Version&) const = default; // all six, lexicographic
bool operator==(const Version&) const = default;
};
static_assert(Version{1,2,3} < Version{1,10,0});
static_assert(Version{2,0,0} >= Version{1,99,99});
The defaulted version compares members top to bottom, which is exactly what you want for a version number, a date, or a sort key. Write it manually only when the ordering is not member-wise.
Cheat sheet¶
| Feature | Std | Replaces |
|---|---|---|
auto [a, b] = pair |
17 | .first / .second, std::get<N> |
if (init; cond) |
17 | A variable leaking past the if it belongs to |
std::optional<T> |
17 | Sentinel values, out-param + bool, nullable pointers |
std::variant + std::visit |
17 | union + manual tag, void*, base-class hierarchies for plain data |
if constexpr |
17 | Tag dispatch, SFINAE overload pairs |
std::string_view |
17 | const std::string& params that force a copy from a literal |
std::filesystem |
17 | Platform-specific path and directory APIs |
[[nodiscard]] |
17 | Comments asking callers not to ignore a result |
Concepts / requires |
20 | SFINAE, enable_if, unreadable template errors |
| Ranges & views | 20 | begin()/end() pairs, eager intermediate containers |
operator<=> |
20 | Six hand-written, mutually-inconsistent comparisons |
std::span<T> |
20 | (pointer, length) parameter pairs |
| Designated initializers | 20 | Comment-annotated positional aggregate init |
constinit / consteval |
20 | Runtime init of globals; "hopefully constexpr" functions |
Traps¶
std::optional<T&> does not exist. There is no optional reference in the
standard. Use T* (nullable by nature) or
std::optional<std::reference_wrapper<T>> — the former is almost always
clearer.
A dangling string_view. std::string_view sv = getName(); where getName
returns std::string by value binds to a temporary that dies at the end of the
statement. string_view never owns; keep the owner alive for at least as long
as the view, and never return one that refers to a local.
Views are invalidated like iterators. A filter_view over a std::vector
holds onto the vector. push_back may reallocate, and every element the view
would have produced is then dangling. Materialize with std::ranges::to
(C++23) or a manual copy if the underlying container will change.
std::visit requires every alternative be handled. With a generic lambda
and if constexpr, a missing else branch is a compile error only if the
return types disagree — annotate the lambda's return type (-> std::string
above) so a forgotten branch fails loudly instead of deducing something
surprising.
Structured bindings are not variables you can capture pre-C++20. In C++17,
a lambda could not capture x from auto [x, y] = .... C++20 fixed it, but
compilers vary; if a capture mysteriously fails, that's why.
Concepts constrain, they do not overload-rank by default. Two functions with
unrelated concepts are ambiguous for a type satisfying both. Subsumption only
orders constraints that are literally composed from the same atomic constraints,
so build concepts by refining others (Numeric → std::integral) rather than
writing two independent requires clauses.
How It Actually Works¶
std::optional<T> is not magic — it's a small class holding a bool flag
plus raw, uninitialized storage exactly sizeof(T) bytes wide (aligned
correctly for T), and it manually calls T's constructor/destructor into
that storage using placement new when a value is assigned or cleared.
This is why sizeof(std::optional<int>) is typically 8 bytes rather than
4 — the bool flag plus alignment padding — and why it has no heap allocation
at all: the storage lives inline, inside the optional object itself,
exactly like a member variable would.
std::variant<Ts...> generalizes this to a tagged union: internally it's
a union-like block of memory sized to fit the largest alternative, plus an
integer index recording which alternative is currently active. std::get<T>
checks that index and throws if it doesn't match; std::visit compiles to a
jump table indexed by that same integer, dispatching to the right callable
overload for whichever type is actually stored — mechanically similar to the
switch jump table from Level 1 Module 3, generated by the compiler from
the std::visit call rather than written by hand.
Structured bindings (auto [a, b] = pair;) aren't a new kind of variable —
the compiler introduces one hidden, unnamed object holding the full
decomposed value, and a/b are just references to its members baked in at
compile time; there's no runtime cost beyond what accessing .first/.second
already had.
std::string_view is the sharpest tool in this set precision-wise: it's
just a pointer plus a length, with no ownership of the underlying
characters at all — construction from a std::string or literal is O(1),
no allocation, no copy. That efficiency is exactly its danger: a
string_view into a temporary std::string or a string that gets
reallocated (Level 1 Module 6) becomes a dangling pointer the moment the
underlying buffer is freed or moved, with nothing in the type system to
catch it.
Exercise¶
Build a small config-value type using the features above. Define
using Value = std::variant<bool, long, double, std::string>; and a
std::map<std::string, Value> config. Then write:
std::optional<Value> get(const std::map<std::string, Value>&, std::string_view key)— returnsstd::nulloptfor a missing key, and is called with anif-with-initializer at the call site.std::string render(const Value&)usingstd::visitandif constexpr, printingtrue/falsefor the bool rather than1/0.- A concept
Stringablesatisfied by any typeTfor whichstd::to_string(T{})is valid (use arequiresexpression), and a constrainedtemplate <Stringable T> Value make(T). - A ranges pipeline that prints the keys of every entry holding a
std::string, sorted —std::views::filteronstd::holds_alternative<std::string>(kv.second), thenstd::views::transformto the key.
Confirm that calling make(std::vector<int>{}) produces a short constraint
error naming Stringable, not a template backtrace.