C++17 was ratified in December 2017. Nearly a decade on, it is the baseline most 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 every C++17 feature 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 took patterns the community had been hand-rolling for years, mostly from Boost, and standardised them. optional, variant, any, string_view and filesystem had all existed as third-party libraries first.
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 most SFINAEstd::string_view— read strings without copying themstd::filesystem— paths, directories and file metadata, portably
Each is covered below with a runnable example.
Structured Bindings
The single 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 most uses of tag dispatch and SFINAE with something a reader can actually follow. 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 safer and faster because it avoids the heap allocation any may perform. any earns its place only when the type set is genuinely open — plugin systems, scripting bridges.
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)
Half the size of a std::string and free to construct. Take string_view for read-only string parameters — passing a literal to const std::string& constructs a temporary and allocates, while string_view does neither.
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.
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: on libstdc++ the parallel policies require linking Intel TBB (-ltbb), and code that compiles will fail at link time without it.
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 — the compiler already ignored it |
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 fully supported in 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, fully supported since GCC 9 and MSVC 2019. Treat it as your baseline, not a decision.
- 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 most SFINAE with a branch a reader can follow.- Parallel algorithms are not a free speedup. Measured on a single-core machine,
std::execution::parsorting ran 1.7× to 5× slower than sequential. - Prefer
varianttoanywhen the type set is known — it is safer and avoids a possible allocation. auto_ptrand dynamic exception specifications were removed, and are the usual cause of old code failing 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.


