Boring C++
Boring C++

Modern C++ Idioms and Best Practices

14 min read

Chapter 11: Modern C++ Idioms and Best Practices

This chapter collects the patterns and features that make modern C++ code safer, clearer, and more efficient — things that do not belong to a single topic but show up across every real project. Some of them (const-correctness, noexcept) are habits to build. Others (std::format, std::span) are tools to reach for when the situation calls for them.

None of this material is optional. A codebase that ignores const-correctness is harder to reason about. A codebase that ignores noexcept produces slower code in some paths. The patterns in this chapter are not advanced features for experts — they are the baseline for writing professional C++.

11.1 const-correctness

const-correctness means applying const to everything that should not change. The goal is to make the code's intent explicit and let the compiler enforce it.

You have already seen const in several forms. Here is a complete picture:

#include <iostream>
#include <string>
#include <vector>

// const parameter: the function promises not to modify s
void print(const std::string& s) {
    std::cout << s << "\n";
    // s += " modified";  // compile error
}

// const method: the method does not modify *this
class Counter {
public:
    explicit Counter(int start = 0) : count_{start} {}

    void increment() { ++count_; }        // non-const: modifies the object
    int value() const { return count_; }  // const: does not modify

    // const and non-const overloads of the same function
    int& operator[](size_t i)             { return data_[i]; }       // non-const version
    const int& operator[](size_t i) const { return data_[i]; }       // const version

private:
    int count_;
    std::vector<int> data_{0, 1, 2, 3, 4};
};

void printCounter(const Counter& c) {
    // Can call only const methods on a const reference
    std::cout << "Value: " << c.value() << "\n";
    std::cout << "Data[0]: " << c[0] << "\n";  // calls const operator[]
    // c.increment();  // compile error: cannot call non-const on const ref
}

// const local variables: prevents accidental reassignment
int computeAnswer() {
    const int base = 6;
    const int multiplier = 7;
    return base * multiplier;
}

int main() {
    Counter c{0};
    c.increment();
    c.increment();
    printCounter(c);

    // const pointer varieties:
    int x = 42;
    const int* p1 = &x;   // pointer to const int: cannot change *p1
    int* const p2 = &x;   // const pointer to int: cannot change p2
    const int* const p3 = &x;  // const pointer to const int: neither

    // *p1 = 100;  // error: *p1 is const
    p1 = nullptr;  // OK: p1 itself is not const

    *p2 = 100;     // OK: *p2 is not const
    // p2 = nullptr;  // error: p2 is const

    std::cout << "Answer: " << computeAnswer() << "\n";
    return 0;
}

The rule: apply const to everything you can. Start by marking every method const that does not modify the object. Mark every parameter const that the function does not need to modify. Declare every local variable const that is initialized once and not reassigned.

When you hit a compile error because const is too restrictive, that is the compiler telling you something about your design. Often the right response is to rethink the design, not to remove const.

11.2 noexcept

noexcept is a promise: "this function will never throw an exception." If it does throw, std::terminate() is called immediately.

#include <iostream>
#include <stdexcept>

// noexcept: guaranteed not to throw
int add(int a, int b) noexcept {
    return a + b;
}

// noexcept(false): may throw (the default)
int divide(int a, int b) {
    if (b == 0) throw std::domain_error{"Division by zero"};
    return a / b;
}

// noexcept on move operations: critical for performance
class Buffer {
public:
    Buffer() : data_{nullptr}, size_{0} {}
    explicit Buffer(size_t n) : data_{new char[n]}, size_{n} {}
    ~Buffer() { delete[] data_; }

    // Move constructor: noexcept lets std::vector use it during reallocation
    Buffer(Buffer&& other) noexcept
        : data_{other.data_}, size_{other.size_}
    {
        other.data_ = nullptr;
        other.size_ = 0;
    }

    Buffer& operator=(Buffer&& other) noexcept {
        if (this != &other) {
            delete[] data_;
            data_ = other.data_;
            size_ = other.size_;
            other.data_ = nullptr;
            other.size_ = 0;
        }
        return *this;
    }

    // Delete copy
    Buffer(const Buffer&) = delete;
    Buffer& operator=(const Buffer&) = delete;

    size_t size() const noexcept { return size_; }

private:
    char* data_;
    size_t size_;
};

int main() {
    std::cout << add(3, 4) << "\n";  // 7

    // noexcept(expr): check at compile time
    std::cout << std::boolalpha;
    std::cout << "add noexcept: " << noexcept(add(1, 2)) << "\n";      // true
    std::cout << "divide noexcept: " << noexcept(divide(4, 2)) << "\n"; // false

    return 0;
}

Why noexcept on move operations matters: when std::vector needs to grow, it can either copy all elements to the new allocation or move them. It will only use move if the move constructor is noexcept — otherwise, if a move throws partway through, the vector cannot provide the strong exception guarantee and falls back to copying. Mark your move constructors and move assignment operators noexcept.

Where to apply noexcept:

  • Destructors (they are noexcept by default since C++11 — keep it that way)
  • Move constructors and move assignment operators
  • Swap functions
  • Any function you can genuinely guarantee will never throw

11.3 Attributes: [[nodiscard]], [[maybe_unused]], [[likely]]

C++17 and C++20 added several standard attributes — hints to the compiler and documentation for readers.

#include <iostream>
#include <optional>
#include <string>
#include <vector>

// [[nodiscard]]: warn if the return value is discarded
[[nodiscard]] int computeResult() {
    return 42;
}

[[nodiscard("Check the error code")]] bool openFile(const std::string& path) {
    return path.size() > 0;  // simplified
}

// [[nodiscard]] on a type: any function returning this type triggers the warning
struct [[nodiscard]] ErrorCode {
    int value;
    bool ok() const { return value == 0; }
};

ErrorCode doWork() {
    return {0};  // success
}

// [[maybe_unused]]: suppress unused variable/parameter warnings
void processData([[maybe_unused]] int debugFlag, const std::vector<int>& data) {
    // debugFlag is used only in debug builds
#ifndef NDEBUG
    std::cout << "Debug flag: " << debugFlag << "\n";
#endif
    for (int x : data) {
        [[maybe_unused]] int tmp = x * 2;  // computed but not used in release
        // use tmp in debug mode only
    }
}

// [[likely]] and [[unlikely]]: hint to the branch predictor (C++20)
int process(int value) {
    if (value > 0) [[likely]] {
        return value * 2;
    } else [[unlikely]] {
        return -1;
    }
}

// [[deprecated]]: warn callers that this is going away
[[deprecated("Use newFunction() instead")]]
void oldFunction() {
    std::cout << "Old\n";
}

void newFunction() {
    std::cout << "New\n";
}

int main() {
    computeResult();  // warning: ignoring return value of nodiscard function

    auto ok = openFile("test.txt");
    if (!ok) std::cout << "Failed to open\n";

    auto ec = doWork();
    if (!ec.ok()) std::cerr << "Error: " << ec.value << "\n";

    std::vector<int> data{1, 2, 3};
    processData(1, data);

    std::cout << process(5) << "\n";   // 10
    std::cout << process(-3) << "\n";  // -1

    // oldFunction();  // warning: deprecated
    newFunction();

    return 0;
}

[[nodiscard]] is one of the most practically useful attributes. Apply it to functions whose return values represent errors or results that callers must check. The standard library has applied it to std::async, std::launder, and others.

11.4 Compile-time programming: constexpr and consteval

constexpr functions can run at compile time (when given compile-time inputs) or at runtime. consteval (C++20) functions must run at compile time — calling them with runtime values is an error.

#include <iostream>
#include <array>

// constexpr function: can be called at compile time or runtime
constexpr int factorial(int n) {
    if (n <= 1) return 1;
    return n * factorial(n - 1);
}

// consteval function (C++20): MUST be called at compile time
consteval int compileTimeOnly(int n) {
    return n * n;
}

// constexpr class
class FixedPoint {
public:
    constexpr FixedPoint(int integer, int fraction)
        : value_{integer * 100 + fraction} {}

    constexpr int integerPart() const { return value_ / 100; }
    constexpr int fractionPart() const { return value_ % 100; }
    constexpr FixedPoint operator+(FixedPoint other) const {
        return FixedPoint{0, value_ + other.value_};
    }

private:
    int value_;
};

// Compile-time lookup table
constexpr std::array<int, 10> makeSquares() {
    std::array<int, 10> arr{};
    for (int i = 0; i < 10; ++i) arr[i] = i * i;
    return arr;
}

int main() {
    // Computed at compile time
    constexpr int f5  = factorial(5);   // 120
    constexpr int f10 = factorial(10);  // 3628800

    // Can also run at runtime
    int n;
    std::cin >> n;
    std::cout << "factorial(" << n << ") = " << factorial(n) << "\n";

    // consteval: must be compile-time
    constexpr int sq7 = compileTimeOnly(7);   // 49, computed at compile time
    // int x = 5; compileTimeOnly(x);  // error: x is not a constant expression

    // Compile-time lookup table — zero runtime cost
    constexpr auto squares = makeSquares();
    for (int s : squares) std::cout << s << " ";
    std::cout << "\n";

    // constexpr class
    constexpr FixedPoint price{3, 99};   // $3.99
    constexpr FixedPoint tax{0, 32};     // $0.32
    constexpr FixedPoint total = price + tax;
    std::cout << total.integerPart() << "." << total.fractionPart() << "\n";

    std::cout << "5! = " << f5 << ", 10! = " << f10 << "\n";
    std::cout << "7^2 = " << sq7 << "\n";

    return 0;
}

if constexpr (C++17) selects branches at compile time — the non-taken branch is not compiled:

#include <iostream>
#include <string>
#include <type_traits>

template<typename T>
std::string describe(const 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 "other";
    }
}

int main() {
    std::cout << describe(42) << "\n";        // integer: 42
    std::cout << describe(3.14) << "\n";      // float: 3.140000
    std::cout << describe(std::string{"x"}) << "\n";  // other
    return 0;
}

11.5 std::span and std::mdspan

std::span<T> (C++20) is a non-owning view over a contiguous range — an array, a vector, a C array — with a known size. It replaces the common pattern of passing a pointer and a size separately.

#include <iostream>
#include <span>
#include <vector>
#include <array>
#include <numeric>

// Takes any contiguous range — no need to overload for vector vs array
void printAll(std::span<const int> data) {
    for (int x : data) std::cout << x << " ";
    std::cout << "\n";
}

double average(std::span<const double> values) {
    if (values.empty()) return 0.0;
    return std::accumulate(values.begin(), values.end(), 0.0) / values.size();
}

// Subspan: a view into part of the data — no copy
void processChunk(std::span<int> chunk) {
    for (int& x : chunk) x *= 2;
}

int main() {
    std::vector<int> v{1, 2, 3, 4, 5};
    std::array<int, 4> a{10, 20, 30, 40};
    int arr[] = {100, 200, 300};

    // All work with the same function
    printAll(v);
    printAll(a);
    printAll(arr);

    // Subspan: first 3 elements of v (no copy)
    auto first3 = std::span{v}.first(3);
    printAll(first3);

    // Last 2 elements
    auto last2 = std::span{v}.last(2);
    printAll(last2);

    // Subspan at offset
    auto middle = std::span{v}.subspan(1, 3);  // indices 1, 2, 3
    printAll(middle);

    // Modify in place through span
    processChunk(v);
    printAll(v);  // 2 4 6 8 10

    std::vector<double> temps{22.5, 23.1, 21.8, 24.0, 22.9};
    std::cout << "Average: " << average(temps) << "\n";

    return 0;
}

Before std::span, you would write void print(const int* data, size_t n) — a raw pointer and a size. std::span bundles them together, adds bounds checking in debug mode, and works with all standard algorithms.

std::mdspan (C++23) is a multidimensional span — a view into a 2D, 3D, or higher-dimensional array with configurable layout. It is important for scientific computing and graphics but beyond the scope of this chapter.

11.6 std::format (C++20)

std::format replaces printf and the verbose std::cout manipulation for formatted output:

#include <iostream>
#include <format>
#include <string>
#include <vector>

int main() {
    // Basic formatting
    std::string s = std::format("Hello, {}!", "world");
    std::cout << s << "\n";

    // Multiple arguments
    int age = 30;
    std::string name = "Alice";
    std::cout << std::format("{} is {} years old\n", name, age);

    // Width, alignment, fill
    for (int i = 1; i <= 5; ++i) {
        std::cout << std::format("{:>3}: {:*<10} {:.2f}\n",
                                  i,
                                  std::string(i, '#'),
                                  i * 1.5);
    }
    // Output:
    //   1: #          1.50
    //   2: ##         3.00
    //   3: ###        4.50
    //   4: ####       6.00
    //   5: #####      7.50

    // Integer formats
    int n = 255;
    std::cout << std::format("Dec: {:d}, Hex: {:x}, Oct: {:o}, Bin: {:b}\n",
                              n, n, n, n);
    // Dec: 255, Hex: ff, Oct: 377, Bin: 11111111

    // Floating point
    double pi = 3.14159265358979;
    std::cout << std::format("Pi: {:.4f}, Sci: {:.3e}\n", pi, pi);
    // Pi: 3.1416, Sci: 3.142e+00

    // Positional arguments (same arg used multiple times)
    std::cout << std::format("{0} + {0} = {1}\n", 21, 42);

    // std::print (C++23): format and print in one step
    // std::print("Hello, {}!\n", "world");

    return 0;
}

std::format is type-safe (unlike printf), composable (returns a std::string), and extensible (you can add formatting support for your own types). It is the right tool for any output that goes beyond simple << chaining.

Compile with -std=c++20. Not all compilers had full std::format support at C++20 launch — check your version. GCC 13+ and Clang 17+ have complete support.

11.7 Modules: a brief introduction (C++20)

Modules are the C++20 replacement for #include. They solve long-standing problems: header files are textually included and recompiled every time they appear, macros leak across translation units, and the order of includes matters.

With modules, a module is compiled once. Its interface is exported explicitly. Macros do not leak.

// math_utils.cppm — a module
export module math_utils;

export double square(double x) {
    return x * x;
}

export double cube(double x) {
    return x * x * x;
}

// Internal helper — NOT exported
double helper(double x) {
    return x + 1.0;
}
// main.cpp — using the module
import math_utils;
#include <iostream>

int main() {
    std::cout << square(5.0) << "\n";  // 25
    std::cout << cube(3.0) << "\n";    // 27
    // helper(1.0);  // error: not exported
    return 0;
}

Compiling with modules requires explicit support from both the compiler and the build system. As of 2026, modules are fully supported in GCC 14+, Clang 16+, and MSVC 2022. CMake 3.28+ has module support.

Build with GCC:

g++ -std=c++20 -fmodules-ts -c math_utils.cppm -o math_utils.o
g++ -std=c++20 -fmodules-ts main.cpp math_utils.o -o main

Modules are the future of C++ code organization. Existing code will continue to use headers for many years — the transition is gradual. In new projects targeting C++20 and later, consider starting with modules.

11.8 Exercises

Easy

  1. Go through the BankAccount class from Chapter 5 and apply const-correctness: mark every accessor method const, every parameter that is not modified const, and every local variable that is initialized once const.

  2. Write a function formatTable(const std::vector<std::pair<std::string, int>>& rows) using std::format (C++20) that prints a two-column table with the name left-aligned in a 20-character column and the number right-aligned in a 10-character column.

Medium

  1. Write a statistics(std::span<const double> data) function (C++20) that computes and returns the minimum, maximum, mean, and standard deviation of the values in a single pass. Use std::span so it works with vectors, arrays, and C arrays.

Stretch

  1. Add [[nodiscard]] to every function in your exercise solutions from Chapters 5–9 whose return value should not be ignored. Note which ones you marked and explain why each one qualifies.

11.9 Common mistakes

  • Removing const because it is inconvenient. When const causes a compile error, the instinct is to remove it. The right response is to understand why the code is trying to modify something that should not be modified.

  • Not marking move constructors noexcept. Without noexcept, std::vector cannot use the move constructor during reallocation and falls back to copying. This can make a 10× performance difference for types stored in large vectors.

  • Ignoring [[nodiscard]] warnings. The compiler is telling you that you are discarding an error code or result that matters. Suppressing the warning with a cast to void is sometimes correct; ignoring it silently is not.

  • Using constexpr where const is sufficient. constexpr is a stronger guarantee and requires the expression to be evaluable at compile time. If the value is only runtime-constant, const is the right choice.

  • std::format precision confusion. {:.2f} means two decimal places; {:.2} for a string means maximum two characters (truncation). They look similar but do different things.

  • Holding a std::span to a destroyed container. span does not own the data. If the vector it was created from is destroyed or resized, the span is dangling.

  • Mixing modules and headers without care. Macros defined in headers can still affect module-using code if headers are also included. Keep the transition gradual and consistent.

11.10 Summary

Const-correctness is a discipline: apply const to everything you can. The compiler enforces the promise and communicates intent to readers.

noexcept is a performance and safety promise. Mark move constructors, move assignment operators, destructors, and swap functions noexcept.

Attributes [[nodiscard]], [[maybe_unused]], [[likely]], and [[deprecated]] communicate intent to the compiler and to readers, and generate actionable warnings.

Compile-time programming with constexpr and consteval moves work from runtime to compile time. if constexpr selects branches at compile time in templates.

std::span replaces pointer+size pairs for contiguous data. std::format replaces verbose stream manipulation and unsafe printf for formatted strings.

Modules (C++20) are the future of code organization — compiled once, no macro leakage, explicit exports.

In Chapter 12 we put everything together in a complete project: a command-line task manager built from scratch.

All code for this chapter is in the GitHub repository:
<TODO: book code repository URL>/tree/main/chapter11

Boring C++

Modern C++ Mastery for Programmers Who Prefer Clarity Over Cleverness.

View book →