C++17 was ratified in December 2017. Nearly a decade on, it is the baseline many C++ codebases build against — not a preview, not a proposal, and not something to evaluate before adopting. If your compiler is from the last six years, you already have all of it.
This guide covers the C++17 features worth knowing, each with a short program that was compiled and run rather than sketched. It also answers the question most feature lists skip: of the hundred-plus changes, which five actually change how you write code day to day. Everything below was built on Ubuntu 24.04 with GCC 13.3 under -std=c++17 with -Wall -Wextra, and every output block is captured from those runs.
Table of Contents
- Where C++17 Sits
- Start With These Five
- Structured Bindings
- if with Initializer
- if constexpr
- std::optional
- std::variant
- std::any
- std::string_view
- std::filesystem
- Parallel Algorithms — With a Caveat
- Smaller Features Worth Knowing
- What C++17 Removed
- Compiler Support
- Key Takeaways
- Frequently Asked Questions
- Conclusion
Where C++17 Sits
C++11 was the revolution — lambdas, auto, move semantics. C++14 was a cleanup release. C++17 is the consolidation: it standardized several facilities that had long existed as third-party libraries or common idioms. optional, variant, any, string_view and filesystem, for example, had library predecessors, while structured bindings addressed long-standing tuple/pair decomposition patterns.
That history matters practically. C++17 features tend to be things you already wanted, which is why adoption was fast and why the migration cost is low.
| Standard | Released | Headline additions |
|---|---|---|
| C++11 | 2011 | auto, lambdas, move semantics, smart pointers |
| C++14 | 2014 | Generic lambdas, make_unique |
| C++17 | 2017 | Structured bindings, optional, variant, string_view, filesystem |
| C++20 | 2020 | Concepts, ranges, coroutines, modules |
| C++23 | 2023 | std::print, std::expected, std::mdspan |
Start With These Five
If you adopt nothing else, these five change everyday code the most:
- Structured bindings — unpack tuples, pairs and structs without boilerplate
std::optional— express “a value, or nothing” in the type systemif constexpr— branch at compile time, replacing many tag-dispatch and SFINAE branchesstd::string_view— read strings without copying themstd::filesystem— paths, directories and file metadata, portably
Each is covered below with a runnable example.
Structured Bindings
One of the most-used C++17 feature. It decomposes a tuple, pair, array or struct into named variables in one declaration.
#include <map>
#include <string>
#include <tuple>
std::tuple<std::string, int, double> getRecord() { return {"Ada", 36, 99.5}; }
auto [name, age, score] = getRecord();
1. structured bindings: Ada, 36, 99.5
Before C++17 that needed std::tie with pre-declared variables, or three std::get<N> calls.
It is at its best iterating a map, where first and second become meaningful names:
std::map<std::string,int> ages{{"Ada",36},{"Alan",41}};
for (const auto& [key, value] : ages)
printf("%s=%d ", key.c_str(), value);
over a map: Ada=36 Alan=41
Structured bindings are a compile-time construct with no runtime cost. They work with arrays, anything providing tuple_size and get<N>, and any struct whose members are public and non-static.
if with Initializer
You can now declare a variable inside an if or switch, scoped to that statement:
if (auto it = ages.find("Ada"); it != ages.end())
printf("found %s\n", it->first.c_str());
// it is out of scope here
2. if with initializer: found Ada
This keeps short-lived variables out of the enclosing scope and pairs naturally with structured bindings — if (auto [iter, ok] = m.insert(...); ok) is a common idiom.
if constexpr
if constexpr discards the untaken branch at compile time. The discarded branch is not compiled, so it may contain code that would be invalid for that type:
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
return "something else";
}
3. if constexpr: integer: 42 | float: 3.500000
This replaces many tag-dispatch and SFINAE branches with something a reader can actually follow. It does not replace SFINAE entirely — controlling which overloads participate in resolution still needs it, and C++20 concepts are the cleaner answer there. For anyone who has written std::enable_if_t chains, it is the biggest readability win in the standard.
std::optional
std::optional<T> holds either a value or nothing, making “this might fail” visible in the signature instead of encoded in a sentinel value or an out-parameter:
#include <optional>
std::optional<int> parsePort(const std::string& s) {
try { return std::stoi(s); }
catch (...) { return std::nullopt; }
}
if (auto p = parsePort("8080")) printf("port %d\n", *p);
if (!parsePort("nope")) printf("\"nope\" -> nullopt\n");
4. optional: port 8080; "nope" -> nullopt
It converts to bool, so if (opt) tests presence. Use *opt or opt.value() to access — value() throws std::bad_optional_access if empty, while * is undefined behaviour, so prefer value() when you have not already checked. opt.value_or(default) covers the common fallback case.
Do not use optional for errors that need explanation. It tells you something failed, not why. C++23’s std::expected fills that gap.
std::variant
A type-safe union: it holds exactly one of several alternatives and remembers which:
#include <variant>
std::variant<int, std::string> v = 42;
printf("holds int: %d, index %zu\n", std::get<int>(v), v.index());
v = std::string("now a string");
printf("holds string: %s, index %zu\n", std::get<std::string>(v).c_str(), v.index());
5. variant holds int: 42, index 0
now holds string: now a string, index 1
Unlike a C union, it tracks the active alternative and calls the correct destructor. Accessing the wrong one throws std::bad_variant_access rather than silently reinterpreting bytes.
std::visit applies a callable to whichever alternative is active, which is how you write exhaustive handling without a chain of holds_alternative checks.
std::any
std::any stores a value of any type, retrieved with std::any_cast:
std::any a = 7;
printf("%d", std::any_cast<int>(a));
a = std::string("text");
printf(" then %s\n", std::any_cast<std::string>(a).c_str());
6. any: 7 then text
Reach for it rarely. If you know the possible types, variant is usually the better choice when the set of types is known, because the alternatives are explicit and checked at compile time. There is a storage difference too, though it is smaller than it is often described: measured here, std::any stored an int or a double with no allocation at all, and allocated only once the object exceeded its 16-byte inline buffer.
std::string_view
A non-owning view over character data: a pointer and a length, no allocation, no copy.
std::string_view sv = "no allocation here";
printf("%zu chars, sizeof=%zu (string is %zu)\n",
sv.size(), sizeof(sv), sizeof(std::string));
7. string_view: 18 chars, sizeof=16 (string is 32)
On the GCC 13.3 / libstdc++ build measured here, 16 bytes against 32 for std::string — both figures are implementation details, not language guarantees. Take string_view for read-only string parameters — passing a literal to const std::string& constructs a temporary std::string, while string_view does not. Whether that temporary also allocates depends on its length: measured here, literals up to 15 characters fit libstdc++’s small-string buffer and cost no allocation at all, while a 16-character literal costs one. string_view constructs nothing either way.
The catch is lifetime: a view does not own its data and becomes dangling if the underlying string dies. The std::string guide covers that trap in detail, including which compilers warn about it.
std::filesystem
Portable file and directory handling, finally in the standard library:
#include <filesystem>
namespace fs = std::filesystem;
fs::create_directories("demo/sub");
for (const auto& e : fs::recursive_directory_iterator("demo"))
if (e.is_regular_file())
std::cout << e.path().string() << " (" << e.file_size() << " bytes)\n";
fs::path p = "demo/sub/b.txt";
std::cout << "stem=" << p.stem() << " ext=" << p.extension() << "\n";
fs::remove_all("demo");
exists(demo): true
file_size(demo/a.txt): 5 bytes
demo/sub/b.txt (7 bytes)
demo/a.txt (5 bytes)
stem="b" ext=".txt" parent="demo/sub"
fs::path handles separators per platform, so the same code works on Windows and POSIX. Note that most operations throw fs::filesystem_error on failure; overloads taking a std::error_code are available where you prefer not to use exceptions.
Parallel Algorithms — With a Caveat
C++17 added execution policies to the standard algorithms. In principle, one argument parallelises a sort:
#include <execution>
std::sort(std::execution::par, v.begin(), v.end());
In practice, measured here on a single-core machine sorting 20 million doubles:
seq sort: 300 ms
par sort: 1580 ms
The parallel version was over five times slower on the first run, settling to roughly 1.7× slower on repeats. Thread creation and coordination are not free, and with one core there is nothing to parallelise onto — only overhead to pay. Both figures below were taken with the TBB backend active — that matters, because without it the two policies are the same code and the comparison is meaningless.
This is not an argument against parallel algorithms. On genuinely multi-core hardware with large enough datasets they help substantially. It is an argument against treating std::execution::par as a free speedup you can sprinkle on: measure on your target hardware, with your data size.
One practical note: libstdc++ implements the parallel policies on top of Intel TBB, and the failure mode when TBB is absent is quiet. On a stock Ubuntu 24.04 / GCC 13.3 box with no TBB installed, std::execution::par compiled and linked without -ltbb, ran without complaint, and spawned no threads at all — libstdc++ had fallen back to its serial backend. Timed against std::execution::seq on twenty million doubles it came out at 0.98–0.99×, which is what identical code looks like. You can write par, ship it, and get nothing, with no diagnostic anywhere. Check that TBB is actually linked before believing a parallel policy is doing something.
Smaller Features Worth Knowing
Class template argument deduction (CTAD) — the compiler deduces template arguments from the constructor:
template <typename T> struct Box { T value; Box(T v) : value(v) {} };
Box b{3.14}; // Box<double>, no explicit argument
std::pair p{1, "one"}; // std::pair<int, const char*>
9. CTAD: Box{3.14} deduced, value = 3.14
Fold expressions collapse a parameter pack without recursive templates:
template <typename... Args>
auto sum(Args... args) { return (args + ... + 0); }
8. fold expression: sum(1,2,3,4,5) = 15
Nested namespace definitions — namespace a::b::c { } instead of three nested blocks.
Inline variables — inline constexpr int limit = 100; in a header, defined once across translation units, which finally makes header-only libraries with globals straightforward.
[[nodiscard]], [[maybe_unused]], [[fallthrough]] — standard attributes. [[nodiscard]] is the most valuable: it makes ignoring a return value a warning, which catches a real class of bug in error-returning APIs.
Guaranteed copy elision — return MyType{...}; is no longer a copy the compiler is permitted to elide; there is no copy to elide. This makes returning non-movable types possible.
What C++17 Removed
Deletions matter for anyone porting older code. The standard’s own change list is the complete record; these are the ones that break real builds:
| Removed | Replacement |
|---|---|
std::auto_ptr | std::unique_ptr (since C++11) |
register keyword | Nothing — it was removed as a storage-class specifier and compilers already ignored it; the keyword stays reserved |
Trigraphs (??= etc.) | Nothing; they were an artefact of pre-Unicode keyboards |
std::random_shuffle | std::shuffle with an explicit generator |
Dynamic exception specifications (throw(int)) | noexcept |
If you inherit a codebase that fails to build under -std=c++17, auto_ptr and dynamic exception specifications are the two most likely causes.
Compiler Support
C++17 is broadly supported from GCC 9, Clang 9, and MSVC 2019 (19.20+). The examples here were built with GCC 13.3.
Two practical caveats. std::filesystem on GCC 8 and earlier required linking -lstdc++fs; from GCC 9 it is in the main library. Parallel algorithms on libstdc++ require Intel TBB, as noted above. Neither is a problem on a current toolchain, but both surface in older CI environments.
Key Takeaways
- C++17 is not new. Ratified December 2017, broadly supported since GCC 9 and MSVC 2019. It is a practical baseline for many modern C++ codebases.
- Five features carry most of the benefit: structured bindings,
optional,if constexpr,string_viewandfilesystem. string_viewis 16 bytes againststd::string‘s 32, and costs nothing to construct — use it for read-only string parameters.if constexprreplaces many SFINAE branches with a branch a reader can follow.- Parallel algorithms are not a free speedup. Measured on a single-core machine,
std::execution::parsorting ran about 1.7× slower on repeat runs, and over 5× on the first. - Prefer
varianttoanywhen the type set is known — it is safer and avoids a possible allocation. auto_ptrand dynamic exception specifications were removed, so older code using them will not compile under-std=c++17.
Frequently Asked Questions
Conclusion
The useful way to think about C++17 is not as a list of features but as the release where C++ absorbed its own best practices. Nearly everything in it existed first as a Boost library or a widely copied helper — optional, variant, string_view, filesystem, even structured bindings echo patterns people had been approximating with std::tie. Standardising them did not add capability so much as remove the need for everyone to reinvent it.
That is why the adoption question barely exists any more. You are not deciding whether to use C++17; you are deciding whether to keep writing the pre-C++17 workarounds for things the standard library now does properly. If you are working forward from here, C++23’s additions continue the same pattern, and the C++ section covers the language fundamentals these features build on.


