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
noexceptby 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
-
Go through the
BankAccountclass from Chapter 5 and apply const-correctness: mark every accessor methodconst, every parameter that is not modifiedconst, and every local variable that is initialized onceconst. -
Write a function
formatTable(const std::vector<std::pair<std::string, int>>& rows)usingstd::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
- 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. Usestd::spanso it works with vectors, arrays, and C arrays.
Stretch
- 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
constbecause it is inconvenient. Whenconstcauses 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. Withoutnoexcept,std::vectorcannot 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 tovoidis sometimes correct; ignoring it silently is not. -
Using
constexprwhereconstis sufficient.constexpris a stronger guarantee and requires the expression to be evaluable at compile time. If the value is only runtime-constant,constis the right choice. -
std::formatprecision 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::spanto a destroyed container.spandoes not own the data. If thevectorit 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