Modern C++ Features (11/14/17/20)

Reviewed & published by Brayan K

By the end of this lesson you'll write modern, idiomatic C++: let the compiler deduce types with auto, loop cleanly with range-based for, pass behaviour around with lambdas, model "maybe a value" with std::optional, unpack data with structured bindings, and compute at compile time with constexpr.

Part of the free C++ course at LearnCodingFast — hands-on lessons with worked examples and the output they print, plus practice exercises and a quick quiz.

What You'll Learn

💡 Real-World Analogy

Think of "old" C++ as filling in a paper form where you must hand-write the same details in every box. Modern C++ is the smart online form: auto auto-fills the type for you, range-based for reads every row without you tracking line numbers, and std::optional is a field that's clearly marked "may be left blank" instead of you writing -1 and hoping the next reader knows it means "empty". The language does the bookkeeping so you can focus on the meaning.

🧭 A Quick Map of the Standards

StandardHeadline features
C++11auto, range-based for, lambdas, nullptr, brace init
C++14generic lambdas, relaxed constexpr
C++17structured bindings, std::optional, if/switch with initialiser
C++20concepts, ranges, constexpr almost everywhere

You select a standard with a compiler flag, e.g. -std=c++17 or -std=c++20. Our runner uses a modern standard, so every example below runs as written.

1. auto, Range-Based for, nullptr & Brace Init

auto tells the compiler "work out the type from the value" — it's still fully static, just less typing. Range-based for walks every element without you managing an index; reach for const auto& by default so you read each element with no copying. nullptr is the type-safe "no pointer" (never use 0 or NULL any more), and brace initialisation with {} works for everything and blocks silent narrowing. Read the worked example, run it, then you'll write your own.

#include <iostream>
#include <vector>
#include <string>
using namespace std;

int main() {
    // 'auto' asks the compiler to deduce the type from the value.
    auto count = 42;          // deduced as int
    auto price = 9.99;        // deduced as double
    auto name = string("Sam");// deduced as std::string

    // Uniform / brace initialisation — {} works for everything
    // and BLOCKS narrowing (see Common Errors below).
    vector<int> scores{90, 75, 88, 100};   // a list of ints

    // Range-based for: walk every element without an index.
    // 'const auto&' = read-only, no copying (fast + safe).
    cout << "Scores:";
    for (const auto& s : scores) {
        cout << " " << s;      // prints each score
    }
    cout << "\n";              // Scores: 90 75 88 100

    // nullptr is the modern, type-safe "no pointer" (never use 0 or NULL).
    int* ptr = nullptr;        // points at nothing — yet
    if (ptr == nullptr) {
        cout << name << " has no pointer yet\n"; // Sam has no pointer yet
    }

    cout << "Count=" << count << ", Price=" << price << "\n"; // Count=42, Price=9.99
    return 0;
}

// ✅ Expected output:
//    Scores: 90 75 88 100
//    Sam has no pointer yet
//    Count=42, Price=9.99

Your turn. The program below averages some temperatures — fill in the two blanks marked ___ using the hints, then run it.

#include <iostream>
#include <vector>
using namespace std;

int main() {
    // 🎯 YOUR TURN — replace each ___ then press "Run".

    // 1) Use auto to let the compiler deduce the type of this list.
    ___ temps = vector<double>{19.5, 21.0, 18.2};  // 👉 write: auto

    double total = 0.0;

    // 2) Loop over every element by reference (read-only).
    for (const auto& ___ : temps) {  // 👉 name the loop variable, e.g. t
        total += t;                  //    (uses the name you chose: t)
    }

    cout << "Average: " << total / temps.size() << "\n";

    // ✅ Expected output:
    //    Average: 19.5667
    return 0;
}

2. Lambdas, Structured Bindings & std::optional

A lambda is a function you can write inline and store in a variable: [capture](args){ body }. The capture list decides which outside variables it can see — [x] copies x (by value), [&x] shares it (by reference). A structured binding, auto [a, b] = pair;, unpacks a pair, tuple, or struct into named pieces in one line. And std::optional<T> models "a T that might be missing" — check it with .has_value() and read it safely with .value_or(fallback). The if (init; cond) form (C++17) lets you declare and test in a single, tightly-scoped line.

#include <iostream>
#include <vector>
#include <algorithm>
#include <optional>
#include <string>
using namespace std;

// optional<T> = "a T that might be missing". No magic -1 needed.
optional<int> findScore(const vector<pair<string,int>>& book, const string& who) {
    for (const auto& [person, score] : book) {  // structured binding!
        if (person == who) return score;        // found it
    }
    return nullopt;  // not found -> empty optional
}

int main() {
    vector<pair<string,int>> book{{"Ada", 95}, {"Linus", 88}};

    // Lambda = an inline function you can store. [capture](args){ body }
    int bonus = 10;
    auto withBonus = [bonus](int s) { return s + bonus; }; // capture by VALUE
    cout << "95 + bonus = " << withBonus(95) << "\n";      // 105

    // Capture by reference [&] lets the lambda change outside variables.
    int calls = 0;
    auto greet = [&calls](const string& n) { ++calls; return "Hi " + n; };
    cout << greet("Ada") << "\n";                          // Hi Ada
    cout << "Lambda ran " << calls << " time(s)\n";        // 1

    // 'if' with an initialiser (C++17): declare + test in one line.
    // The variable 'r' only exists inside this if/else.
    if (auto r = findScore(book, "Linus"); r.has_value()) {
        cout << "Linus: " << r.value() << "\n";            // Linus: 88
    } else {
        cout << "Linus: not found\n";
    }

    // value_or gives a fallback when the optional is empty — no crash.
    cout << "Grace: " << findScore(book, "Grace").value_or(-1) << "\n"; // -1
    return 0;
}

// ✅ Expected output:
//    95 + bonus = 105
//    Hi Ada
//    Lambda ran 1 time(s)
//    Linus: 88
//    Grace: -1

Now you try. Split a pair with a structured binding, then guard an optional before reading it. Fill in the two blanks:

#include <iostream>
#include <optional>
#include <string>
using namespace std;

// Returns the half of an even number, or nothing for an odd number.
optional<int> halfIfEven(int n) {
    if (n % 2 == 0) return n / 2;
    return nullopt;
}

int main() {
    // 🎯 YOUR TURN — fill in the two blanks.

    // 1) A pair, then split it with a STRUCTURED BINDING.
    pair<string,int> player{"Zoe", 3};
    auto [name, level] = ___;        // 👉 bind to: player

    cout << name << " is on level " << level << "\n"; // Zoe is on level 3

    // 2) Only print the half when the optional actually HAS a value.
    auto h = halfIfEven(10);
    if (h.___()) {                   // 👉 method that returns true/false: has_value
        cout << "Half of 10 is " << h.value() << "\n";
    }

    // ✅ Expected output:
    //    Zoe is on level 3
    //    Half of 10 is 5
    return 0;
}

3. constexpr, switch with Initialiser & a C++20 Peek

constexpr marks something the compiler can compute before the program runs — so it costs nothing at run time and can do things ordinary values can't, like sizing an array. The switch (init; value) form mirrors the if initialiser, keeping a helper variable scoped to just that block. Finally, a brief look at C++20: concepts name a requirement a template type must meet (clearer errors than the old SFINAE tricks), and ranges let you pipe algorithms together with |.

#include <iostream>
#include <string>
using namespace std;

// constexpr = computed at COMPILE time. Zero run-time cost, and the
// result can size arrays or feed templates.
constexpr int square(int x) { return x * x; }

int main() {
    constexpr int side = 4;
    constexpr int area = square(side);  // computed before the program runs
    int grid[area];                     // legal: 'area' is a compile-time 16
    cout << "Grid cells: " << sizeof(grid) / sizeof(int) << "\n"; // 16

    // 'switch' with an initialiser (C++17): set up + branch together.
    // 'day' is scoped to the switch only.
    switch (int day = (side * 2) % 7; day) {
        case 0:  cout << "Sunday\n";     break;
        case 1:  cout << "Monday\n";     break;
        default: cout << "Day #" << day << "\n"; break;   // Day #1
    }

    // --- A peek at C++20 (concepts + ranges) ---
    // concept: a named requirement on a type. Pseudocode shape:
    //   template<class T> concept Number = std::is_arithmetic_v<T>;
    //   template<Number T> T twice(T v) { return v + v; }
    // ranges: pipe-style algorithms, e.g.
    //   for (int n : v | std::views::filter(isEven)) { ... }
    cout << "C++20 adds concepts & ranges for cleaner generics.\n";
    return 0;
}

// ✅ Expected output:
//    Grid cells: 16
//    Monday
//    C++20 adds concepts & ranges for cleaner generics.

Pro Tips

Common Errors (and the fix)

📋 Quick Reference (feature → version)

FeatureSyntaxSince
Type deductionauto x = 42;C++11
Range-based forfor (const auto& e : v)C++11
Lambda[x](int n){ return n+x; }C++11
Null pointerint* p = nullptr;C++11
Brace initvector<int> v{1,2,3};C++11
Structured bindingauto [a, b] = pair;C++17
Optionaloptional<int> o = nullopt;C++17
if / switch initif (auto r = f(); r) ...C++17
constexpr fnconstexpr int sq(int x)C++11/14
Concepts / rangestemplate<Number T>C++20

Mini-Challenge: Cheapest Item Finder

No blanks this time — just a brief and an outline to keep you on track. Combine everything: a vector of pairs, a function returning std::optional, a range-based for, and structured bindings. Build it, run it, and check your output against the example in the comments.

#include <iostream>
#include <vector>
#include <optional>
#include <string>
using namespace std;

// 🎯 MINI-CHALLENGE: Cheapest item finder
//  1. Make a vector of pair<string,double>: item name + price.
//     e.g. {{"Pen", 1.50}, {"Mug", 6.00}, {"Pad", 2.25}}
//  2. Write a function returning optional<pair<string,double>>:
//     the cheapest item, or nullopt if the list is empty.
//  3. Use a range-based for + structured bindings to compare.
//  4. In main(), if the optional has a value, print "Cheapest: NAME at PRICE".
//
//  ✅ Expected output:
//     Cheapest: Pen at 1.5

int main() {
    // your code here
    return 0;
}

🎉 Lesson Complete

Practice quiz

What is true about auto x = 5; in C++?

  • x can later hold text, like in JavaScript
  • x is deduced as int at compile time and stays int
  • auto defers the type to run time
  • auto makes x a reference to 5

Answer: x is deduced as int at compile time and stays int. auto is fully static — the compiler deduces one fixed type (here int) at compile time; it never changes.

In a range-based for loop, why is const auto& the safe default?

  • It copies each element for safety
  • It reads each element with no copy and can't mutate the source
  • It is required for the loop to compile
  • It converts every element to a string

Answer: It reads each element with no copy and can't mutate the source. const auto& binds a read-only reference, so there is no copying and you cannot accidentally change the container.

A lambda captures a local by reference with [&] and is then used after that local goes out of scope. What happens?

  • It safely reads a copy of the value
  • A compile error
  • Undefined behaviour from a dangling reference
  • The lambda recreates the variable

Answer: Undefined behaviour from a dangling reference. [&] captures by reference; using the lambda after the referenced variable dies is a dangling reference — undefined behaviour. Capture by value if the lambda outlives the variable.

What does std::optional<T> model?

  • A T that might be missing, checkable with has_value()
  • A pointer that is always non-null
  • A T that is computed lazily on first use
  • A thread-safe wrapper around T

Answer: A T that might be missing, checkable with has_value(). optional<T> represents 'maybe a value' — far clearer than a magic -1; check with has_value() or read safely with value_or(fallback).

What does calling .value() on an empty std::optional do?

  • Returns 0
  • Returns a default-constructed T
  • Throws std::bad_optional_access
  • Returns nullptr

Answer: Throws std::bad_optional_access. Reading an empty optional with .value() throws bad_optional_access; guard with has_value() or use value_or().

What does a structured binding like auto [name, level] = player; do?

  • Creates two pointers into player
  • Unpacks a pair/tuple/struct into named pieces in one line
  • Sorts the members of player
  • Only works on std::array

Answer: Unpacks a pair/tuple/struct into named pieces in one line. Structured bindings (C++17) split a pair, tuple, or struct into named variables in a single declaration.

How does constexpr differ from const?

  • They are identical
  • const must be compile-time; constexpr may be run-time
  • constexpr must be computable at compile time; const may be computed at run time
  • constexpr only applies to pointers

Answer: constexpr must be computable at compile time; const may be computed at run time. const means 'not changeable after set' (possibly run-time); constexpr is stronger — the value must be known at compile time, so it can size arrays.

Given constexpr int square(int x){ return x*x; }, what is the value of square(4) used to size an array?

  • 8
  • 16
  • 4
  • It cannot size an array

Answer: 16. square(4) is 16, computed at compile time, so int grid[16]; is legal.

Why does brace initialisation refuse int n{3.9};?

  • Braces only work for vectors
  • {} blocks narrowing conversions like double-to-int
  • 3.9 is not a valid literal
  • n must be declared auto

Answer: {} blocks narrowing conversions like double-to-int. Uniform brace init forbids narrowing, so truncating 3.9 to an int is a compile error — a safety feature.

Which is the modern, type-safe way to say 'this pointer points at nothing'?

  • 0
  • NULL
  • nullptr
  • void

Answer: nullptr. nullptr (C++11) is a proper null-pointer type; prefer it over the old 0 or NULL spellings.

Continue this course

Frequently asked questions

Is auto the same as JavaScript's loose typing?

No. auto is fully static — the compiler deduces ONE fixed type at compile time and it never changes. auto x = 5; makes x an int forever; you cannot later store text in it. It saves typing, not type safety.

When should I use optional instead of just returning -1 or nullptr?

Use std::optional whenever a function may legitimately have no answer (a lookup that misses, a parse that fails). It makes 'no value' a real, checked state instead of a magic number like -1 that a caller can forget to test. nullptr only works for pointers; optional works for any type.

Why does my lambda crash after the function returns?

You almost certainly captured a local variable by reference ([&]) and then used the lambda after that variable went out of scope — a dangling reference. If the lambda outlives the variable, capture by value ([=] or [x]) so it keeps its own copy.

Do I need a special compiler flag for these features?

Yes — pick the standard with a flag. C++17 features need -std=c++17 and C++20 features (concepts, ranges) need -std=c++20 on GCC/Clang. Our runner uses a modern standard, so the examples here compile as written.

What's the difference between constexpr and const?

const means 'cannot be changed after it is set' (it may still be computed at run time). constexpr is stronger: the value must be computable at COMPILE time, so it can size arrays, be used in templates, and add zero run-time cost.