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::moveon 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".