Skip to content

C++17: The Practical Standard

Explore C++17's most impactful features: structured bindings, std::optional/variant/any, std::string_view, std::filesystem, if constexpr, and new attributes.

Why C++17 Matters

C++17 is often called the "practical standard" because it introduced a wealth of features that simplify everyday coding without requiring deep template metaprogramming knowledge. It removed legacy baggage (like std::auto_ptr and trigraphs), added vocabulary types (std::optional, std::variant, std::any), and introduced syntactic sugar that makes code both shorter and clearer. If you are writing production C++ today, C++17 should be your minimum baseline. Think of it as the sensible cardigan of standards: unglamorous, but you will be glad of it.

Structured Bindings & Initialiser Statements

Structured bindings let you unpack aggregates (structs, arrays, pairs, tuples) into named variables in a single declaration. Combined with if/switch initializers, they eliminate many temporary variables, rather like finally clearing out the shed:

structured_bindings.cpp
#include <iostream>
#include <map>
#include <string>
#include <tuple>

std::tuple<std::string, int, double> get_employee() {
    return {"Alice", 42, 95000.0};
}

int main() {
    // Structured binding with tuple
    auto [name, age, salary] = get_employee();
    std::cout << name << " is " << age << " years old\n";

    // Structured binding with map iteration
    std::map<std::string, int> scores{{"Alice", 95}, {"Bob", 87}};
    for (const auto& [student, score] : scores) {
        std::cout << student << ": " << score << "\n";
    }

    // if with initializer — the variable is scoped to the if/else
    if (auto it = scores.find("Alice"); it != scores.end()) {
        std::cout << "Found: " << it->second << "\n";
    } else {
        std::cout << "Not found\n";
    }
    // 'it' no longer exists here — no scope leakage

    // Structured binding with array
    int arr[3] = {10, 20, 30};
    auto [x, y, z] = arr;
    std::cout << x << ", " << y << ", " << z << "\n";

    return 0;
}

Vocabulary Types: optional, variant, any

std::optional represents a value that may or may not be present (replacing sentinel values and output parameters). std::variant is a type-safe union. std::any holds any copyable type with runtime type checking. Do keep a stiff upper lip when the cast fails:

vocabulary_types.cpp
#include <iostream>
#include <optional>
#include <variant>
#include <any>
#include <string>

// optional: replaces "return -1 on failure" patterns
std::optional<int> find_index(const std::string& haystack, char needle) {
    for (size_t i = 0; i < haystack.size(); ++i) {
        if (haystack[i] == needle) return static_cast<int>(i);
    }
    return std::nullopt;  // no value
}

int main() {
    // std::optional
    auto idx = find_index("hello", 'l');
    if (idx.has_value()) {
        std::cout << "Found at index " << *idx << "\n";       // 2
    }
    std::cout << idx.value_or(-1) << "\n";  // safe default

    // std::variant — type-safe union
    std::variant<int, double, std::string> value = "hello";
    std::cout << std::get<std::string>(value) << "\n";

    // Visit pattern — exhaustive handling of all types
    std::visit([](const auto& v) {
        std::cout << "Value: " << v << "\n";
    }, value);

    value = 3.14;  // now holds a double
    std::cout << std::get<double>(value) << "\n";

    // std::any — truly any type, with runtime checking
    std::any data = 42;
    std::cout << std::any_cast<int>(data) << "\n";
    data = std::string("world");
    std::cout << std::any_cast<std::string>(data) << "\n";

    return 0;
}

string_view & filesystem

std::string_view is a lightweight, non-owning reference to a string. It avoids copies when you only need to read a string. std::filesystem provides portable, cross-platform path manipulation and file operations, so you can stop apologising to Windows users:

string_view_fs.cpp
#include <iostream>
#include <string>
#include <string_view>
#include <filesystem>

namespace fs = std::filesystem;

// string_view avoids copying — just a pointer + length
void print_trimmed(std::string_view sv) {
    auto start = sv.find_first_not_of(' ');
    auto end = sv.find_last_not_of(' ');
    if (start != std::string_view::npos) {
        std::cout << sv.substr(start, end - start + 1) << "\n";
    }
}

int main() {
    std::string name = "  Hello, C++17!  ";
    print_trimmed(name);       // no copy — string_view binds to string
    print_trimmed("  world  "); // no copy — binds to string literal

    // std::filesystem — portable path operations
    fs::path source_dir = "/home/user/project/src";
    std::cout << "Filename: " << source_dir.filename() << "\n";
    std::cout << "Parent:   " << source_dir.parent_path() << "\n";
    std::cout << "Exists:   " << fs::exists(source_dir) << "\n";

    // Iterate directory entries
    for (const auto& entry : fs::directory_iterator(".")) {
        if (entry.is_regular_file()) {
            std::cout << entry.path().filename()
                      << " (" << entry.file_size() << " bytes)\n";
        }
    }

    // Create directories recursively
    fs::create_directories("output/logs/2024");

    return 0;
}

if constexpr, CTAD, and More

if constexpr enables compile-time branching inside templates; branches that don't match are discarded entirely, with no hard feelings. Class Template Argument Deduction (CTAD) lets you omit template arguments when the compiler can deduce them. Fold expressions simplify variadic template parameter packs:

if_constexpr.cpp
#include <iostream>
#include <string>
#include <vector>
#include <type_traits>

// if constexpr — branches resolved at compile time
template<typename T>
std::string to_string_safe(T value) {
    if constexpr (std::is_arithmetic_v<T>) {
        return std::to_string(value);
    } else if constexpr (std::is_same_v<T, std::string>) {
        return value;
    } else {
        return "[unsupported type]";
    }
}

// Fold expressions — collapse parameter packs
template<typename... Args>
auto sum(Args... args) {
    return (args + ...);  // unary right fold
}

template<typename... Args>
void print_all(Args&&... args) {
    ((std::cout << args << " "), ...);  // fold over comma
    std::cout << "\n";
}

int main() {
    std::cout << to_string_safe(42) << "\n";
    std::cout << to_string_safe(std::string("hi")) << "\n";

    std::cout << sum(1, 2, 3, 4, 5) << "\n";  // 15
    print_all(1, "hello", 3.14);               // 1 hello 3.14

    // CTAD — no need for std::vector<int>
    std::vector v{1, 2, 3, 4, 5};   // deduced as vector<int>
    std::pair p{"hello", 42};        // deduced as pair<const char*, int>

    // Nested namespaces
    // namespace A::B::C { } instead of namespace A { namespace B { namespace C { } } }

    // Inline variables — can be defined in headers without ODR violations
    // inline constexpr int version = 17;

    return 0;
}
Best Practice

C++17 standardised three important attributes:

[[nodiscard]] — Warns if a return value is discarded. Use it on functions whose return value should always be checked (error codes, allocated resources, factory functions).

[[maybe_unused]] — Suppresses unused-variable/parameter warnings. Useful for variables only used in debug builds or platform-specific code.

[[fallthrough]] — Indicates intentional fallthrough in a switch case, silencing compiler warnings.

Adopt [[nodiscard]] aggressively on your APIs. A discarded error code is a bug waiting to happen, rather like an unanswered letter from the council.

Pitfall

Never return a std::string_view that references a local string. Since string_view does not own its data, the underlying string may be destroyed before the view is used, causing undefined behaviour:

// DANGEROUS — returns a view to a destroyed temporary!
std::string_view bad() {
    std::string s = "hello";
    return s;  // s is destroyed at end of function!
}

Rule of thumb: use string_view for parameters (reading data), return std::string for return values (owning data). Otherwise you are pointing at a seat that someone has already taken away.

Key Takeaways
  • Structured bindings (auto [a, b] = ...) unpack tuples, pairs, structs, and arrays cleanly
  • std::optional replaces sentinel values; std::variant replaces unsafe unions; std::any is for truly dynamic types
  • std::string_view avoids copies for read-only string access, but beware dangling references
  • if constexpr enables compile-time branching in templates, eliminating SFINAE boilerplate
  • CTAD lets the compiler deduce template arguments: std::vector v{1,2,3} just works
  • Use [[nodiscard]] on functions whose return values must not be ignored

Quiz — Test Your Knowledge

(15 XP)

1. What does `auto [x, y] = std::make_pair(1, 2.0);` do in C++17?

2. What does `std::optional<int>` represent?

3. Why is returning a `std::string_view` from a function dangerous if it references a local string?