C++ has accumulated four decades of features. Some of them are bad ideas the language can't remove without breaking existing code: raw new/delete, unscoped enums, C-style casts, macros for almost everything. Modern C++ has cleaner replacements for nearly all of them, and a written set of recommendations called the C++ Core Guidelines spells out which to prefer. This chapter walks through the most important guidelines: prefer auto, prefer ranges and algorithms over raw loops, prefer smart pointers over new/delete, use RAII everywhere, prefer scoped enums, keep const correctness, avoid macros, use override and final, follow the rule of zero, use std::string and std::string_view, use std::optional and std::variant for results, and prefer initializer lists. The chapter closes with the Guidelines Support Library (GSL) and the static-analysis tools that automate enforcement.
The C++ Core Guidelines are a community-maintained document edited by Bjarne Stroustrup (the language's designer) and Herb Sutter. They live at <https://isocpp.github.io/CppCoreGuidelines/> and consist of around 500 rules covering interface design, resource management, concurrency, performance, error handling, and class design. Each rule has a short name (I.1, R.20, C.21), a one-line summary, a longer explanation, examples of the rule applied, examples of the rule violated, and (where possible) a note on whether clang-tidy can enforce it automatically.
The guidelines are not a coding style. They don't tell you where to put braces or whether to use camelCase. They tell you which language features to prefer for safety and clarity, and which to avoid because they're error-prone. Most teams combine the Core Guidelines with a separate formatter config (often Google's or LLVM's) for the layout.
The diagram shows the typical setup. The guidelines are read by humans and encoded into checks for clang-tidy. The tool runs over the code and flags violations. The GSL provides small runtime types (gsl::span, gsl::not_null, gsl::owner) that the guidelines reference. clang-format handles whitespace and layout separately. Together they let a team write modern, safe C++ with automated enforcement.
The rest of this chapter goes through the highest-impact guidelines one by one. They're the ones that most often catch real bugs and the ones the static analyzers focus on.
Spelling out the type of a local variable is sometimes useful and often noisy. The auto keyword (since C++11) tells the compiler to deduce the type from the initializer.
The rule (Core Guideline ES.11) is to prefer auto for local variables when the type is either obvious from the initializer or so long that spelling it out hurts readability. The benefits are concrete:
std::vector<std::string>::iterator becomes auto. std::unordered_map<std::string, std::vector<int>>::const_iterator becomes auto.int x = 1.5; truncates without warning. auto x = 1.5; deduces double, the actual type of the initializer.int to int64_t, every variable spelled out as int becomes a narrowing conversion. Variables written with auto adapt automatically.auto doesn't fit everywhere. When the type matters to the reader and isn't visible in the initializer, write it out. auto x = makeWidget(); hides what x is, which makes the code harder to read; Widget x = makeWidget(); is clearer if there's any doubt. Variables documented by their type (function parameters, member variables, return types) often benefit from being explicit.
A frequent recommendation in the guidelines is almost-always-auto (AAA), a style where auto appears on the left of every variable declaration:
AAA forces every variable to have an initializer (you can't write auto x;), which catches a common class of bugs: using a variable before it's been given a value. Whether to adopt AAA is a team-level choice; the lighter "use auto when the type is obvious" is the more common middle ground.
Raw for loops over containers are responsible for a large share of off-by-one bugs, iterator invalidation bugs, and bounds errors in C++ code. The standard library has named algorithms for almost everything you'd write a raw loop for, and C++20 added the ranges library for a cleaner syntax on top.
The same operation three ways:
Output:
All three produce the same answer. The third version is the one the Core Guidelines prefer (rule ES.4, ES.71). The advantage isn't fewer lines, it's that the name std::accumulate says what the loop is doing. A reviewer reading the algorithm version immediately knows the loop sums elements. A reviewer reading the raw loop has to trace the body to find out.
A small sampling of the standard algorithms and what they replace:
| Algorithm | Replaces |
|---|---|
std::accumulate | Loop that sums or folds elements |
std::transform | Loop that maps each element to a new value |
std::copy_if | Loop that filters elements into a new container |
std::find_if | Loop that searches for the first matching element |
std::sort | Hand-written sorting (almost always slower and buggier) |
std::partition | Loop that splits elements into two groups |
std::for_each | Loop with no transformation, just side effects |
std::all_of / std::any_of / std::none_of | Loop that checks a condition over all elements |
C++20 ranges add a pipeline-style syntax:
std::views::filter produces a lazy view: the filter predicate runs only when the range is iterated, and no intermediate container is built. This is one of the cleaner additions in C++20, and it's the form to use when chaining multiple operations together.
A major source of memory bugs in C++ code is raw new and delete. Forgotten delete calls leak memory. Double delete corrupts the heap. delete of a pointer that's still being used elsewhere causes use-after-free. The standard library's smart pointers (std::unique_ptr and std::shared_ptr) make ownership explicit in the type system and free memory automatically.
Output:
The Core Guidelines on this (rules R.10, R.11) are blunt: avoid new and delete in application code. The acceptable cases are inside the implementation of a library class that wraps a resource (and even then, prefer std::make_unique to new).
The choice between unique_ptr and shared_ptr:
| Use | When |
|---|---|
std::unique_ptr<T> | One owner, possibly transferred via move. The default choice. |
std::shared_ptr<T> | Multiple owners share the resource; the last one to release frees it. |
std::weak_ptr<T> | Non-owning observer to a shared_ptr that needs to detect destruction. |
Raw pointer (T*) | Non-owning reference. The pointee is owned by someone else, who outlives this usage. |
The default is unique_ptr. Use shared_ptr only when ownership is genuinely shared, which is rarer than it seems. shared_ptr carries reference-counting overhead and a heap allocation for its control block. Using it as a default has a measurable cost.
std::make_unique and std::make_shared are the preferred factory functions. std::make_shared<Order>(args) makes one allocation for the object and its control block together; std::shared_ptr<Order>(new Order(args)) makes two. The factory functions also avoid a subtle exception-safety bug with f(std::shared_ptr<A>(new A), g()) where the order of evaluation can leak the A if g() throws.
RAII stands for Resource Acquisition Is Initialization, which is a bad name for a great idea. The idea: bind every resource (memory, file handle, mutex lock, network connection, database transaction) to the lifetime of a stack object. When the object is constructed, it acquires the resource. When the object is destroyed (at scope exit, automatically), it releases the resource. The destructor runs whether the scope exits normally or via an exception, so resources never leak.
The function has no explicit close() call on the file, no explicit unlock() on the mutex, and no error-handling for the case where the body throws halfway through. The destructors handle all of it.
RAII is how C++ handles cleanup in the absence of finally blocks. Other languages bolt try/finally onto exceptions to ensure cleanup runs; C++ binds the cleanup to a destructor and lets the language's deterministic destruction rules guarantee it.
The Core Guidelines on RAII (rule P.8, R.1) are short: every resource should be owned by an object whose destructor releases it. If you find yourself writing if (something) free_resource(); in an error path, you're doing it wrong. Wrap the resource in a class with a destructor and let the language do the cleanup.
The standard library provides RAII wrappers for the common resources:
std::unique_ptr<T> and std::shared_ptr<T> for heap allocations.std::ifstream, std::ofstream, std::fstream for files.std::lock_guard, std::unique_lock, std::scoped_lock for mutexes.std::jthread (C++20) for threads, with automatic join on destruction.std::vector, std::map, etc.) for their internal buffers.For resources without a standard wrapper, the pattern is to write a small class that takes the raw resource in its constructor and frees it in its destructor.
The old enum keyword has two problems. Its values leak into the surrounding scope, so two enums with members named Open collide. And the values convert implicitly to int, which means you can pass an enum where an integer is expected and the compiler won't complain.
Output:
The Core Guideline (Enum.3) is to prefer enum class for new code. The two benefits are scoping (no global name pollution) and type safety (no implicit conversion to or from int). The cost is the slightly more verbose call site (PaymentStatus::Completed instead of Completed), which is also exactly what makes the code clearer at a glance.
Use plain enum only when you genuinely need integer compatibility (e.g., flags that combine via |), and even then, the modern approach is enum class with explicit static_cast<int> at the boundaries.
const tells the compiler (and the reader) that something doesn't change. Every parameter that isn't going to be modified should be const. Every member function that doesn't modify the object should be const. Every variable that doesn't need to change should be const.
The Core Guideline (Con.1, Con.2) is to make everything immutable by default. const does three things:
const std::string& parameter signals that the function won't modify the string. The reader can rely on it.view.add(...) doesn't compile when view is a const Cart&, even though the call site looks innocent. The compiler catches the bug.const member functions need the const keyword after the parameter list, before the body: double total() const { ... }. That's the modifier that says "this method doesn't change the object." Inside such a method, attempting to modify a member is a compile error, and the method can be called on a const instance.
A close relative is constexpr, which marks values and functions that can be evaluated at compile time. constexpr int MAX_ITEMS = 100; is a typed, scoped, address-able constant that replaces what was once written as #define MAX_ITEMS 100. constexpr functions can run at compile time if their arguments are constants, and at run time otherwise.
The preprocessor doesn't understand C++. It substitutes text. That means a macro has no type, no scope, no namespace, no debug symbol, and no participation in overload resolution. Almost every old use of macros has a better modern replacement.
| Old (Macro) | Modern C++ |
|---|---|
#define MAX_ITEMS 100 | constexpr int MAX_ITEMS = 100; |
#define SQUARE(x) ((x)*(x)) | constexpr int square(int x) { return x*x; } |
#define STATUS_OK 0 etc. | enum class Status { Ok, Failed }; |
#define BEGIN_NS namespace foo { | Write namespace foo { directly |
The Core Guideline (ES.30, ES.31) is to avoid macros except for include guards, conditional compilation flags, and source-location-aware logging or assertions (where __FILE__ and __LINE__ need to be captured at the call site, which only macros can do). Outside of those cases, macros are error-prone, and the language now has cleaner answers.
A typical bug that a macro introduces and a function doesn't:
std::max(next_id(), 1005) calls next_id() once. The function version binds the arguments to parameters before evaluating the body. The macro pastes the argument text in twice, which means the side effect runs twice.
When you override a virtual function in a derived class, the language doesn't require you to say so. The function just has to have the same signature as the base class's virtual. This is fragile: a typo in the signature, or a base-class change that updates the signature, creates a function that doesn't override anything. The compiler doesn't warn, because there's no way for it to know the intent.
Output:
The Core Guideline (C.128) is to mark every override with override. The keyword is checked at compile time: if the function doesn't actually override a base virtual (because of a typo, missing const, wrong parameter type, or signature drift), the compile fails immediately with a clear message.
final is the related keyword for stopping derived classes from overriding further. void display() const override final { ... } says "this is an override, and no one downstream can override it again." Marking a method final enables some compiler optimizations (devirtualization of calls), but the main use is documentation: a final method declares that the derivation chain stops here.
final can also apply to entire classes: class Order final { ... }; means no class can inherit from Order. This is useful for value types that shouldn't be polymorphic, and for closing off inheritance hierarchies that aren't designed to be extended.
If your class manages a raw resource, you typically need to write five special member functions: the destructor, the copy constructor, the copy assignment operator, the move constructor, and the move assignment operator. This is the rule of five. Writing one usually means writing all five, because the compiler-generated versions of the others won't do the right thing.
The cleaner option is the rule of zero: structure your class so it doesn't own a raw resource. Use std::vector, std::string, std::unique_ptr, std::shared_ptr, or another well-designed value type for each resource, and let the compiler generate all five special members.
Output:
No destructor, no copy constructor, no copy assignment, no move constructor, no move assignment. The members handle themselves. The compiler-generated versions correctly copy, move, and destroy the strings and the vector, which means they correctly copy, move, and destroy the Customer.
The Core Guidelines on this (C.20) are: prefer to write no special members at all. Write all five (rule of five) only when you have to. Writing some but not others (the "rule of three minus two") is almost always a bug.
std::string replaces C-style char* strings for any place where you need text. It owns its memory, manages its length, can grow and shrink, and has dozens of useful methods. std::string_view (C++17) is a non-owning, non-mutable view into an existing string, useful as a function parameter when you don't need to take ownership.
Output:
The Core Guideline (SL.str.1, SL.str.2) is to use std::string for owning strings and std::string_view for non-owning string parameters. Reasons to prefer them over const char*:
std::string knows its own length. const char* doesn't (it relies on a null terminator).std::string handles memory automatically. No new/delete, no buffer overruns.std::string_view accepts any string-like input (std::string, const char*, string literals) without copying. A function taking std::string_view can be called with all three.The one caveat with string_view is that it doesn't own its data. A string_view that outlives the string it points to dangles, the same way a pointer to a destroyed object dangles. Use string_view for function parameters and short-lived locals; don't store one in a member variable that outlives the source string.
C-style "no result" sentinels (-1 for a not-found index, nullptr for a missing pointer, magic enum values for failure) make the type system carry information that doesn't belong there. C++17 added std::optional<T> and std::variant<T, U, ...> for these cases.
Output:
The type std::optional<std::string> says exactly what the function returns: either a std::string, or nothing. The caller has to handle both cases, because the type forces them to. A C-style API that returned std::string and used the empty string as the sentinel is ambiguous: an empty product name is indistinguishable from a missing one. std::optional removes the ambiguity.
std::variant<T, U, V> is a type-safe union: a value that holds exactly one of several types at a time. It's useful for return types that can be one of several success or failure cases, and for state machines where the state has different data depending on which state it's in.
The Core Guideline (F.41, F.42, F.43) is to prefer these for return values over sentinels, out-parameters, or exception-based signaling.
Brace initialization (T x{args}; and T x = {args};) is the modern default for constructing objects. It has two safety properties that the older parenthesis form doesn't:
Output:
The Core Guideline (ES.23) is to prefer the {} form. It catches narrowing conversions at compile time, it avoids the "most-vexing-parse" (where Widget w(Init()); declares a function instead of an object), and it works uniformly for value types, aggregates, and types with initializer-list constructors.
The one place to be careful: for containers like std::vector, brace initialization with values prefers the std::initializer_list<T> constructor over other constructors. std::vector<int>{10} makes a one-element vector, not a ten-element vector. When you want the "n default elements" form, use parentheses: std::vector<int>(10). The two syntaxes do different things for these specific container types, which is the one wart in an otherwise clean rule.
The GSL is a small header-only library that provides types the Core Guidelines reference but the standard library doesn't yet ship. The three most used:
std::span in C++20): a non-owning view over a contiguous range. Accepts arrays, std::vector, std::array, and pointer-plus-size pairs. Replaces the T* + size_t pair that C-style APIs use.gsl::not_null<T*>**: a pointer type that asserts at construction that the pointer isn't null. Documents in the type that the parameter is required, and lets the compiler optimize without null checks.gsl::owner<T*>**: a marker on a raw pointer saying "this pointer owns the memory." Static analyzers use it to verify that exactly one delete exists per owner.std::span is the standard way to pass a contiguous sequence without specifying the container type. It lets you write a function that processes "an array of doubles" without forcing the caller to use a specific container.
In modern code, most of the GSL has either landed in the standard library (std::span) or been replaced by smart pointers (gsl::owner is rare because std::unique_ptr does the job). The library remains useful for legacy codebases migrating to modern C++ and for project-wide markers that static analyzers can check.
The guidelines are only useful if they're checked. clang-tidy is the static analyzer that ships with Clang and checks hundreds of rules across several categories.
A minimal .clang-tidy file at the project root:
The check families:
| Family | What it catches |
|---|---|
bugprone-* | Common bug patterns: dangling references, integer overflow, suspicious casts |
cppcoreguidelines-* | Direct enforcement of Core Guidelines rules |
modernize-* | C++03 patterns that should be C++11+ (raw loops, NULL instead of nullptr, typedef instead of using) |
performance-* | Unnecessary copies, missed move, slow string operations |
readability-* | Style and clarity issues |
A typical workflow:
The trailing -- -std=c++17 tells clang-tidy what compile flags to use. Without it, the analyzer guesses and often gets the standard version wrong.
For a real project, you'd integrate clang-tidy into the build (CMake has CMAKE_CXX_CLANG_TIDY for this) and into the CI pipeline. Once it's running automatically, the cost of enforcement drops to zero, and the team stops debating rules during code review.
A few other static analyzers see common use:
For most teams, clang-tidy plus clang-format covers 90% of what's needed. The remaining 10% is project-specific: custom checks for in-house APIs, project naming conventions, or legacy patterns to migrate.
For a new C++ project, a workable setup:
.clang-format based on Google or LLVM style, with 3-5 overrides for project preferences..clang-tidy enabling bugprone-*, cppcoreguidelines-*, modernize-*, performance-*. Disable any specific checks too noisy for your code.CMAKE_EXPORT_COMPILE_COMMANDS=ON exports the compilation database that both tools need.The benefit is cumulative. The first month is a slog of fixing lint warnings and arguing about which checks to enable. By the third month, the code looks consistent, the worst bug patterns stop appearing, and code review focuses on logic instead of style.
9 quizzes