C++17 Features Explained — What Changed and What to Use Today

C++17 was ratified in 2017, not proposed. The features that changed everyday C++, each compiled and run, plus the one that can make code slower.

A stack of layers representing C++ standards, with the C++17 layer highlighted and holding five component blocks

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

Where C++17 sitsRatified December 2017. Universally supported since roughly 2019.C++11lambdas, auto, moveC++14minor cleanupC++17this articleC++20concepts, rangesC++23std::printStart with these fivestructured bindingscleaner unpackingstd::optionala value, or nothingif constexprcompile-time branchingstd::string_viewreads without copyingstd::filesystempaths and directoriesParallel algorithms are nota free speedup.std::execution::par adds realthread overhead. Measured on asingle-core machine, a parallelsort ran 1.7× SLOWER thanthe sequential one.Every feature in this article was compiled and run with g++ -std=c++17.

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.

StandardReleasedHeadline additions
C++112011auto, lambdas, move semantics, smart pointers
C++142014Generic lambdas, make_unique
C++172017Structured bindings, optional, variant, string_view, filesystem
C++202020Concepts, ranges, coroutines, modules
C++232023std::print, std::expected, std::mdspan

Start With These Five

If you adopt nothing else, these five change everyday code the most:

  1. Structured bindings — unpack tuples, pairs and structs without boilerplate
  2. std::optional — express “a value, or nothing” in the type system
  3. if constexpr — branch at compile time, replacing most SFINAE
  4. std::string_view — read strings without copying them
  5. std::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 definitionsnamespace a::b::c { } instead of three nested blocks.

Inline variablesinline 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 elisionreturn 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:

RemovedReplacement
std::auto_ptrstd::unique_ptr (since C++11)
register keywordNothing — the compiler already ignored it
Trigraphs (??= etc.)Nothing; they were an artefact of pre-Unicode keyboards
std::random_shufflestd::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_view and filesystem.
  • string_view is 16 bytes against std::string‘s 32, and costs nothing to construct — use it for read-only string parameters.
  • if constexpr replaces most SFINAE with a branch a reader can follow.
  • Parallel algorithms are not a free speedup. Measured on a single-core machine, std::execution::par sorting ran 1.7× to 5× slower than sequential.
  • Prefer variant to any when the type set is known — it is safer and avoids a possible allocation.
  • auto_ptr and 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.

Scroll to Top