This note covers C++17. Unlike the patch-style update that was C++14, C++17 contains several features that substantively change how you write code day to day.
Structured bindings
Unpack the members of an aggregate, pair, tuple or array into named variables in one step:
C++std::map<std::string, int> scores;
// C++14
for (const auto& kv : scores)
std::cout << kv.first << ": " << kv.second << '\n';
// C++17
for (const auto& [name, score] : scores)
std::cout << name << ": " << score << '\n';
Meaningless names like first and second can finally disappear. It works equally well for functions returning several values:
C++auto [it, inserted] = scores.insert({"tony", 100});
if (inserted) { /* ... */ }
Two caveats: the number of bindings must match the number of members exactly β you cannot bind only some of them β and the names a structured binding introduces cannot be captured by a lambda (that was relaxed in C++20).
if and switch with an initializer
C++if (auto it = cache.find(key); it != cache.end()) {
use(it->second);
}
// it is already out of scope here
This tightens a variable's scope to the region where it is actually needed, on the same principle as the initializer in a for loop. It reads especially well for the "acquire then check" shape: lookups, locks, opening files.
if constexpr
Compile-time branching inside a template, at last without SFINAE and tag dispatch:
C++template <typename T>
void print(const T& value) {
if constexpr (std::is_pointer_v<T>) {
std::cout << *value;
} else {
std::cout << value;
}
}
The essential part is that the branch not taken is never instantiated. In the code above, *value does not even have to be valid when T is not a pointer β an ordinary if cannot do this, because both branches must compile.
This one feature eliminates a great deal of template-metaprogramming boilerplate. What used to need two overloads and std::enable_if is now one if inside one function.
std::optional
Expresses "there may be no value" without special sentinel values or output parameters:
C++std::optional<Config> loadConfig(const std::string& path);
if (auto cfg = loadConfig("app.toml")) {
apply(*cfg);
} else {
useDefaults();
}
Compared with returning -1, nullptr, or bool tryGet(T& out), optional writes "this may fail" into the type, where the caller cannot overlook it.
It does not allocate: the value is stored inside the optional itself, so sizeof(std::optional<int>) is typically 8 β four bytes of int, one of flag, and padding.
Mind the difference between *opt and opt.value(): the first is undefined behaviour when empty, the second throws std::bad_optional_access.
std::variant and std::visit
A type-safe union. Unlike the C-style union covered in C++ #2, a variant knows which type it is currently holding:
C++using Value = std::variant<int, double, std::string>;
Value v = 3.14;
std::visit([](const auto& x) { std::cout << x << '\n'; }, v);
std::visit dispatches to the handler matching the type currently held. If the visitor does not cover every possible type, it fails to compile β which is exactly its advantage over a virtual inheritance hierarchy: a closed set of types, no heap allocation, no vtable, and when you add a new type the compiler points at every place that needs updating.
With a common helper template you can write it as a set of branches:
C++template <class... Ts> struct overloaded : Ts... { using Ts::operator()...; };
template <class... Ts> overloaded(Ts...) -> overloaded<Ts...>;
std::visit(overloaded{
[](int i) { std::cout << "int " << i; },
[](double d) { std::cout << "double " << d; },
[](const std::string& s) { std::cout << "string " << s; },
}, v);
std::string_view
A non-owning view over a string: one pointer and one length.
C++void log(std::string_view msg); // takes const char*, std::string, literals β all zero-copy
log("literal"); // no std::string constructed
log(someStdString); // no copy
log(std::string_view(buf, len)); // substrings are free too
Before this, passing a literal to a function taking const std::string& constructed a temporary string, possibly with a heap allocation. With string_view that cost disappears. For any read-only string parameter, string_view should be the default choice.
The price is that lifetime is entirely on you: a string_view does not extend the life of what it points at.
C++std::string_view bad() {
std::string s = "temp";
return s; // dangling: s is gone after the return
}
By the same reasoning, do not store a string_view in a member variable unless you are certain the pointed-at data outlives it.
Other things worth knowing
Class template argument deduction (CTAD). No more spelling out template arguments at construction:
C++std::pair p(1, 2.0); // deduced as std::pair<int, double>
std::lock_guard lk(mutex); // no std::lock_guard<std::mutex>
std::vector v{1, 2, 3}; // std::vector<int>
Inline variables. Global variables can be defined in a header, with no companion definition in a .cpp:
C++inline constexpr int kMaxUsers = 1024;
struct Config { inline static int instances = 0; };
Fold expressions. Variadic templates without recursive expansion:
C++template <typename... Args>
auto sum(Args... args) { return (args + ...); } // (a1 + (a2 + (a3 + ...)))
std::filesystem. Paths, directory iteration and file status in the standard library, so you stop writing one version per platform.
Nested namespaces. namespace a::b::c { } instead of three levels of nesting.
[[nodiscard]]. Warns when a return value is ignored. Valuable on anything returning an error code or an optional:
C++[[nodiscard]] bool tryConnect();
tryConnect(); // warning: return value discarded
Guaranteed copy elision. When a prvalue is returned from a function, the standard now requires that no copy or move happens. Which means non-copyable, non-movable types can be returned from factory functions.
Wrapping up
If I had to pick three C++17 features to actually adopt, they would be structured bindings (readability), if constexpr (killing template boilerplate) and string_view (killing implicit string copies). Those three together are enough to make a C++11 codebase read several years younger.