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 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

Where C++17 sitsRatified December 2017. Widely supported by modern compilers.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 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.

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 many tag-dispatch and SFINAE branches
  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

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 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 — 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_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 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_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 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::par sorting ran about 1.7× slower on repeat runs, and over 5× on the first.
  • 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, 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.

Scroll to Top