Back to blog

Phase 3: Modern C++ Features (C++11 to C++23)

cppc++modern-cppprogrammingcpp20
Phase 3: Modern C++ Features (C++11 to C++23)

Welcome to Phase 3

You've built a solid foundation in C++ fundamentals and OOP. Now it's time to explore what makes Modern C++ genuinely modern. The C++11 standard was a watershed moment—so transformative that Bjarne Stroustrup (C++'s creator) described it as feeling like a new language. Every major revision since has continued that evolution.

This phase surveys the key features introduced from C++11 through C++23: the features that separate idiomatic modern C++ from old-school C-style C++.

Time commitment: 2-3 weeks, 1-2 hours daily
Prerequisite: Phase 2: Object-Oriented Programming

What You'll Learn

By the end of Phase 3, you'll be able to:

✅ Write expressive lambdas and use them with STL algorithms
✅ Use auto, structured bindings, and range-for loops fluently
✅ Replace raw pointers with smart pointers
✅ Apply C++17 utilities: optional, variant, string_view
✅ Write constrained templates with C++20 Concepts
✅ Use the Ranges library for composable pipelines
✅ Read and understand C++23's std::expected and std::print


1. C++11 Core Features

auto Type Deduction

auto lets the compiler deduce the type from the initializer:

#include <vector>
#include <map>
 
int main() {
    auto x = 42;              // int
    auto pi = 3.14;           // double
    auto name = std::string{"Alice"};  // std::string
 
    std::vector<int> nums = {1, 2, 3, 4, 5};
    auto it = nums.begin();   // std::vector<int>::iterator
 
    std::map<std::string, int> scores;
    // Without auto: std::map<std::string, int>::iterator
    auto mit = scores.begin();
}

When to use auto:

  • Long iterator/template types (always clearer)
  • Range-for loop variables
  • Lambda return types
  • Avoid for simple types where the type is informative (int count = 0)

auto and references:

std::vector<std::string> names = {"Alice", "Bob"};
 
// BAD: copies the string
for (auto name : names) { }
 
// GOOD: reference (no copy)
for (auto& name : names) { }
 
// GOOD: const reference (read-only)
for (const auto& name : names) { }

Range-Based For Loops

Cleaner iteration over any range:

#include <iostream>
#include <vector>
#include <map>
 
int main() {
    std::vector<int> nums = {1, 2, 3, 4, 5};
 
    // By value (copy)
    for (auto n : nums) {
        std::cout << n << ' ';
    }
 
    // By const reference (no copy, read-only)
    for (const auto& n : nums) {
        std::cout << n << ' ';
    }
 
    // By reference (modify in-place)
    for (auto& n : nums) {
        n *= 2;
    }
 
    // Works with maps too
    std::map<std::string, int> ages = {{"Alice", 30}, {"Bob", 25}};
    for (const auto& [name, age] : ages) {  // structured binding!
        std::cout << name << ": " << age << '\n';
    }
}

Lambda Expressions

Lambdas are anonymous functions defined inline:

#include <algorithm>
#include <vector>
#include <iostream>
 
int main() {
    // Basic lambda syntax: [capture](params) { body }
    auto greet = [](const std::string& name) {
        std::cout << "Hello, " << name << "!\n";
    };
    greet("World");
 
    // Lambda with return value
    auto add = [](int a, int b) -> int {
        return a + b;
    };
    std::cout << add(3, 4) << '\n';  // 7
 
    // Using lambdas with STL algorithms
    std::vector<int> nums = {5, 2, 8, 1, 9, 3};
 
    // Sort descending
    std::sort(nums.begin(), nums.end(), [](int a, int b) {
        return a > b;
    });
 
    // Find first even number
    auto it = std::find_if(nums.begin(), nums.end(), [](int n) {
        return n % 2 == 0;
    });
 
    if (it != nums.end()) {
        std::cout << "First even: " << *it << '\n';
    }
}

Capture modes:

int x = 10;
int y = 20;
 
// Capture by value (copy of x at definition time)
auto f1 = [x]() { return x; };
 
// Capture by reference
auto f2 = [&x]() { x += 5; };
 
// Capture all by value
auto f3 = [=]() { return x + y; };
 
// Capture all by reference
auto f4 = [&]() { x += y; };
 
// Mix: x by value, y by reference
auto f5 = [x, &y]() { return x + y; };
 
// Capture this (inside a class method)
auto f6 = [this]() { return this->member_; };

Mutable lambdas (modify captured-by-value variables):

int count = 0;
 
auto counter = [count]() mutable {
    return ++count;  // Modifies the COPY of count
};
 
counter();  // returns 1
counter();  // returns 2
// count is still 0 (the original)

nullptr

Replaces the old NULL (which was just 0):

// Old way (ambiguous: is 0 an int or pointer?)
int* p1 = NULL;
 
// Modern way (unambiguous null pointer constant)
int* p2 = nullptr;
 
void foo(int x);
void foo(int* p);
 
// foo(NULL);    // Ambiguous! Could call either overload
foo(nullptr);    // Always calls foo(int*)

constexpr

Compute values at compile time:

// Regular function: computed at runtime
int square(int n) { return n * n; }
 
// constexpr function: can be computed at compile time
constexpr int square_ce(int n) { return n * n; }
 
int main() {
    constexpr int size = square_ce(10);  // Evaluated at compile time!
    int arr[size];  // OK: size is a compile-time constant
 
    // Also works at runtime (flexible)
    int n;
    std::cin >> n;
    int result = square_ce(n);  // Computed at runtime when n is not constexpr
}

constexpr classes (C++14+):

class Point {
public:
    constexpr Point(double x, double y) : x_{x}, y_{y} {}
    constexpr double x() const { return x_; }
    constexpr double y() const { return y_; }
    constexpr double distance_from_origin() const {
        return x_ * x_ + y_ * y_;  // Not sqrt to keep it constexpr
    }
private:
    double x_, y_;
};
 
constexpr Point origin{0.0, 0.0};
constexpr Point p{3.0, 4.0};
constexpr double dist = p.distance_from_origin();  // 25.0 at compile time

Uniform Initialization

Brace initialization works everywhere, prevents narrowing:

// All of these are "uniform initialization"
int x{42};
double pi{3.14};
std::string s{"hello"};
 
struct Point {
    int x, y;
};
Point p{1, 2};  // Aggregate initialization
 
std::vector<int> v{1, 2, 3, 4, 5};  // Initializer list
 
// Prevents narrowing conversions (compile error)
int y = 3.14;   // OK: silently truncates to 3
int z{3.14};    // ERROR: narrowing conversion not allowed!

std::initializer_list

Accept a brace list as parameter:

#include <initializer_list>
#include <algorithm>
 
class Stats {
    std::vector<double> data_;
public:
    Stats(std::initializer_list<double> values)
        : data_{values} {}
 
    double max() const {
        return *std::max_element(data_.begin(), data_.end());
    }
    double min() const {
        return *std::min_element(data_.begin(), data_.end());
    }
};
 
Stats s{1.5, 2.3, 0.7, 4.1, 3.0};
std::cout << s.max() << '\n';  // 4.1

enum class (Scoped Enumerations)

Strongly-typed enums that don't pollute the enclosing scope:

// Old-style enum (pollutes scope, implicit int conversion)
enum Color { RED, GREEN, BLUE };
enum Traffic { RED, YELLOW, GREEN };  // ERROR: RED conflicts!
 
// Modern scoped enum
enum class Color { Red, Green, Blue };
enum class Traffic { Red, Yellow, Green };  // OK!
 
Color c = Color::Red;
Traffic t = Traffic::Red;
 
// No implicit conversion to int
int x = c;           // ERROR
int y = static_cast<int>(c);  // OK
 
// Control underlying type
enum class Flags : uint8_t {
    None  = 0,
    Read  = 1 << 0,
    Write = 1 << 1,
    Exec  = 1 << 2,
};

2. C++11 Smart Pointers

Smart pointers are the modern replacement for raw new/delete. They provide automatic memory management through RAII.

std::unique_ptr

Exclusive ownership. When the unique_ptr is destroyed, the object is deleted.

#include <memory>
 
class Widget {
public:
    Widget(int id) : id_{id} {
        std::cout << "Widget " << id_ << " created\n";
    }
    ~Widget() {
        std::cout << "Widget " << id_ << " destroyed\n";
    }
    int id() const { return id_; }
private:
    int id_;
};
 
int main() {
    // Create a unique_ptr (prefer make_unique)
    auto w1 = std::make_unique<Widget>(1);
    std::cout << "w1 id: " << w1->id() << '\n';
 
    // Can't copy, but can move
    // auto w2 = w1;           // ERROR: can't copy
    auto w2 = std::move(w1);   // OK: w1 is now null
 
    if (!w1) {
        std::cout << "w1 is empty after move\n";
    }
 
    // w2 destroyed here (Widget 1 destroyed)
    // w1 is null, nothing to destroy
}

Factory function pattern:

std::unique_ptr<Shape> makeShape(const std::string& type) {
    if (type == "circle") return std::make_unique<Circle>(5.0);
    if (type == "rect")   return std::make_unique<Rectangle>(3.0, 4.0);
    return nullptr;
}
 
auto shape = makeShape("circle");
if (shape) {
    shape->draw();
}

std::shared_ptr

Shared ownership. Reference-counted: destroyed when the last owner releases it.

#include <memory>
 
int main() {
    auto sp1 = std::make_shared<Widget>(42);
    std::cout << "ref count: " << sp1.use_count() << '\n';  // 1
 
    {
        auto sp2 = sp1;  // COPY: shared ownership
        std::cout << "ref count: " << sp1.use_count() << '\n';  // 2
 
        auto sp3 = sp1;
        std::cout << "ref count: " << sp1.use_count() << '\n';  // 3
    }  // sp2, sp3 destroyed here
 
    std::cout << "ref count: " << sp1.use_count() << '\n';  // 1
    // Widget destroyed when sp1 goes out of scope
}

When to use each:

Smart PointerUse When
unique_ptrSingle owner (vast majority of cases)
shared_ptrGenuinely shared ownership (multiple owners)
weak_ptrBreak circular references with shared_ptr

std::weak_ptr

Non-owning reference to a shared_ptr-managed object. Breaks cycles:

#include <memory>
 
class Node {
public:
    std::shared_ptr<Node> next;
    std::weak_ptr<Node> prev;  // weak to break the cycle!
    int value;
 
    Node(int v) : value{v} {}
};
 
int main() {
    auto n1 = std::make_shared<Node>(1);
    auto n2 = std::make_shared<Node>(2);
 
    n1->next = n2;
    n2->prev = n1;  // weak_ptr, doesn't increment ref count
 
    // Use weak_ptr: must lock() to get shared_ptr temporarily
    if (auto locked = n2->prev.lock()) {
        std::cout << "n1 value: " << locked->value << '\n';
    }
}

3. C++14 Features

C++14 was a refinement release—polishing and extending C++11.

Generic Lambdas

Use auto parameters in lambdas (lambda templates):

// C++11: must specify types
auto add11 = [](int a, int b) { return a + b; };
 
// C++14: generic lambda with auto parameters
auto add14 = [](auto a, auto b) { return a + b; };
 
std::cout << add14(1, 2) << '\n';        // int: 3
std::cout << add14(1.5, 2.5) << '\n';   // double: 4.0
std::cout << add14(std::string{"hi"}, std::string{" there"}) << '\n';

Return Type Deduction

Functions can deduce their return type from return statements:

// C++11: must specify return type
auto sum11(int a, int b) -> int { return a + b; }
 
// C++14: deduced from return statement
auto sum14(int a, int b) { return a + b; }  // deduces int
 
// All return paths must return the same type
auto classify(int n) {
    if (n > 0) return std::string{"positive"};
    if (n < 0) return std::string{"negative"};
    return std::string{"zero"};
}

std::make_unique

C++11 had std::make_shared but forgot std::make_unique. Fixed in C++14:

// C++11: had to write this yourself
auto p = std::unique_ptr<Widget>(new Widget(42));
 
// C++14: clean and exception-safe
auto p = std::make_unique<Widget>(42);

Binary Literals and Digit Separators

// Binary literals (C++14)
int flags = 0b1010'1100;  // 172
 
// Digit separators for readability
long long million = 1'000'000;
double pi = 3.141'592'653'589'793;
unsigned hex = 0xFF'FF'FF'FF;

[[deprecated]] Attribute

Mark functions/types as deprecated with an optional message:

[[deprecated("Use newFunction() instead")]]
void oldFunction() {
    // ...
}
 
void newFunction() {
    // ...
}
 
int main() {
    oldFunction();  // Compiler warning: deprecated
    newFunction();  // OK
}

4. C++17 Features

C++17 brought several highly practical features that simplify everyday code.

Structured Bindings

Unpack tuples, pairs, and structs into named variables:

#include <map>
#include <tuple>
#include <string>
 
int main() {
    // Unpack a pair
    std::pair<std::string, int> p{"Alice", 30};
    auto [name, age] = p;
    std::cout << name << " is " << age << '\n';
 
    // Unpack a tuple
    auto t = std::make_tuple(1, 2.5, std::string{"hello"});
    auto [i, d, s] = t;
 
    // Unpack struct (aggregate)
    struct Point { int x, y; };
    Point pt{10, 20};
    auto [x, y] = pt;
 
    // Map iteration (very useful!)
    std::map<std::string, int> scores = {{"Alice", 95}, {"Bob", 87}};
    for (const auto& [student, score] : scores) {
        std::cout << student << ": " << score << '\n';
    }
 
    // Insert result unpacking
    auto [it, inserted] = scores.insert({"Charlie", 92});
    if (inserted) {
        std::cout << "Inserted: " << it->first << '\n';
    }
}

if and switch with Initializers

Initialize a variable scoped to the if statement:

#include <map>
#include <string>
 
std::map<std::string, int> data = {{"key", 42}};
 
// C++17: init-statement in if
if (auto it = data.find("key"); it != data.end()) {
    std::cout << "Found: " << it->second << '\n';
}
// 'it' is not accessible here
 
// Useful for mutex guards too
if (std::lock_guard lock{mutex}; queue.empty()) {
    // ...
}
 
// Works with switch too
switch (auto ch = getChar(); ch) {
    case 'a': // ...
    case 'b': // ...
    default: break;
}

std::optional

Represents a value that may or may not be present. Replaces sentinel values and nullptr:

#include <optional>
#include <string>
 
// Return optional instead of using -1, null, or exceptions
std::optional<int> parseInt(const std::string& s) {
    try {
        return std::stoi(s);
    } catch (...) {
        return std::nullopt;  // No value
    }
}
 
int main() {
    auto result = parseInt("42");
    if (result) {
        std::cout << "Parsed: " << *result << '\n';
    }
 
    // value_or provides a default
    auto bad = parseInt("not a number");
    std::cout << bad.value_or(-1) << '\n';  // -1
 
    // Optional in function signatures (clearer intent)
    std::optional<std::string> findUser(int id);
 
    // Chaining with and_then (C++23), or manual if-chains
    if (auto user = findUser(42)) {
        std::cout << "User: " << *user << '\n';
    }
}

std::variant

A type-safe union. Holds exactly one of several possible types:

#include <variant>
#include <string>
#include <iostream>
 
// A value that can be int, double, or string
using Value = std::variant<int, double, std::string>;
 
void printValue(const Value& v) {
    // std::visit dispatches based on the active type
    std::visit([](const auto& val) {
        std::cout << val << '\n';
    }, v);
}
 
int main() {
    Value v1 = 42;
    Value v2 = 3.14;
    Value v3 = std::string{"hello"};
 
    printValue(v1);  // 42
    printValue(v2);  // 3.14
    printValue(v3);  // hello
 
    // Check active type
    if (std::holds_alternative<int>(v1)) {
        std::cout << "int: " << std::get<int>(v1) << '\n';
    }
 
    // Pattern matching style with overloaded visitor
    struct Visitor {
        void operator()(int n)                { std::cout << "int: " << n << '\n'; }
        void operator()(double d)             { std::cout << "double: " << d << '\n'; }
        void operator()(const std::string& s) { std::cout << "string: " << s << '\n'; }
    };
 
    std::visit(Visitor{}, v3);
}

Variant as error handling:

using Result = std::variant<std::string, std::error_code>;
 
Result readFile(const std::string& path) {
    // If success: return content
    // If error: return error code
}

std::string_view

A non-owning view into a string. No allocation, no copy:

#include <string_view>
#include <string>
 
// Old way: always copies
void processOld(const std::string& s);
 
// Better: no allocation for string literals
void processNew(std::string_view sv) {
    std::cout << sv.size() << ": " << sv << '\n';
    // sv.substr() returns string_view (no allocation!)
    auto first5 = sv.substr(0, 5);
}
 
int main() {
    processNew("hello world");       // No allocation
    processNew(std::string{"hi"});   // No copy
    const char* c = "raw string";
    processNew(c);                   // Works!
 
    // string_view operations (non-allocating)
    std::string_view sv = "Hello, World!";
    auto pos = sv.find("World");
    if (pos != std::string_view::npos) {
        std::cout << sv.substr(pos) << '\n';
    }
}

Warning: string_view must not outlive the string it references.

[[nodiscard]], [[fallthrough]], [[maybe_unused]]

Standard attributes for better warnings:

// [[nodiscard]]: warn if return value is ignored
[[nodiscard]] int computeResult() { return 42; }
 
// [[nodiscard]] with message (C++20)
[[nodiscard("Check error code!")]]
std::error_code openFile(const std::string& path);
 
// [[fallthrough]]: intentional switch fallthrough
switch (n) {
    case 1:
        doSomething();
        [[fallthrough]];  // Intentional!
    case 2:
        doMore();
        break;
}
 
// [[maybe_unused]]: suppress unused variable warnings
[[maybe_unused]] int debug_flag = 1;
 
void function([[maybe_unused]] int param) {
    // param not used in release build
    #ifdef DEBUG
    std::cout << param << '\n';
    #endif
}

std::filesystem

Portable filesystem operations:

#include <filesystem>
#include <iostream>
 
namespace fs = std::filesystem;
 
int main() {
    fs::path p = "/home/user/documents";
 
    // Path operations
    std::cout << p.filename() << '\n';   // "documents"
    std::cout << p.parent_path() << '\n'; // "/home/user"
    std::cout << p.extension() << '\n';   // "" (no extension)
 
    fs::path file = p / "report.txt";   // path concatenation
    std::cout << file << '\n';           // "/home/user/documents/report.txt"
 
    // Directory iteration
    for (const auto& entry : fs::directory_iterator(".")) {
        if (entry.is_regular_file()) {
            std::cout << entry.path().filename() << " ("
                      << entry.file_size() << " bytes)\n";
        }
    }
 
    // File operations
    fs::create_directories("output/data");  // Create nested dirs
    fs::copy("source.txt", "dest.txt");
    fs::remove("old_file.txt");
 
    // Check existence
    if (fs::exists("config.json")) {
        std::cout << "Config found\n";
    }
}

Fold Expressions

Apply binary operators to variadic template parameter packs:

// Sum of any number of arguments
template<typename... Args>
auto sum(Args... args) {
    return (args + ...);  // Unary right fold
    // Expands to: args1 + (args2 + (args3 + ...))
}
 
template<typename... Args>
bool allTrue(Args... args) {
    return (... && args);  // Unary left fold
}
 
template<typename T, typename... Args>
void print(T first, Args... rest) {
    std::cout << first;
    ((std::cout << ", " << rest), ...);  // Fold over comma operator
    std::cout << '\n';
}
 
int main() {
    std::cout << sum(1, 2, 3, 4, 5) << '\n';  // 15
    std::cout << allTrue(true, true, false) << '\n';  // false
    print(1, "hello", 3.14, 'x');  // 1, hello, 3.14, x
}

5. C++20 Features

C++20 was the largest C++ update since C++11, adding four major features (Concepts, Ranges, Coroutines, Modules) plus many smaller ones.

Concepts

Constrain template type parameters with named requirements:

#include <concepts>
 
// Define a concept: T must support < and ==
template<typename T>
concept Comparable = requires(T a, T b) {
    { a < b } -> std::convertible_to<bool>;
    { a == b } -> std::convertible_to<bool>;
};
 
// Use concept to constrain template
template<Comparable T>
T max(T a, T b) {
    return (a < b) ? b : a;
}
 
// Standard library concepts
template<std::integral T>
T factorial(T n) {
    if (n <= 1) return 1;
    return n * factorial(n - 1);
}
 
template<std::floating_point T>
T divide(T a, T b) {
    return a / b;
}
 
// Abbreviated function templates (terse syntax)
auto add(std::integral auto a, std::integral auto b) {
    return a + b;
}

Built-in standard concepts:

// <concepts>
std::same_as<T, U>      // T and U are exactly the same type
std::derived_from<T, U> // T is derived from U
std::convertible_to<T, U>
std::integral<T>        // T is an integer type
std::floating_point<T>  // T is a floating-point type
std::copyable<T>        // T is copyable
std::movable<T>         // T is movable
std::invocable<F, Args> // F can be called with Args

Improved error messages: With concepts, the compiler says "T does not satisfy Comparable" instead of 50 lines of template substitution errors.

Ranges Library

Composable, lazy range algorithms:

#include <ranges>
#include <vector>
#include <iostream>
 
int main() {
    std::vector<int> nums = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10};
 
    // Compose views with | (pipe operator)
    auto result = nums
        | std::views::filter([](int n) { return n % 2 == 0; }) // even numbers
        | std::views::transform([](int n) { return n * n; })    // square them
        | std::views::take(3);                                  // first 3
 
    for (auto n : result) {
        std::cout << n << ' ';  // 4 16 36
    }
    std::cout << '\n';
 
    // Ranges algorithms (no begin/end needed)
    std::vector<int> data = {5, 2, 8, 1, 9};
    std::ranges::sort(data);  // [1, 2, 5, 8, 9]
 
    auto found = std::ranges::find(data, 5);
    if (found != data.end()) {
        std::cout << "Found: " << *found << '\n';
    }
}

Useful views:

namespace views = std::views;
 
// Generate ranges
auto zero_to_9 = views::iota(0, 10);        // 0, 1, ..., 9
auto naturals = views::iota(1);             // 1, 2, 3, ... (infinite)
 
// Transform views
auto uppercase = str | views::transform(::toupper);
auto split_words = text | views::split(' ');
 
// Combine
auto first5evens = views::iota(1)
    | views::filter([](int n) { return n % 2 == 0; })
    | views::take(5);
// 2, 4, 6, 8, 10 — evaluated lazily!

Three-Way Comparison (<=>)

The "spaceship operator" generates all comparison operators:

#include <compare>
 
struct Version {
    int major, minor, patch;
 
    // Define <=> and == gets <, <=, >, >= for free
    auto operator<=>(const Version&) const = default;
    bool operator==(const Version&) const = default;
};
 
int main() {
    Version v1{1, 2, 3};
    Version v2{1, 3, 0};
 
    std::cout << (v1 < v2) << '\n';   // true
    std::cout << (v1 > v2) << '\n';   // false
    std::cout << (v1 == v2) << '\n';  // false
    std::cout << (v1 <= v2) << '\n';  // true
}

std::span

Non-owning view over a contiguous sequence (array, vector, raw array):

#include <span>
#include <vector>
#include <iostream>
 
// Old: pass pointer + size (error-prone)
void printOld(const int* data, size_t size) {
    for (size_t i = 0; i < size; ++i)
        std::cout << data[i] << ' ';
}
 
// Modern: span (safe, works with any contiguous container)
void printModern(std::span<const int> data) {
    for (const auto& n : data) {
        std::cout << n << ' ';
    }
    std::cout << '\n';
}
 
int main() {
    std::vector<int> vec = {1, 2, 3, 4, 5};
    int arr[] = {6, 7, 8, 9, 10};
 
    printModern(vec);           // Works with vector
    printModern(arr);           // Works with C array
    printModern({arr, 3});      // First 3 elements
    printModern(vec);           // Full vector
 
    // Span subviews (no allocation)
    std::span<int> s{vec};
    auto first3 = s.first(3);   // [1, 2, 3]
    auto last2  = s.last(2);    // [4, 5]
    auto middle = s.subspan(1, 3);  // [2, 3, 4]
}

std::format

Python-style string formatting (replaces printf and stringstream):

#include <format>
#include <string>
 
int main() {
    // Basic formatting
    std::string s = std::format("Hello, {}!", "World");
    std::cout << s << '\n';  // Hello, World!
 
    // Positional arguments
    auto msg = std::format("{0} + {1} = {2}", 3, 4, 3 + 4);
    std::cout << msg << '\n';  // 3 + 4 = 7
 
    // Format specifiers (similar to printf)
    std::cout << std::format("{:>10}", "right") << '\n';   // right-align width 10
    std::cout << std::format("{:<10}", "left") << '\n';    // left-align
    std::cout << std::format("{:^10}", "center") << '\n';  // center-align
    std::cout << std::format("{:.3f}", 3.14159) << '\n';   // 3.142
    std::cout << std::format("{:08x}", 255) << '\n';       // 000000ff
    std::cout << std::format("{:,}", 1234567) << '\n';     // 1,234,567
 
    // std::print (C++23) — directly prints without intermediate string
}

Designated Initializers

Initialize aggregate members by name:

struct Config {
    int port = 8080;
    std::string host = "localhost";
    bool ssl = false;
    int timeout = 30;
};
 
// Old: positional (error-prone, fragile)
Config c1{9090, "example.com", true, 60};
 
// C++20: designated initializers (clear and order-independent for clarity)
Config c2{
    .port = 9090,
    .host = "example.com",
    .ssl = true,
    .timeout = 60,
};
 
// Only specify what you need; rest get defaults
Config c3{
    .port = 3000,
    .ssl = true,
};

consteval and constinit

// consteval: MUST be evaluated at compile time (unlike constexpr which can be runtime)
consteval int square(int n) {
    return n * n;
}
 
constexpr int a = square(5);  // OK: compile time
// int x; square(x);          // ERROR: x is not constexpr
 
// constinit: variable initialized at compile time, but not necessarily const
constinit int counter = 0;  // Initialized at compile time
// counter = 5;              // OK: it's not const, can be modified

Coroutines (Basics)

Coroutines are functions that can suspend and resume. C++20 provides the machinery; libraries like cppcoro provide convenient wrappers:

#include <coroutine>
#include <generator>  // C++23, or use cppcoro for C++20
 
// Generator coroutine: produces values lazily
std::generator<int> fibonacci() {
    int a = 0, b = 1;
    while (true) {
        co_yield a;       // Suspend and yield value
        auto next = a + b;
        a = b;
        b = next;
    }
}
 
int main() {
    auto fibs = fibonacci();
    for (int i = 0; i < 10; ++i) {
        auto it = fibs.begin();
        std::cout << *it << ' ';
        ++it;
    }
}

The three coroutine keywords:

  • co_yield: Suspend and produce a value
  • co_return: End the coroutine (optionally returning a value)
  • co_await: Suspend until an awaitable completes (used in async I/O)

6. C++23 Features

C++23 continues refining the language with practical additions.

std::expected

Better error handling than exceptions or error codes. Like optional, but carries an error value when empty:

#include <expected>
#include <string>
#include <cerrno>
 
enum class ParseError { InvalidInput, Overflow, Empty };
 
std::expected<int, ParseError> parsePositive(std::string_view s) {
    if (s.empty()) return std::unexpected(ParseError::Empty);
 
    int result = 0;
    for (char c : s) {
        if (c < '0' || c > '9')
            return std::unexpected(ParseError::InvalidInput);
        result = result * 10 + (c - '0');
        if (result < 0)
            return std::unexpected(ParseError::Overflow);
    }
    return result;
}
 
int main() {
    auto result = parsePositive("42");
    if (result) {
        std::cout << "Parsed: " << *result << '\n';
    } else {
        switch (result.error()) {
            case ParseError::InvalidInput: std::cout << "Invalid input\n"; break;
            case ParseError::Empty: std::cout << "Empty input\n"; break;
            case ParseError::Overflow: std::cout << "Overflow\n"; break;
        }
    }
 
    // value_or for defaults
    std::cout << parsePositive("bad").value_or(-1) << '\n';  // -1
 
    // Monadic operations
    auto doubled = parsePositive("21")
        .transform([](int n) { return n * 2; })
        .value_or(0);
    std::cout << doubled << '\n';  // 42
}

std::print and std::println

Direct, type-safe printing without std::cout:

#include <print>
 
int main() {
    std::print("Hello, {}!\n", "World");   // No trailing newline
    std::println("Hello, {}!", "World");   // Adds newline automatically
 
    std::println("{} + {} = {}", 1, 2, 3);
    std::println("{:.2f}", 3.14159);       // 3.14
 
    // Print to stderr
    std::println(stderr, "Error: {}", "something went wrong");
}

Deducing this

Explicit this parameter enables CRTP-free polymorphism and fluent builders:

struct Builder {
    std::string name;
    int age = 0;
 
    // 'this' is explicit, enabling perfect forwarding
    template<typename Self>
    auto&& setName(this Self&& self, std::string n) {
        self.name = std::move(n);
        return std::forward<Self>(self);
    }
 
    template<typename Self>
    auto&& setAge(this Self&& self, int a) {
        self.age = a;
        return std::forward<Self>(self);
    }
};
 
Builder b;
b.setName("Alice").setAge(30);

std::flat_map and std::flat_set

Sorted, contiguous containers. Better cache performance than tree-based std::map:

#include <flat_map>
#include <flat_set>
 
int main() {
    // Like std::map, but backed by sorted vector
    std::flat_map<std::string, int> scores;
    scores["Alice"] = 95;
    scores["Bob"] = 87;
    // Stored in sorted, contiguous memory — fast iteration
 
    // Like std::set, but backed by sorted vector
    std::flat_set<int> primes{2, 3, 5, 7, 11};
    primes.insert(13);
    // O(log n) lookup but better cache behavior than std::set
}

if consteval

Detect at compile time whether you're in a constant-evaluated context:

constexpr double computePi() {
    if consteval {
        // Compile-time path (can use compile-time-only operations)
        return 3.14159265358979323846L;
    } else {
        // Runtime path
        return std::numbers::pi;
    }
}

7. Feature Comparison Across Standards

// Task: Filter even numbers, square them, collect to vector
 
// C++03 style
std::vector<int> result_03;
for (size_t i = 0; i < nums.size(); ++i) {
    if (nums[i] % 2 == 0) {
        result_03.push_back(nums[i] * nums[i]);
    }
}
 
// C++11 style
std::vector<int> result_11;
std::copy_if(nums.begin(), nums.end(), std::back_inserter(result_11),
    [](int n) { return n % 2 == 0; });
std::transform(result_11.begin(), result_11.end(), result_11.begin(),
    [](int n) { return n * n; });
 
// C++20 style (ranges)
auto result_20 = nums
    | std::views::filter([](int n) { return n % 2 == 0; })
    | std::views::transform([](int n) { return n * n; })
    | std::ranges::to<std::vector>();

8. What to Adopt First

Not all features need equal attention. Here's a practical adoption order:

Adopt immediately (C++11/14):

FeatureWhy
autoReduces verbosity, avoids type mismatches
Range-forCleaner iteration, fewer bugs
Smart pointersEliminate delete and memory leaks
LambdasReplace function objects and bind
nullptrType-safe null
override/finalCatch overriding mistakes at compile time
enum classScoped, type-safe enums
make_unique/make_sharedException-safe allocation

Adopt as needed (C++17):

FeatureWhy
Structured bindingsCleaner pair/tuple/map iteration
std::optionalReplace null returns and sentinels
std::string_viewZero-cost string parameters
[[nodiscard]]Prevent ignored error returns
if with initializerScope-limit variables

Learn and use selectively (C++20):

FeatureWhy
ConceptsBetter template errors, clearer constraints
RangesComposable, readable data pipelines
std::formatReplace printf and stringstream
Designated initializersClearer struct construction
<=>Automatic comparison operators

Common Mistakes

1. Dangling string_view

// DANGEROUS: string_view outlives the string
std::string_view sv;
{
    std::string temp = "hello";
    sv = temp;  // OK here
}  // temp destroyed!
// sv now dangles — undefined behavior to use it

Fix: Don't store string_view if the string might be destroyed.

2. Capturing Local by Reference in Long-Lived Lambdas

std::function<int()> make_counter_bad() {
    int count = 0;
    return [&count]() { return ++count; };  // DANGLING: count is gone!
}
 
std::function<int()> make_counter_good() {
    int count = 0;
    return [count]() mutable { return ++count; };  // OK: copy!
}

3. Using shared_ptr Everywhere

// BAD: shared_ptr when there's only one owner
auto sp = std::make_shared<Widget>(42);
 
// GOOD: unique_ptr for single ownership
auto up = std::make_unique<Widget>(42);

shared_ptr has overhead (reference counting, control block). Use unique_ptr by default.

4. Ignoring [[nodiscard]]

[[nodiscard]] std::error_code writeFile(const std::string& path, const std::string& data);
 
// WARNING: result ignored
writeFile("output.txt", "content");
 
// CORRECT: check the result
if (auto err = writeFile("output.txt", "content")) {
    std::cerr << "Write failed: " << err.message() << '\n';
}

Summary

C++11: auto, range-for, lambdas, smart pointers, nullptr, constexpr, enum class
C++14: Generic lambdas, return type deduction, make_unique, digit separators
C++17: Structured bindings, optional, variant, string_view, filesystem, [[nodiscard]]
C++20: Concepts, Ranges, format, span, spaceship operator, designated initializers
C++23: std::expected, std::print, deducing this, flat_map/flat_set

What's Next?

Additional Resources


Series: Modern C++ Learning Roadmap
Previous: Phase 2: Object-Oriented Programming
Next: Deep Dive: Memory Management & Smart Pointers

📬 Subscribe to Newsletter

Get the latest blog posts delivered to your inbox every week. No spam, unsubscribe anytime.

We respect your privacy. Unsubscribe at any time.

💬 Comments

Sign in to leave a comment

We'll never post without your permission.