Skip to content
Free public beta until December 4, 2026: every course is open, no card needed. After that, the first chapter of each course stays free and full access is a one-time $39 ($19 for up to 100 founding members chosen for helpful feedback). Your progress is kept. How the beta works

Language & library reference

Concept clusters →

Every named result in this course, grouped by chapter. Each one links back to where it's derived.

Values, types, and variables

Rule

Initialization rules

also known as: brace initialization, narrowing, indeterminate values

Formal. Brace initialization, int n{3};, forbids narrowing conversions: int n{3.9}; with a constant doesn't compile. Copy initialization, int n = 3.9;, permits narrowing and stores 3. A local of fundamental type with no initializer, int total;, has an indeterminate value, and reading it is a bug with no promised result; int total{}; value-initializes it to zero. For a vector, braces list the values and parentheses give a count: std::vector<int> a(3); holds three zeros, std::vector<int> b{3}; holds the single value 3.

In plain English. Curly braces refuse to squeeze a value into a type that can't hold it. An = squeezes it anyway and cuts off the fraction. A variable you never gave a value holds whatever junk was in that memory, so give every variable a value, even if it's just {} for zero.

Example. int count{}; starts at 0. int n{3.9}; doesn't compile, which is the point: the mistake is caught before the program runs.

Why this rule exists. Uninitialized locals and silent narrowing are two of the oldest C++ bugs, and braces catch both at compile time.

Rule

Integer arithmetic rules

also known as: integer division drops the fraction, remainder sign, unsigned wraparound

Formal. When both sides of / are integers, the result is an integer: the fraction is dropped, rounding toward zero, so 7 / 2 is 3 and -7 / 2 is -3. % gives the remainder with the sign of the left side: -7 % 2 is -1. If either side is floating-point, the other is converted first and the division is floating-point. Unsigned arithmetic wraps modulo 2ⁿ, so 0u - 1 is the largest unsigned int. Signed overflow is undefined behavior.

In plain English. Two whole numbers divide to a whole number: the fraction is dropped, never rounded. The remainder keeps the sign of the number being divided. Put a decimal on either side and you get a decimal answer. Unsigned numbers roll over like an odometer, but going past the biggest signed number is a bug with no promised result.

Example. static_cast<double>(total) / count converts before dividing, so an average of 7 and 2 keeps its .5.

Why this rule exists. Integer division silently drops fractions, the most common source of wrong averages and percentages in C++.

Input, output, and strings

Rule

Reading input rules

also known as: extraction with >>, fail state, getline, the >> then getline trap

Formal. std::cin >> x skips leading whitespace (spaces, tabs, newlines), then reads the characters that fit x's type and stops at the first one that doesn't. Whatever is left waits for the next read. Into a std::string, >> reads one word. If no number can be read, a numeric read stores 0 and puts std::cin in a fail state, and every later read does nothing until std::cin.clear(). Input that starts with a number succeeds: 12abc gives 12 and leaves abc waiting. std::getline(std::cin, s) reads up to the next newline, spaces included, and discards the newline. After std::cin >> age; the newline is still waiting, so a getline right after it reads an empty line. std::getline(std::cin >> std::ws, s) skips that leftover first.

In plain English. >> grabs one piece at a time and treats spaces and Enter as dividers. If it wants a number and gets a word, the variable becomes 0 and std::cin stops listening until you reset it. A number followed by junk counts as the number, and the junk waits to trip up the next read. To read a whole line, spaces and all, use std::getline, and skip the leftover Enter with std::ws when a >> came first.

Example. The user types 36, Enter, Ada Lovelace, Enter. std::cin >> age; then std::getline(std::cin >> std::ws, name); gives 36 and Ada Lovelace. Drop the >> std::ws and name is empty.

Why this rule exists. Almost every input bug in a first program is one of these: a word read as a number, a name cut at the space, or an empty line from a leftover Enter.

Operators, expressions, and control flow

Rule

Control-flow rules

also known as: short-circuiting, = versus ==, switch fallthrough, off-by-one

Formal. && and || short-circuit: the right side runs only if the left side doesn't already decide the answer. = assigns and == compares, so if (x = 5) stores 5 and the test is always true. Arithmetic binds tighter than comparison, comparison tighter than &&, and && tighter than ||, so a || b && c means a || (b && c). A switch jumps to the matching case and then falls through into the cases below it until a break. [[fallthrough]]; marks a fallthrough you meant, and only silences the warning. for (int i = 0; i < n; ++i) visits 0 to n - 1; writing <= runs one time too many.

In plain English. C++ stops checking as soon as it knows the answer, so put a guard like count > 0 first. One = stores, two compare. && groups before ||, and parentheses make it clear. A switch keeps running into the next case until it meets break. A counting loop over n things stops before n.

Example. count > 0 && total / count > 90 never divides by zero: when count is 0 the left side is false and the division is skipped.

Why this rule exists. Each of these compiles, runs, and gives a wrong answer, which makes them some of the most common bugs in beginner and professional code alike.

Functions

Rule

Function rules

also known as: declare before use, default arguments, overloading, pass by value, one definition

Formal. A function must be declared before it is called: define it above the call, or put a declaration such as int square(int x); first. Default arguments fill trailing parameters the caller leaves out, and must come last in the list. When a function has a separate declaration, the default goes on the declaration, and repeating it on the definition is an error. Overloads must differ in their parameters; a different return type alone isn't enough. Parameters are passed by value by default: each is a copy, so changing it doesn't change the caller's variable. A header holds declarations; one .cpp file holds each definition. A missing definition is a link error, and a function defined twice is a redefinition or multiple-definition error.

In plain English. C++ reads top to bottom, so it has to meet a function's first line before the call. Pre-filled arguments go at the end, on the line other files see. Functions can share a name only if they take different inputs. A function gets a photocopy of what you pass. The header is the table of contents, and the real code lives in exactly one .cpp file.

Example. convert.h declares double to_km(double miles);, and convert.cpp defines it. Any file that includes convert.h can call to_km, as long as convert.cpp is part of the build.

Why this rule exists. These rules explain the first compile and link errors every C++ programmer meets, and why a function can't quietly change your variables.

Scope, lifetime, and the stack

Rule

Scope and lifetime rules

also known as: block scope, automatic lifetime, static locals, globals, namespaces

Formal. Scope is where in the source a name can be used; lifetime is when the object behind it exists while the program runs. A local's name works from its declaration to the closing brace of its block, including nested blocks. A local is created when execution reaches it and destroyed when execution leaves its block, so every call starts fresh. A static local is initialized once, the first time execution reaches it, and keeps its value until the program ends; its scope is unchanged. A global's name works in every function below it, and it lives from before main starts until the program ends. A name inside a namespace is used from outside as family::name, and the same name in two namespaces never collides. An unnamed namespace, namespace { ... }, keeps its contents private to one .cpp file.

In plain English. Scope is where you can write a name; lifetime is when the variable is alive. A plain local lives on its call's plate and is cleared at its }. The word static moves it to a permanent shelf, but its name still works only inside its function. A global is on that shelf too, and every function can change it. Namespaces give names a family name, and a namespace with no name keeps things private to one file.

Example. static int ticket = 100; inside next_ticket(), with ++ticket; return ticket;, gives 101, 102, 103 on three calls. Without static it gives 101 every time.

Why this rule exists. Knowing where a name works and how long its variable lives explains "not declared in this scope" errors, functions that forget, and globals that change behind your back.

Arrays, vectors, and strings in depth

Rule

Index, capacity and string_view rules

also known as: bounds checking, size versus capacity, reallocation, string_view lifetime

Formal. A container of size n has valid indexes 0 to n - 1. c[i] doesn't check: an index out of range is undefined behavior. c.at(i) checks and throws std::out_of_range; if nothing catches it, the program stops. size() is how many elements a vector holds and capacity() is how many fit in its current block, with capacity() >= size(). When push_back finds the block full, the vector moves every element to a new, larger block. reserve(n) makes the capacity at least n and leaves the size unchanged. A std::string_view is a read-only window onto characters owned by something else, and it must not outlive that text.

In plain English. A row of 5 boxes has boxes 0 to 4. [] walks there blindfolded, and at() checks first and stops the program loudly if the box doesn't exist. A vector rents more room than it uses; when the room runs out, it moves house to a bigger block. A string_view is a window onto someone else's letters, so it must never outlive them.

Example. A function that builds a local std::string must return std::string, not std::string_view: the local is destroyed when the function returns, and a string_view of it would show rubble.

Why this rule exists. Reading past the end and keeping a string_view of text that's gone are two of the most common memory bugs in C++, and compilers often miss both.

References

Rule

Reference rules

also known as: aliases, no re-binding, const references, dangling references, value semantics

Formal. A reference, int& r = x;, is another name for an existing object. It must be bound when declared, and it can never be re-bound: r = b; copies b's value into the object r names. A const T& binds to the caller's object with no copy and is read-only; unlike a plain T&, it can also bind to a temporary such as a literal. A function's locals and by-value parameters are destroyed when it returns, so a reference to one of them dangles, and using it is undefined behavior; return by value instead. C++ types have value semantics: b = a on a std::vector copies every element, and sharing happens only when you write &.

In plain English. A reference is a second name tag on the same box. It must go on a box the moment you make it, and it stays on that box forever. A const tag means look, don't touch, and it also accepts quoted text. Never hand back a tag for a function's own variable: the box is thrown away when the function ends. In C++, making b from a photocopies unless you ask for a tag with &.

Example. for (int& s : scores) { s = s + 5; } changes every score. Without the &, the loop changes throwaway copies.

Why this rule exists. References are how C++ avoids copies and lets functions change your data, and a dangling one is the first memory bug this course meets.

Pointers and memory

Rule

Choosing a parameter type

also known as: pass by value vs const reference, in parameters

Formal. A parameter's type states what the function does with the argument. A small value it only reads (int, double) is passed by value. A larger object it only reads is passed as const T&, which avoids the copy. Text it reads without keeping is std::string_view, and a run of elements it reads is std::span<const T>. An object it must change, and which must exist, is T&. An object that may be absent is T* or const T*, with nullptr meaning "none".

In plain English. Decide what the function does with the thing you hand it. Just looking at a number? Give it its own copy. Just looking at something big? Let it look at yours, read-only, so nothing is copied. Allowed to change yours? Hand it yours with a plain &. Might there be nothing to hand over? Use a pointer, where "nothing" is nullptr.

Example. double average(std::span<const int> scores), void rename(Account& account, std::string_view name) and int square(int x) each say, in the signature alone, what they'll do.

Why this rule exists. The wrong choice either copies large objects on every call, or lets a function change something its caller expected to keep.

Dynamic memory, ownership, and RAII

Rule

Choosing an owner

also known as: unique_ptr vs shared_ptr vs weak_ptr

Formal. An object with one owner at a time is held in a std::unique_ptr<T>, made with std::make_unique. Only when several owners must keep it alive does it move to a std::shared_ptr<T>, made with std::make_shared, which deletes the object when its reference count reaches 0. A unique_ptr converts to a shared_ptr by moving; the reverse is impossible. A std::weak_ptr<T> observes an object that shared_ptrs own without owning it, and breaks cycles of shared_ptrs, which would otherwise never reach a count of 0. A function that only uses an object takes T& or const T&, not a smart pointer.

In plain English. Start with one owner. Share only when two parts of the program really must both keep the object alive. If two shared objects hold on to each other, neither is ever freed, so one side should only watch. And a function that just uses an object doesn't need to own it at all.

Example. auto doc = std::make_unique<Document>(); and later std::shared_ptr<Document> shared = std::move(doc); when a second owner appears. A child's back-link to its parent is a std::weak_ptr<Parent>.

Why this rule exists. Shared ownership costs a control block and an extra count update on every copy, and it hides who is responsible for an object. Cycles of it leak memory with no new in sight.

Copy, move and the Rule of 0/3/5

Rule

The Rule of Five

also known as: the five special member functions

Formal. A class that manages a resource by hand writes all five special member functions:

Member Signature for a class T
destructor ~T()
copy constructor T(const T& other)
copy assignment T& operator=(const T& other)
move constructor T(T&& other) noexcept
move assignment T& operator=(T&& other) noexcept

Two generation rules make this a rule rather than a suggestion:

  • Declare a destructor or a copy operation, and the compiler stops generating the move operations. std::move on the object still compiles, and copies.
  • Declare a move operation, and the implicit copy operations are deleted until you write them or declare them = default.

Both moves are marked noexcept, and both leave the source valid and destructible: an owning pointer is set to nullptr.

In plain English. A class that looks after its own memory needs a complete checklist: clean-up, two copying jobs and two moving jobs. Write only some of them and C++ may quietly stop writing the others. Your "moves" then become full copies.

Example. Buffer(Buffer&& other) noexcept takes the other object's pointer with std::exchange(other.data_, nullptr). When the moved-from object is destroyed later, it runs delete[] nullptr, which does nothing.

Why this rule exists. The trap is silent: nothing fails to compile, the program just copies where you expected a move. noexcept matters because std::vector, when it grows, moves its elements only if their move constructor is noexcept. Otherwise it copies them to stay safe.

The standard library: containers and iterators

Rule

Iterator invalidation rules

also known as: which operations invalidate iterators, pointers and references

Formal. For std::vector and std::string, an operation that reallocates (growing past capacity(), or reserve beyond it) invalidates every iterator, pointer and reference into the container; without a reallocation, insert and erase invalidate those at or after the changed position, and any size change invalidates end(). For std::list, std::map and std::set, inserting invalidates nothing and erasing invalidates only the erased element. For std::unordered_map, a rehash invalidates iterators but not references or pointers. erase returns an iterator to the next element, so a loop that erases writes it = c.erase(it); and increments only when it didn't erase.

In plain English. A vector keeps its items side by side. When it runs out of room it moves everything, and every position you were holding stops being valid. Removing or inserting in the middle shifts everything after that spot. A list or map keeps each item in its own box that never moves, so only a removed item's position stops being valid.

Example. auto it = v.begin(); v.push_back(4); may leave it pointing into freed memory. for (auto it = m.begin(); it != m.end();) { if (bad(*it)) it = m.erase(it); else ++it; } is safe.

Why this rule exists. Using an invalidated iterator is undefined behavior, and nothing checks it in an ordinary build.

Debugging, warnings, sanitizers and static analysis

Rule

Which tool finds which bug

also known as: warnings vs static analysis vs sanitizers vs the debugger

Formal. The four tools catch bugs at different moments:

Tool When Sees Misses
compiler warnings every build one file, mostly one function at a time values that depend on input
static analyzers a separate step, or every build via CMake paths through the code, library rules, style anything decided at run time
sanitizers test runs of an instrumented build real memory and arithmetic, as it happens paths the run doesn't take
debugger when you ask any value, at any stop nothing is flagged automatically; you judge

Matching a bug to the tool most likely to catch it first:

Bug Tool
out-of-bounds access, use after free, double free AddressSanitizer (-fsanitize=address)
signed overflow, bad shift, null dereference UndefinedBehaviorSanitizer (-fsanitize=undefined)
data race between threads ThreadSanitizer (-fsanitize=thread)
use after std::move, a needless by-value copy a static analyzer such as clang-tidy
a signed/unsigned comparison, an unused variable compiler warnings (-Wall -Wextra)
a wrong result with no crash and no warning the debugger: breakpoints and stepping

In plain English. Warnings come free with every build. Analyzers read the code more carefully but never run it. Sanitizers watch the program as it runs. The debugger lets you look at anything, but only you can decide what's wrong.

Example. A moved-from std::string is still a valid object, so no sanitizer fires when it is used. clang-tidy's bugprone-use-after-move check is what flags it.

Why this rule exists. The tools overlap, and none replaces the others. A bug whose trigger is written in the code can be caught without running anything. A bug that depends on input, timing or sizes known only at run time needs a sanitizer run that reaches it.

Appendix A: Old Way and Modern Way -- a field guide to legacy C++

Rule

Old Way to Modern Way: arrays, strings and pointers

also known as: C array to std::array, NULL to nullptr, pointer and count to std::span

Formal. Each Old Way form on the left compiles and still appears in legacy code. Its modern replacement on the right moves one fact the programmer had to remember into a type the compiler checks.

Old Way Modern Way What the type now carries
int a[5] (size fixed in the code) std::array<int, 5> its length
a C array whose size isn't fixed in the code std::vector<int> its length, and it can grow
char buf[32] plus strcpy (owns text) std::string its own buffer
a read-only const char* parameter std::string_view its length
NULL, or 0 used as a pointer nullptr a pointer-only type
f(const T* data, int n) f(std::span<const T> data) the count, bundled with the pointer
for (int i = 0; i < v.size(); ++i) that only reads v[i] for (int x : v) where the container starts and ends

In plain English. Old code keeps facts in the programmer's head: how long a list is, how big a text box is, that a zero means "no pointer". The modern spelling writes each fact into the code, so the compiler can check it.

Example. double average(const int* data, int n) becomes double average(std::span<const int> data). The caller passes the array alone, and the span reads the count from the array's type.

Why this rule exists. A C array passed to a function decays to a pointer to its first element and loses its length. NULL is some integer zero, and which one is up to the library. With overloads f(int) and f(int*), MSVC's NULL is 0, so f(NULL) quietly calls f(int); GCC and Clang use a special __null, so the call is ambiguous. Either way it never means "pointer". Each modern type closes one of these gaps. Modernizing changes the type, not the behavior: the program must print the same output.

Rule

Old Way to Modern Way: memory and ownership

also known as: new to make_unique, goto cleanup to RAII, auto_ptr to unique_ptr, private copy to = delete

Formal. Each Old Way ownership pattern is replaced by an RAII owner: an object whose destructor releases the resource on every path out of its scope, including early returns and exceptions.

Old Way Modern Way
T* p = new T(args); ... delete p; auto p = std::make_unique<T>(args);
new T[n] ... delete[] std::vector<T>
goto cleanup; on every exit, releases under the label one RAII owner per resource, then ordinary early returns
std::auto_ptr<T> std::unique_ptr<T>, with std::move wherever ownership moves
copy constructor and copy assignment declared private and never defined both declared = delete in the public part of the class

std::shared_ptr replaces new only when several owners really share one object.

In plain English. Old code borrows memory with new and must hand it back with delete, once, on every way out of a function. Modern code gives the memory to an owner object that hands it back by itself when the owner's variable ends.

Example. int* level = new int{3}; followed later by delete level; becomes auto level = std::make_unique<int>(3);, with no delete at all.

Why this rule exists. A missed delete leaks, and a second one corrupts the heap. A goto cleanup works until someone adds a return that skips the label, or an exception bypasses it. Copying a std::auto_ptr silently moves ownership and leaves the source empty. A private, undefined copy gives callers an access error, or a link error inside the class. = delete gives every caller a clear compile error that names the deleted function.

Rule

Old Way to Modern Way: constants, casts, type names and printf

also known as: define to constexpr, C-style cast to static_cast, typedef to using, printf to std::format

Formal. Each Old Way spelling is replaced by one that the compiler can type-check.

Old Way Modern Way
#define MAX_USERS 64 constexpr int max_users = 64;
#define SQUARE(x) ((x) * (x)) a constexpr or inline function, or a template
(double)total or double(total) static_cast<double>(total)
typedef std::vector<std::string> NameList; using NameList = std::vector<std::string>;
printf("%d items", qty) std::cout << std::format("{} items", qty)
scanf into a char buffer std::cin or std::getline

const_cast and reinterpret_cast are for the rare cases where you really mean them. The printf placeholders translate directly: %-8s is {:<8}, %3d is {:3}, and %8.2f is {:8.2f}.

In plain English. A #define is find-and-replace that runs before the compiler reads your code, so it has no type and ignores scopes. A cast in parentheses doesn't say what kind of conversion it is. printf trusts its pattern to describe each value. The modern forms say what they mean, and the compiler checks it.

Example. A function-like macro pastes its argument's text. With #define MAX(a, b) ((a) > (b) ? (a) : (b)), MAX(i++, 2) can run i++ twice. SQUARE(i++) is worse: it puts two i++ into one multiplication with no order between them, which is undefined behavior. constexpr int square(int x) evaluates its argument once, like any function.

Why this rule exists. A C-style cast tries const_cast, then static_cast, then reinterpret_cast, and uses the first that compiles, so one spelling can hide a dangerous conversion. A printf placeholder that doesn't match its argument's type is undefined behavior. using reads left to right and, unlike typedef, can take template parameters.

Rule

Old Way to Modern Way: errors, callbacks and generic code

also known as: out-parameter to optional or expected, tagged union to variant, bind to lambda, enable_if to concepts

Formal. Each Old Way pattern keeps a rule in a convention. Its replacement writes that rule into a type or a declaration.

Old Way Modern Way
error code returned, result through an out-parameter std::optional<T> when "no answer" needs no reason; std::expected<T, E> when the caller needs to know why; an exception when failure is rare and the caller can't continue
union plus an enum field saying which member is live std::variant<A, B>, read with std::visit, std::holds_alternative or std::get_if
function pointer, often with a void* user_data a lambda at the call site; std::function<Sig> to store a callback; a template parameter when it is only called
std::bind(f, std::placeholders::_1, 10) [](int x) { return f(x, 10); }
std::enable_if_t<std::is_integral_v<T>> (SFINAE) a concept: template <std::integral T>, or a requires clause

In plain English. Old code runs on unwritten rules: "if it returns -1, don't read the answer", "if the tag says 2, the box holds text". Each modern form writes the rule down where the compiler can check it.

Example. int parse_port(const char* text, int* port), returning 0 on success, becomes std::optional<int> parse_port(std::string_view text). A caller can no longer read a port that was never set.

Why this rule exists. Nothing forces a caller to check an error code before reading the out-parameter. Reading the wrong member of a union is undefined behavior. A function pointer carries no data. std::bind hides the argument order. An enable_if failure shows up as "no matching function", while a failed concept names the requirement that wasn't met.

Rule

Old Way to Modern Way: headers, builds and threads

also known as: include what you use, CMake over Makefiles, package managers over vendored copies, jthread over manual join

Formal.

Old Way Modern Way
#include <bits/stdc++.h> one standard header per facility used
using namespace std; in a header std:: names; a using-directive only inside a function in a .cpp
a hand-written Makefile a CMakeLists.txt that describes targets
library sources copied into third_party/ vcpkg, Conan or FetchContent, pinned to a version
std::thread with join() by hand std::jthread

In plain English. Each Old Way here works on the author's machine and fails somewhere else: another compiler, another file that includes the header, a library update that never arrives, or an exception that skips the join().

Example. std::jthread worker([&total] { total = 42; }); joins when worker goes out of scope, so no early return or exception can leave the thread running.

Why this rule exists. Unlike the earlier entries, these are project-level choices. Change them with the team, one at a time, with the tests passing before and after.

Appendix B: Toolchain quick-reference cards

Rule

Warning and language-standard flags

also known as: -Wall -Wextra and /W4, -Werror and /WX, -std=c++20 and /std:c++20

Formal. None of the recommended warnings are on by default. Ask for them, and choose the language standard, on every build.

Purpose GCC and Clang MSVC
the recommended set -Wall -Wextra /W4
flag non-standard extensions -Wpedantic /permissive-
conversions that may lose data -Wconversion on at /W4
a local that hides another name -Wshadow on at /W4
treat every warning as an error -Werror /WX
C++17 -std=c++17 /std:c++17
C++20 -std=c++20 /std:c++20
C++23 -std=c++23 /std:c++latest

In CMake, set(CMAKE_CXX_STANDARD 23) picks the standard, set(CMAKE_CXX_STANDARD_REQUIRED ON) refuses to fall back to an older one, and set(CMAKE_CXX_EXTENSIONS OFF) gives -std=c++23 instead of -std=gnu++23.

In plain English. -Wall sounds like "every warning", but it is only a good starting bundle, and -Wextra adds a second one. MSVC's /W4 is roughly both. -Werror or /WX turns any warning into a hard stop.

Example. g++ -std=c++23 -Wall -Wextra -Wpedantic main.cpp -o main on GCC is cl /std:c++latest /EHsc /W4 /permissive- /utf-8 main.cpp on MSVC.

Why this rule exists. A warning marks code that compiles but is probably wrong, and the flags that ask for warnings are spelled differently on each toolchain. MSVC's /Wall really is almost everything, including warnings inside the standard library's own headers, so /W4 is the level to use.

Rule

Build-type and sanitizer flags

also known as: Debug, Release, RelWithDebInfo, MinSizeRel, -fsanitize=address, undefined, thread

Formal. CMake's four build types map to these GCC and Clang flags (MSVC's equivalents in the last column):

CMAKE_BUILD_TYPE GCC and Clang MSVC
Debug -g (optimization stays at -O0) /Zi /Od
Release -O3 -DNDEBUG /O2 /DNDEBUG
RelWithDebInfo -O2 -g -DNDEBUG /Zi /O2 /DNDEBUG
MinSizeRel -Os -DNDEBUG /O1 /DNDEBUG

-DNDEBUG switches assert off. Sanitizers are built with -g and without heavy optimization:

Flag Finds
-fsanitize=address out-of-bounds reads and writes, use after free, double free, leaks
-fsanitize=undefined signed overflow, bad shifts, null dereference, other undefined behavior
-fsanitize=thread data races between threads
-fno-omit-frame-pointer nothing itself; gives reports complete call stacks

address and thread can't be combined in one build. MSVC has only AddressSanitizer, as /fsanitize=address.

In plain English. A debug build keeps your code as written so a debugger can follow it. A release build lets the compiler speed it up, and switches off assert. A sanitizer build is a debug build with a guard on every risky operation, which stops the program at the first bad one.

Example. g++ -std=c++23 -g -fsanitize=address,undefined -fno-omit-frame-pointer main.cpp -o main-asan builds with AddressSanitizer and UndefinedBehaviorSanitizer together.

Why this rule exists. Each build answers a different question: debug for stepping, release for shipping and timing, sanitizers for catching memory and undefined-behavior bugs the moment they happen.

Rule

The CMake configure, build and test commands

also known as: cmake -S . -B build, cmake --build build, ctest --test-dir build

Formal. Every CMake workflow is three commands.

Step Command What it does
configure cmake -S . -B build reads CMakeLists.txt, finds the compiler, writes build files for one generator
build cmake --build build runs that generator's build tool
test ctest --test-dir build runs every test registered with add_test

Where Debug or Release is chosen depends on the generator:

Generator Choose the build type
single-config (Ninja, Makefiles) at configure: -DCMAKE_BUILD_TYPE=Release
multi-config (Visual Studio, Ninja Multi-Config) at build: --config Release, and ctest -C Release

The compiler is chosen once per build folder, at its first configure, from CXX or -DCMAKE_CXX_COMPILER. ctest --output-on-failure prints a failing test's own output, and -R <pattern> runs only the tests whose names match.

In plain English. Configure plans the work, build does it, and CTest checks the result. Some build tools want "debug or release?" when you plan, and others when you build.

Example. On Linux with Ninja: cmake -S . -B build -G Ninja -DCMAKE_BUILD_TYPE=Debug, then cmake --build build, then ctest --test-dir build --output-on-failure.

Why this rule exists. The Visual Studio generator ignores CMAKE_BUILD_TYPE and builds Debug unless the build step says --config Release. Putting an option at the wrong step is the most common reason a CMake command "does nothing".