C++ Basics


  • Description: A note on the C++ basics — constants, declarations, tokens, storage qualifiers, initialization forms, enums, attributes, and alignment
  • My Notion Note ID: K2A-B1-1
  • Created: 2018-09-14
  • Updated: 2026-02-28
  • License: Reuse is very welcome. Please credit Yu Zhang and link back to the original on yuzhang.io

Table of Contents


1. Constants and Immutability

1.1 const vs #define

The const keyword and the #define preprocessor directive both define constant values, but they behave very differently. Prefer const (or constexpr since C++11) in modern C++.

Aspect const #define
Type safety Has a type; compiler performs type checking No type; simple text substitution
Scope Respects scope rules Global text replacement
Debugging Visible in debugger Replaced before compilation; invisible to debugger
Testability Can be used in unit tests and assertions Cannot be inspected programmatically
#define MAX_SIZE 100              // No type, no scope, no safety
const int kMaxSize = 100;         // Type-safe, scoped, debuggable
constexpr int kMaxSize2 = 100;    // C++11: compile-time constant

1.2 Pointer to const vs const Pointer

The position of const relative to * determines what is immutable.

int value = 42;

// Pointer to const: cannot modify the pointed-to value through this pointer
const int* p1 = &value;       // OK
int const* p2 = &value;       // Same as above (equivalent syntax)
// *p1 = 10;                  // ERROR: cannot modify through pointer-to-const

// Const pointer: cannot change what the pointer points to
int* const p3 = &value;       // Must be initialized
// p3 = &other;               // ERROR: cannot reassign const pointer
*p3 = 10;                     // OK: can modify the value

// Const pointer to const: both are immutable
const int* const p4 = &value; // Must be initialized
// *p4 = 10;                  // ERROR
// p4 = &other;               // ERROR

Mnemonic: Read the declaration right-to-left. const int* is "pointer to const int." int* const is "const pointer to int."


2. Declarations

2.1 Variable Declaration Gotchas

C++ declaration syntax can be surprising when declaring multiple variables on one line.

  1. Only the last variable in a chain receives the initializer:

    // Only 'index' is initialized to 0; 'column' and 'row' are uninitialized
    int column, row, index = 0;
    
    // All three are initialized to 0
    int column = 0, row = 0, index = 0;
    
    // Chain assignment also works
    int column, row, index;
    column = index = row = 0;
    
  2. The * in a multi-variable pointer declaration applies only to the immediately following identifier:

    int* a, b, c;
    // This is NOT three pointers. It is equivalent to:
    //   int* a;   -- pointer to int
    //   int b;    -- plain int
    //   int c;    -- plain int
    
    // To declare three pointers:
    int *a, *b, *c;
    
    // Or better yet, one declaration per line:
    int* a;
    int* b;
    int* c;
    

2.2 Tokens

A token is the smallest individual element of a C++ program that the compiler recognizes. There are six categories:

  1. Keywords — Reserved words with special meaning (if, class, virtual, etc.)
  2. Identifiers — User-defined names for variables, functions, classes
  3. Constants — Literal values (42, 3.14, 'a', true)
  4. String literals — Sequences of characters ("hello")
  5. Special symbols — Punctuation and delimiters ([] () {} , ; * = #)
  6. Operators — Symbols that perform operations (+, -, <<, >)

2.3 Comma Operator

The comma operator evaluates all operands from left to right but returns only the result of the last expression. It is rarely used intentionally and should generally be avoided for clarity.

int a = (1, 2, 3);  // a == 3; all expressions evaluated, last one returned

3. Storage Qualifiers

3.1 mutable

The mutable keyword allows a non-static class member to be modified even within a const member function.

Why mutable is needed

A const member function promises logical const: the object's user-observable state will not change. That is a weaker promise than bitwise const, which would mean no byte of the object's memory changes.

Real classes often have internal bookkeeping that callers do not observe and should not have to know about — caches, lazy-init flags, mutexes, debug counters. Updating that bookkeeping inside a const method is fine logically, but the type system cannot tell logical-const apart from bitwise-const on its own. mutable is the escape hatch: it marks a member as "OK to modify even in a const method, because it is not part of the observable state."

Without mutable, you would have to either drop the const qualifier from the method (contagious — every caller through a const reference breaks) or use const_cast (legal, but ugly and easy to misuse).

Common use cases

  1. Memoization / caches — cache an expensive computation; the cached value is internal, not observable.
  2. Lazy initialization — defer constructing a heavy member until first access.
  3. Mutex inside const methods — to make const methods thread-safe, the mutex itself must be lockable, which requires it to be mutable.
  4. Reference-counted handlesstd::shared_ptr's control block updates a count even when the pointed-to object is const.
  5. Debug instrumentation — access counters, timing data, etc.

Example

#include <iostream>

class Example {
public:
    Example(int val) : val_(val) {}

    int getVal() const {
        // This is a const method, but we can modify mutable members
        ++access_count_;
        return val_;
    }

    int getAccessCount() const { return access_count_; }

private:
    int val_;
    mutable int access_count_ = 0;  // Can be modified in const methods
};

int main() {
    const Example ex(5);
    std::cout << ex.getVal() << std::endl;         // 5 (access_count_ = 1)
    std::cout << ex.getVal() << std::endl;         // 5 (access_count_ = 2)
    std::cout << ex.getAccessCount() << std::endl; // 2
    return 0;
}

getVal() is const because the value it returns to callers does not change between calls. The fact that access_count_ ticks up is internal — it is not part of what the user sees as "the state of ex," so it is correct for mutable to allow it.

3.2 volatile

The volatile qualifier tells the compiler that a variable's value may change at any time by means outside the program's normal flow. The compiler must always read and write the variable from memory; it cannot cache it in a register or optimize away "redundant" reads.

volatile int hardware_register;  // Value may change outside program control

When to use volatile

volatile has three legitimate uses, all involving memory that is modified outside the standard C++ execution model:

  1. Memory-mapped I/O — accessing hardware registers mapped into the program's address space (status flags, device control registers, etc.).
  2. Signal handlers — a flag set inside a signal handler and polled in the main flow (use volatile std::sig_atomic_t).
  3. setjmp / longjmp — local variables modified between setjmp and the matching longjmp must be volatile to be reliably observable after the jump.

Example: signal handler flag

#include <csignal>
#include <iostream>

volatile std::sig_atomic_t signal_received = 0;

void handler(int) {
    signal_received = 1;  // safe to assign inside a signal handler
}

int main() {
    std::signal(SIGINT, handler);

    while (!signal_received) {
        // Without volatile, the compiler could observe that nothing in the
        // visible flow modifies signal_received and turn this into an
        // infinite loop. volatile forces a fresh read every iteration.
    }
    std::cout << "Caught signal, exiting." << std::endl;
    return 0;
}

When NOT to use volatile

volatile does not provide thread safety or atomicity. It guarantees neither atomic reads/writes, ordering between threads, nor inter-thread memory visibility. For multithreaded code, use std::atomic<T> or proper synchronization primitives (std::mutex, std::condition_variable).

// WRONG: volatile does not make this thread-safe
volatile int counter = 0;
// ++counter from multiple threads is still a race condition

// CORRECT
std::atomic<int> counter{0};
// ++counter is atomic across threads

Rule of thumb: volatile is for talking to the outside world (hardware, signals, OS-level jumps); std::atomic is for talking to other threads.

3.3 extern

The extern keyword declares that a variable or function is defined in another translation unit (source file). It tells the compiler to look for the definition during linking rather than expecting it in the current file.

// file1.cpp -- definition
int myVar = 42;

// file2.cpp -- declaration (uses the variable defined in file1.cpp)
extern int myVar;

int main() {
    std::cout << myVar << std::endl;  // 42
    return 0;
}

extern is also used for C linkage when calling C functions from C++ code:

extern "C" {
    void c_library_function(int arg);
}

4. Initialization Forms

C++ has many ways to initialize a variable. They are not all equivalent.

int a;            // default initialization (uninitialized for built-ins)
int b = 0;        // copy initialization
int c(0);         // direct initialization
int d{0};         // direct-list (brace) initialization
int e = {0};      // copy-list initialization
int f{};          // value initialization (zero-initialized)

std::vector<int> v1;            // default ctor
std::vector<int> v2(5);         // FIVE zero-initialized ints
std::vector<int> v3{5};         // ONE element with value 5  (initializer_list wins!)
std::vector<int> v4{5, 10};     // TWO elements: 5, 10
std::vector<int> v5(5, 10);     // FIVE elements: 10, 10, 10, 10, 10

struct Point { int x; int y; };
Point p{1, 2};                  // aggregate initialization
Point q = {.x = 1, .y = 2};     // designated initializers (C++20)

The {} (brace) form is recommended in modern C++:

  1. Prevents narrowing conversionsint x{3.14}; is an error, but int x = 3.14; silently truncates.
  2. Avoids the "most vexing parse"Widget w(Foo()); declares a function; Widget w{Foo{}}; constructs an object.
  3. Uniform syntax for aggregates, classes, and arrays.

The exception: when a class has an initializer_list constructor, brace init prefers it. std::vector<int> v{5}; creates one element with value 5, not a 5-element vector. Use () when calling the count-based constructor.


5. Enums

C++ has two kinds of enumerations: traditional (unscoped) and scoped (enum class, C++11).

// Unscoped enum (C-style) — names leak into the surrounding scope, implicitly converts to int
enum Color { Red, Green, Blue };
int c = Red;                   // OK: implicit conversion
if (c == 0) { /* ... */ }

// Scoped enum (enum class) — names are scoped, no implicit conversion
enum class Priority { Low, Medium, High };
Priority p = Priority::High;
// int n = p;                  // ERROR: no implicit conversion
int n = static_cast<int>(p);   // OK: explicit

Always prefer enum class in modern code:

  1. No name leakagePriority::High, not just High.
  2. No accidental conversions to/from int.
  3. Specify the underlying type explicitly: enum class Flags : uint8_t { ... }.

For bitmask enums, opt back into the bitwise operators:

enum class Perm : unsigned { Read = 1, Write = 2, Exec = 4 };

constexpr Perm operator|(Perm a, Perm b) {
    return static_cast<Perm>(static_cast<unsigned>(a) | static_cast<unsigned>(b));
}

Perm p = Perm::Read | Perm::Write;

6. Attributes

Attributes (C++11+) are standardized annotations the compiler can use to enable diagnostics or optimizations.

Attribute Meaning
[[nodiscard]] Warn if the return value is ignored
[[nodiscard("reason")]] (C++20) Warn with a specific message
[[deprecated]] Warn on use
[[deprecated("use foo instead")]] With message
[[maybe_unused]] Suppress unused-variable warnings
[[fallthrough]] Acknowledge intentional switch fallthrough
[[likely]] / [[unlikely]] (C++20) Branch prediction hints
[[no_unique_address]] (C++20) Allow zero-size empty subobjects
[[nodiscard]] int compute() { return 42; }
compute();   // warning: ignoring return value

[[deprecated("use compute() instead")]]
int legacy_compute();

void process(int x, [[maybe_unused]] int debug_id) {
    // debug_id only used in debug builds; no warning in release
}

switch (n) {
    case 1: do_one();
    [[fallthrough]];
    case 2: do_two_or_one(); break;
    case 3: do_three(); break;
}

if (cond) [[likely]] {
    // hot path
}

[[nodiscard]] on factory functions and fallible operations is one of the most useful — it catches "I forgot to check the return value" bugs at compile time.


7. Alignment and std::byte

alignof and alignas query and request memory alignment.

alignof(int);                  // typically 4 (4-byte alignment)
alignof(double);               // typically 8

struct alignas(64) CacheLine {  // aligned to a 64-byte cache line
    int data[16];
};

alignas(16) char buffer[256];   // 16-byte aligned buffer

Common uses:

  1. Cache-line alignment to avoid false sharing in multi-threaded code.
  2. SIMD intrinsics that require 16- or 32-byte alignment.
  3. Hardware that requires specific alignment (e.g., DMA buffers).

std::byte (C++17, <cstddef>) is a type-safe byte type — meant for raw memory, not arithmetic. It does not implicitly convert to/from char or int.

#include <cstddef>

std::byte b{0xFF};             // ok
b = std::byte{0x10};           // ok
// int n = b;                   // ERROR: no implicit conversion
int n = std::to_integer<int>(b);   // explicit: 16
b |= std::byte{0x01};          // bitwise ops are defined

Use std::byte for "this is opaque memory": serialization buffers, generic byte streams, hashing input. Use unsigned char if you actually need to do arithmetic on bytes.


8. Aggregate Types and POD

These terms classify types by their layout and behavior. They matter for serialization, ABI compatibility, and memcpy-based copying.

Aggregate types

An aggregate is an array, or a class type with:

  1. No user-declared (or inherited) constructors.
  2. No private or protected non-static data members.
  3. No virtual functions.
  4. No virtual or non-public base classes (since C++17).

Aggregates can be initialized with brace-init lists, including designated initializers (C++20):

struct Point { int x; int y; };
Point p{1, 2};                   // aggregate init
Point q = {.x = 1, .y = 2};      // designated init (C++20)

Trivially copyable

A type is trivially copyable if its copy, move, and destruct operations are all "trivial" (compiler-generated with no user logic). Trivially copyable types can be safely memcpy'd.

Standard layout

A type has standard layout if:

  1. All non-static data members have the same access control.
  2. No virtual functions or virtual bases.
  3. Members can be safely interpreted as a C-compatible struct.

POD (Plain Old Data)

A POD type is both a trivial type AND a standard-layout type. (Trivial is stricter than trivially copyable — it additionally requires a trivial default constructor.) POD types are byte-for-byte compatible with C structs — useful for binary serialization, OS APIs, network protocols.

Trait What it means
std::is_aggregate_v<T> Aggregate-initializable
std::is_trivially_copyable_v<T> Safe to memcpy
std::is_standard_layout_v<T> C-compatible layout
std::is_pod_v<T> (deprecated in C++20) Both trivial AND standard layout
struct A { int x; };                    // aggregate, trivially copyable, standard layout, POD
struct B { int x; B(int) {} };          // not aggregate (has ctor)
struct C { int x; private: int y; };    // not standard layout (mixed access)
struct D { virtual void f(); };         // not POD (has virtual)

static_assert(std::is_trivially_copyable_v<A>);
static_assert(!std::is_aggregate_v<B>);

In C++20, std::is_pod was deprecated in favor of the more precise std::is_trivially_copyable_v and std::is_standard_layout_v. POD as a single concept is no longer the right vocabulary; pick the specific trait you need.