C++ features history

C++XX: version of C++ released in 20XX

C++11
C++14
C++17
C++20
C++23

C++11

The big update that brought modern C++.

Move semantics

Before C++11, when you passed or returned objects by value, copies were always made. For large objects (like vectors, strings, or complex data structures), this could be very expensive.

Example:

std::vector make_vector() {
    std::vector v(1000000, 42);
    return v; // expensive copy before C++11
}

Even though the temporary v was about to be destroyed, C++ had to copy it into the return value.

Move semantics lets you transfer ownership of resources (like heap memory or file handles) from one object to another, instead of copying them. A move is basically a cheap transfer of internal pointers — no deep duplication.

Before C++11, we already had lvalues and rvalues, but they became much more important.

C++11 introduced std::move() to explicitly indicate that an object can be "moved from", simply meaning that it casts its argument to an rvalue. One common use case is in move constructors, for example:

#include <iostream>
#include <string>
#include <utility> // for std::move

struct S {
    std::string data;

    S(const std::string& d) : data(d) { std::cout << "copy constructor\n"; }

    S(std::string&& d) : data(std::move(d)) {
        // Even though 'd' is an rvalue reference, it's a *named variable*, so in reality an lvalue!
        // This is because every named variable has an address and can be used again, which makes 
        // it an lvalue expression, even if its type is an rvalue reference (T&&). We have to make
        // sure to use std::move to cast it back to an rvalue so that the string's move constructor
        // is called instead of the copy constructor!
        std::cout << "move constructor\n";
    }
};

int main() {
    std::string s = "hello";

    S a(s);             // uses copy constructor — 's' is an lvalue
    S b(std::move(s));  // uses move constructor — 's' turned into rvalue
    S c("temp");        // uses move constructor — temporary is an rvalue
}
Perfect forwarding

Additionally, C++11 introduced std::forward, which is a special cast used in template functions to preserve how an argument was originally passed — whether it was an lvalue or an rvalue.

std::forward preserves the value category (lvalue vs rvalue) of an argument when passing it to another function. This enables perfect forwarding — efficiently forwarding arguments without unnecessary copies. It's what makes std::make_unique, std::make_shared, and emplace_back possible.

#include <iostream>
#include <string>
#include <utility>

struct S {
    std::string data;

    S(const std::string& d) : data(d) { std::cout << "copy constructor\n"; }
    S(std::string&& d) : data(std::move(d)) { std::cout << "move constructor\n"; }
};

template<typename T, typename Arg>
T make_object(Arg&& arg) {
    return T(std::forward<Arg>(arg)); // preserves lvalue/rvalue
}

int main() {
    std::string s = "hello";

    S a = make_object<S>(s);               // copy constructor
    S b = make_object<S>(std::move(s));    // move constructor
    S c = make_object<S>("temp");          // move constructor
}

Smart pointers

Before C++11, manual memory management with raw pointers was error-prone, leading to memory leaks and dangling pointers. Smart pointers automatically manage object lifetime using RAII (Resource Acquisition Is Initialization).

std::unique_ptr - Exclusive ownership:

#include <memory>
#include <iostream>

struct Widget {
    Widget() { std::cout << "Widget created\n"; }
    ~Widget() { std::cout << "Widget destroyed\n"; }
};

void example() {
    std::unique_ptr<Widget> ptr = std::make_unique<Widget>();
    // Widget automatically destroyed when ptr goes out of scope
    
    // Cannot copy (deleted copy constructor)
    // auto ptr2 = ptr; // ERROR!
    
    // Can move ownership
    auto ptr2 = std::move(ptr); // ptr is now nullptr
}

std::shared_ptr - Shared ownership with reference counting:

void example() {
    std::shared_ptr<Widget> ptr1 = std::make_shared<Widget>();
    std::cout << "Count: " << ptr1.use_count() << "\n"; // 1
    
    {
        std::shared_ptr<Widget> ptr2 = ptr1; // copy allowed
        std::cout << "Count: " << ptr1.use_count() << "\n"; // 2
    } // ptr2 destroyed, count decreases
    
    std::cout << "Count: " << ptr1.use_count() << "\n"; // 1
} // Widget destroyed when last shared_ptr dies

The circular reference problem:

struct Node {
    std::shared_ptr<Node> next;
    std::shared_ptr<Node> prev; // both are shared_ptr - BAD!
    ~Node() { std::cout << "Node destroyed\n"; }
};

void bad_example() {
    auto node1 = std::make_shared<Node>(); // node1 refcount = 1
    auto node2 = std::make_shared<Node>(); // node2 refcount = 1
    
    node1->next = node2; // node2 refcount = 2 (node1 owns it too)
    node2->prev = node1; // node1 refcount = 2 (node2 owns it too)
    
    // When function exits:
    // - local node1 destroyed → node1 refcount = 1 (still owned by node2->prev)
    // - local node2 destroyed → node2 refcount = 1 (still owned by node1->next)
    // Both nodes keep each other alive forever! Memory leak!
    // "Node destroyed" is NEVER printed
}

Each node keeps the other alive because they both have a reference count > 0. Neither can be destroyed, causing a memory leak.

std::weak_ptr - Solution: Non-owning reference that breaks cycles:

struct Node {
    std::shared_ptr<Node> next; // ownership: forward direction
    std::weak_ptr<Node> prev;   // non-owning: backward reference
    ~Node() { std::cout << "Node destroyed\n"; }
};

void good_example() {
    auto node1 = std::make_shared<Node>(); // node1 refcount = 1
    auto node2 = std::make_shared<Node>(); // node2 refcount = 1
    
    node1->next = node2; // node2 refcount = 2
    node2->prev = node1; // node1 refcount still 1 (weak_ptr doesn't count!)
    
    // To use weak_ptr, must convert to shared_ptr first
    if (auto shared = node2->prev.lock()) {
        // shared is a valid shared_ptr to node1
        std::cout << "node1 is still alive\n";
    }
    
    // When function exits:
    // - local node1 destroyed → node1 refcount = 0 → node1 deleted
    // - local node2 destroyed → node2 refcount = 1 → decrements to 0 → node2 deleted
    // "Node destroyed" printed twice - no leak!
}

weak_ptr observes but doesn't own. It must be converted to shared_ptr via .lock() before use (returns empty shared_ptr if object was destroyed).

Lambda expressions

Before C++11, passing simple functions to algorithms required defining separate function objects or functions. Lambdas allow you to define anonymous functions inline, making code more concise and readable.

Basic syntax: [capture](parameters) -> return_type { body }

auto add = [](int a, int b) { return a + b; };
std::cout << add(3, 4) << "\n"; // 7

int x = 10;
auto add_x = [x](int y) { return x + y; };
std::cout << add_x(5) << "\n"; // 15

int counter = 0;
auto increment = [&counter]() { counter++; };
increment();
increment();
std::cout << counter << "\n"; // 2

Capture modes:

Strongly typed enums (enum class)

Before C++11, traditional enums had several problems: they implicitly converted to integers, polluted the surrounding namespace, and allowed comparisons between different enum types.

C++11 introduced enum class (also called scoped enums) to solve these issues:

enum Color { Red, Green, Blue }; // old-style enums
enum Fruit { Apple, Orange, Banana };

enum class Color { Red, Green, Blue }; // introduction of enum class
enum class Fruit { Apple, Orange, Banana };

int main() {
    Color c_old = Red;           // Red pollutes the enclosing namespace
    int x_old = c_old;               // implicit conversion to int — dangerous!
    
    Color c_new = Color::Red;    // must use scope resolution
    int x_new = static_cast<int>(c_new); // explicit cast required
    
    if (Red == Apple) {}      // compiles! different enum types compared
    if (Color::Red == Fruit::Apple) {} // ERROR: can't compare different enum types
}

Specifying underlying type:

enum class Status : uint8_t {  // uses 1 byte instead of default int
    OK = 0,
    Warning = 1,
    Error = 2
};

// Old enums can also specify underlying type in C++11
enum OldStyle : uint16_t { A, B, C };

Other C++11 features

Auto

Compiler automatically deduces the type:

std::vector<int> vec = {1, 2, 3};
auto it = vec.begin();  // instead of std::vector<int>::iterator
auto lambda = [](int x) { return x * 2; };
Range-based for loops

Clean syntax for iterating over containers:

std::vector<int> vec = {1, 2, 3, 4, 5};

for (auto x : vec) {}        // by value (copy)
for (auto& x : vec) {}       // by reference (can modify)
for (const auto& x : vec) {} // by const reference (efficient, read-only)
nullptr

Type-safe null pointer that fixes ambiguity issues with NULL (represented as a 0 integer):

void func(int x) {}
void func(char* ptr) {}

func(NULL);    // calls int version - wrong!
func(nullptr); // calls pointer version - correct!

int* p = nullptr; // clear and type-safe
constexpr

Evaluate at compile time:

constexpr int square(int x) { return x * x; }

constexpr int size = square(5); // evaluated at compile time
int arr[size];                  // size must be compile-time constant
Trailing return types

Return type after parameters using ->; useful when it depends on arguments.

template
auto add(T a, U b) -> decltype(a + b) { return a + b; }

auto lam = [](int x) -> long { return x; }; // trailing return on lambda
Variadic templates

Templates that accept any number of arguments:

template<typename T, typename... Args>
void print(T first, Args... rest) {
    std::cout << first << " ";
    print(rest...);
}

print(1, 2.5, "hello", 'c'); // prints: 1 2.5 hello c

Threading

TODO: Add content about C++11 threading library (std::thread, std::mutex, std::lock_guard, std::condition_variable, std::atomic, etc.)

C++14

Small new features and improvements.

Generic lambdas (auto parameters)

Lambda parameters can use auto to be type-generic.

auto add = [](auto a, auto b) { return a + b; };
static_assert(add(1, 2) == 3);
static_assert(add(1.5, 2.5) == 4.0);

Auto return type deduction

Functions can deduce return type with auto (no trailing return needed).

auto sum(int a, int b) { return a + b; }   // returns int
auto make_vec(int n) { return std::vector(n); } // returns std::vector

std::make_unique

Factory for std::unique_ptr; safe and concise object creation.

struct W { W(int, std::string) {} };
auto p = std::make_unique(42, "ok");

Relaxed constexpr

constexpr functions may use local variables, loops, and branches.

constexpr int fact(int n) {
    int r = 1;
    for (int i = 2; i <= n; ++i) r *= i;
    return r;
}
static_assert(fact(5) == 120);

Binary literals and digit separators

Binary integer literals and apostrophe digit separators improve readability.

int flags = 0b1010'1100;     // 172
auto million = 1'000'000;    // 1000000

C++17

A practical release focusing on cleaner code and performance improvements.

Structured bindings

Structured bindings allow unpacking tuples, pairs, and structs directly into named variables.

#include <tuple>
#include <string>
#include <iostream>

std::tuple<int, std::string> get_person() {
    return {25, "Alice"};
}

int main() {
    auto [age, name] = get_person(); // unpack directly
    std::cout << name << " is " << age << " years old\n";
}

They can also be used with std::map or std::unordered_map iteration:

std::map<std::string, int> scores = {{"Alice", 90}, {"Bob", 80}};
for (auto [name, score] : scores)
    std::cout << name << ": " << score << "\n";

If / switch with initializers

You can now initialize a variable directly inside an if or switch statement, limiting its scope to that statement.

if (auto it = scores.find("Bob"); it != scores.end()) {
    std::cout << "Bob's score: " << it->second << "\n";
} else {
    std::cout << "Bob not found\n";
}

std::optional, std::variant, std::any

New standard utilities to represent optional or flexible data.

std::optional

Represents an optional value that may or may not exist.

#include <optional>

std::optional<int> find_user_id(std::string name) {
    if (name == "Alice") return 42;
    return std::nullopt;
}

int main() {
    if (auto id = find_user_id("Alice")) 
        std::cout << "ID = " << *id << "\n";
    else 
        std::cout << "User not found\n";
}
std::variant

Type-safe union — holds one of several types at a time.

#include <variant>

std::variant<int, std::string> data;

data = 10;
std::cout << std::get<int>(data) << "\n";

data = "hello";
std::cout << std::get<std::string>(data) << "\n";

// std::get_if returns pointer or nullptr
if (auto p = std::get_if<int>(&data))
    std::cout << "int: " << *p << "\n";
else
    std::cout << "not an int\n";
std::any

A type-safe container for a single value of any type.

#include <any>

std::any a = 5;
std::cout << std::any_cast<int>(a) << "\n";

a = std::string("Hello");
std::cout << std::any_cast<std::string>(a) << "\n";

std::filesystem

The new filesystem library provides portable tools for file and directory manipulation.

#include <filesystem>
#include <iostream>

namespace fs = std::filesystem;

int main() {
    fs::create_directory("example");
    for (auto& p : fs::directory_iterator(".")) {
        std::cout << p.path() << "\n";
    }
}

Fold expressions

Simplify variadic templates by allowing concise reduction over parameter packs.

template<typename... Args>
auto sum(Args... args) {
    return (args + ...); // expands to (((arg1 + arg2) + arg3) + ...)
}

int main() {
    std::cout << sum(1, 2, 3, 4) << "\n"; // 10
}

Inline variables

Allow defining variables in headers without violating the one-definition rule.

struct Config {
    static inline const std::string name = "App";
    static inline int version = 2;
};

constexpr improvements

More operations allowed in constexpr functions — including if, switch, and most loops.

Other small but useful C++17 features

[[nodiscard]] int compute() { return 42; }

int main() {
    compute(); // compiler may warn: 'nodiscard' result ignored
}

C++20

A major modernizing release — introducing concepts, ranges, coroutines, and modules.

Concepts

Concepts allow you to constrain template parameters with readable, compile-time checks.

#include <concepts>
#include <iostream>

template<std::integral T>
T add(T a, T b) { return a + b; }

int main() {
    std::cout << add(3, 4) << "\n";    // OK
    // add(3.5, 2.1); // compile error: not integral
}

You can also define your own concepts:

template<typename T>
concept Incrementable = requires(T x) {
    ++x; x++;
};

template<Incrementable T>
void increment(T& value) { ++value; }

Ranges

The Ranges library introduces a powerful new way to compose algorithms.

#include <ranges>
#include <vector>
#include <iostream>

int main() {
    std::vector<int> v = {1, 2, 3, 4, 5};

    auto even_doubled =
        v | std::views::filter([](int n){ return n % 2 == 0; })
          | std::views::transform([](int n){ return n * 2; });

    for (int n : even_doubled)
        std::cout << n << " "; // 4 8
}

Ranges make algorithms composable and lazy — they only compute what’s needed.

Coroutines

Coroutines enable writing asynchronous or lazy computations with co_await, co_yield, and co_return.

#include <coroutine>
#include <iostream>

struct Generator {
    struct promise_type;
    using handle_type = std::coroutine_handle<promise_type>;
    struct promise_type {
        int current_value;
        auto get_return_object() { return Generator{handle_type::from_promise(*this)}; }
        std::suspend_always initial_suspend() { return {}; }
        std::suspend_always yield_value(int value) { current_value = value; return {}; }
        std::suspend_always final_suspend() noexcept { return {}; }
        void return_void() {}
        void unhandled_exception() { std::exit(1); }
    };
    handle_type h;
    Generator(handle_type h) : h(h) {}
    ~Generator() { h.destroy(); }
    bool next() { h.resume(); return !h.done(); }
    int value() const { return h.promise().current_value; }
};

Generator counter(int n) {
    for (int i = 0; i < n; ++i)
        co_yield i;
}

int main() {
    auto g = counter(3);
    while (g.next())
        std::cout << g.value() << "\n"; // 0 1 2
}

Modules

Modules are a new way to organize code — faster to compile and easier to maintain than headers.

// math.ixx
export module math;
export int add(int a, int b) { return a + b; }

// main.cpp
import math;
#include <iostream>
int main() {
    std::cout << add(2, 3) << "\n";
}

constexpr improvements

C++20 allows dynamic memory, virtual calls, and most STL containers in constexpr code.

constexpr int factorial(int n) {
    if (n <= 1) return 1;
    else return n * factorial(n - 1);
}
static_assert(factorial(5) == 120);

The <=> (spaceship) operator

Defines all comparison operators in one go.

#include <compare>

struct Point {
    int x, y;
    auto operator<=>(const Point&) const = default; // generates ==, <, >, etc.
};

Calendar & timezones

The <chrono> library now includes calendars, time zones, and parsing/formatting.

#include <chrono>
#include <iostream>

int main() {
    using namespace std::chrono;
    auto today = floor<days>(system_clock::now());
    std::cout << "Days since epoch: " << today.time_since_epoch().count() << "\n";
}

Other notable C++20 features

#include <format>
std::string msg = std::format("Hello, {}!", "world");
std::cout << msg; // Hello, world!

C++23

A refinement release — polishing C++20 and adding practical modern features.

std::expected

std::expected represents a value or an error without throwing exceptions.

#include <expected>
#include <iostream>
#include <string>

std::expected<int, std::string> parse_number(const std::string& s) {
    try {
        return std::stoi(s);
    } catch (...) {
        return std::unexpected("Invalid number");
    }
}

int main() {
    auto result = parse_number("42");
    if (result)
        std::cout << "Parsed: " << *result << "\n";
    else
        std::cerr << "Error: " << result.error() << "\n";
}

std::print

A simpler, faster replacement for std::cout, inspired by Python’s print().

#include <print>

int main() {
    std::print("Hello, {}!\n", "world");
    std::println("Pi ≈ {:.3f}", 3.14159);
}

Deducing this

Member functions can now declare this as a parameter, improving generic and fluent APIs.

struct Point {
    int x, y;

    auto& move(this auto& self, int dx, int dy) {
        self.x += dx;
        self.y += dy;
        return self; // allows chaining
    }
};

int main() {
    Point p{1, 2};
    p.move(2, 3).move(1, 1);
    std::println("({}, {})", p.x, p.y); // (4, 6)
}

Ranges updates

C++23 extends the Ranges library with new views and algorithms.

#include <ranges>
#include <vector>
#include <iostream>

int main() {
    std::vector<int> a = {1, 2, 3};
    std::vector<char> b = {'A', 'B', 'C'};

    for (auto [num, letter] : std::views::zip(a, b))
        std::cout << num << " - " << letter << "\n";
}

constexpr and language refinements

More standard library features are now constexpr, including std::string, std::vector, and algorithms.

constexpr bool all_even() {
    std::vector v = {2, 4, 6};
    return std::ranges::all_of(v, [](int n){ return n % 2 == 0; });
}
static_assert(all_even());

std::mdspan

A lightweight, multidimensional view for numerical computing.

#include <mdspan>
#include <iostream>

int main() {
    double data[6] = {1, 2, 3, 4, 5, 6};
    std::mdspan m(data, 2, 3); // 2 rows, 3 columns
    std::cout << m(1, 2) << "\n"; // 6
}

Multirange for-loops

You can now iterate multiple ranges in parallel directly in a range-based for loop.

std::vector<int> a = {1, 2, 3};
std::vector<char> b = {'x', 'y', 'z'};

for (auto [num, letter] : std::views::zip(a, b))
    std::print("{}{}\n", num, letter);

Other notable C++23 additions