2Brute2Best
🔥0
M1.1

Pointers vs References Deep Dive

Easy High-frequency interview topicPointersReferencesPointer Arithmeticint** 2D ArraysFunction Pointers
⚡ 15-Second Mental ModelM1.1 · Instant Intuition
Single-Read Mastery
The Real-World Physical Analogy (Pointers vs References)

A pointer is a house address written on a sticky note (takes 8 bytes; you can erase it or write 0x0). A reference is a nickname for the house itself (takes 0 bytes; you can never redirect a nickname to a different house).

Rule to Remember: Taking &ref gives the address of the target, never the reference.
3 Golden Facts to Remember
Fact #1
Can a reference ever be null in C++, and how does that affect API design?

In well-defined C++, a reference can never be null (int& r = *static_cast<int*>(nullptr); is immediate Undefined Behavior at the moment the null pointer is dereferenced). In API design, this is a contract: take parameters by reference (const T& or T&) when the caller must supply a valid object, and take a pointer (const T* or T*) only when 'no object' (nullptr) is a valid optional input.

Fact #2
How much memory does a reference take compared to a pointer?

A pointer on a 64-bit platform always takes 8 bytes (or 16 bytes for certain member function pointers). A reference is an alias in the C++ standard (sizeof(r) == sizeof(target)). At the machine level, if the reference is local to a function, the compiler usually optimizes it away completely (0 extra bytes). If stored as a struct/class member or passed to an external non-inlined function, the ABI stores an 8-byte address identical to a T* const pointer.

Fact #3
Why can't you create a `std::vector<int&>`, and what is the standard workaround?

std::vector<T> requires T to be Erasable and Assignable and allocates a contiguous T* array in memory. References are not objects—they have no address of their own (int&* is illegal) and cannot be rebound after initialization. The standard workaround is std::vector<std::reference_wrapper<int>> (created via std::ref(x)), which wraps a raw int* inside a small copyable/assignable value object.

Pinned Concepts in this Chapter (Click to Locate in Text):
View All 4 Chapter Q&As
01

Interviewer's lens

What separates a strong C++ candidate from a textbook answer

Interviewers use pointers versus references as a litmus test: do you see variables as abstract symbols, or do you understand physical machine addresses versus compiler aliases?

  • The Amateur View: Reciting textbook answers like 'a pointer can be null, but a reference cannot.'
  • The Senior View: Explaining that a pointer holds a 64-bit house address in its own box of memory, while a reference is an alias—a sticky label slapped onto an existing box.
  • What Evaluators Test:
    1. How compilers lower references to zero-cost CPU registers or hidden non-null pointers (T* const).
    2. Why pointer arithmetic advances by chunks of sizeof(T) bytes rather than 1 single byte.
    3. Why jagged 2D arrays (int**) destroy CPU cache locality compared to contiguous flat buffers.
02

Under the hood & memory layout

Hardware & memory representation

Think of memory like a vast street of houses:

  • A raw pointer (T*) is a notepad where you write down an 8-byte street address. It lives in its own box of memory, can be erased, rewritten to point to another house, or left empty (nullptr).
  • A reference (T&) is a second nameplate nailed to the front door of an existing house. It is not an independent box—it is an alias.

Under the hood on a 64-bit architecture (x86_64 / ARM64):

  1. Raw Pointers: Always occupy 8 bytes (sizeof(void*) == 8). They hold a 64-bit virtual address and support dereferencing (*p) and arithmetic (p + 1).
  2. References at Compile Time: Have no distinct address of their own in the C++ abstract machine. Taking &ref yields the address of the original target, and sizeof(ref) returns sizeof(T).
  3. Machine Code Lowering: With optimizations (-O2), local references disappear into CPU registers at zero runtime cost. When passed across non-inlined function calls or stored inside structs, the ABI lowers them into a non-null, non-rebindable pointer (T* const).
Raw Pointer (T* p)

8 bytes on 64-bit (sizeof(void*) == 8). Holds a virtual address (e.g. 0x7ffd4010) or 0x0 (nullptr). Can be reassigned (p = &other) and indexed (p + k advances by k * sizeof(T) bytes).

Lvalue Reference (T& r)

Alias to an existing object. Must be bound at initialization, cannot be rebound (r = y overwrites the target's value, not the binding!), and &r == &target is always true.

Pointer Arithmetic (p + i)

The CPU calculates reinterpret_cast<uintptr_t>(p) + i * sizeof(T). For int* (sizeof(int) == 4), p + 1 adds 4 bytes, not 1 byte. Subtracting two pointers q - p divides the byte distance by sizeof(T) (std::ptrdiff_t).

Jagged int** vs Contiguous 2D Block

A naive new int*[R] + loop of new int[C] performs R + 1 separate heap allocations scattered in RAM. A contiguous new int[R * C] uses 1 heap block, keeping rows adjacent in L1/L2 cache.

03

Core concepts & syntax traps

01

Rebinding vs Value Assignment & Why Arrays of References Are Illegal

Think of a reference as a nickname glued permanently to a person. Once tied to a variable, it cannot be unpeeled to name someone else:

  1. Rebinding is Impossible: Writing r = b; does not rebind r to b. It copies b's value into the target variable a. The address &r remains forever identical to &a.
  2. No Independent Identity: References have no distinct address or standalone size. In standard C++, pointer-to-reference (int&*) and arrays of references (int& arr[5]) are illegal syntax.
  3. STL Container Rule: Because std::vector<T> requires elements to be assignable and hold their own memory addresses, std::vector<int&> will fail to compile.
  4. The Standard Workaround: When you need a container of references, use std::reference_wrapper<T> (std::ref(x) from <functional>), which wraps a raw pointer T* inside a copyable value object.
C++23 snippet
int a = 10, b = 99;
int& r = a;
r = b; // Does NOT rebind r to b! Overwrites a with 99.
// a is now 99, b is 99, and &r is still &a.

// Need a vector of references? Use std::reference_wrapper:
std::vector<std::reference_wrapper<int>> refs = {a, b};
refs[0].get() = 500; // mutates 'a' directly!
Interview takeaway:

Use a reference when 'null' and rebinding are impossible; use a pointer (or std::reference_wrapper) when you must rebind or store in a container.

02

nullptr vs NULL vs 0 (Why NULL Causes Overload Bugs)

In legacy C, NULL is simply the integer 0 (or 0L). This creates a dangerous trap when functions are overloaded:

  1. The Overload Ambiguity Trap: If you declare void route(int) and void route(int*), calling route(NULL) passes an integer 0. The compiler selects route(int)—or halts with an ambiguous call error.
  2. The Modern C++ Solution: C++11 introduced nullptr, a dedicated keyword of type std::nullptr_t.
  3. Strict Type Safety: nullptr converts implicitly to any raw pointer (T*) or pointer-to-member, but never converts to an integer (int). It guarantees you call the pointer overload every time.
C++23 snippet
void route(int id);       // Overload 1: integer ID
void route(int* payload); // Overload 2: pointer

// route(NULL);    // BUG: calls route(int) because NULL is 0!
route(nullptr);    // Calls route(int*) unambiguously (typeof nullptr is std::nullptr_t)
Interview takeaway:

Never write NULL or 0 for pointers in modern C++; always use nullptr.

03

Pointer Arithmetic, Array Decay & std::ptrdiff_t

Pointers do not advance byte-by-byte; they jump in strides equal to the size of the underlying type:

  1. Stride by Type Size: For an integer pointer (int* p), writing p + 1 does not add 1 byte. The CPU calculates address + 1 * sizeof(int) (an advance of 4 bytes).
  2. Array Decay: When passed into a function, a C-style array (int arr[5]) loses its size metadata and decays into a raw pointer to its first element (&arr[0]). Inside the function, sizeof(p) is 8 bytes (the pointer itself), not 20 bytes.
  3. Pointer Subtraction (std::ptrdiff_t): Subtracting two pointers within the same array (q - p) computes the number of elements between them, not the raw byte delta. The result is a signed integer of type std::ptrdiff_t.
  4. Boundary Invariant: You may legally point to one-past-the-end (arr + 5) to form an STL-style half-open range [begin, end), but dereferencing that address is immediate Undefined Behavior.
C++23 snippet
int arr[5] = {10, 20, 30, 40, 50};
int* p = arr;             // decays to &arr[0]
int* onePast = arr + 5;   // LEGAL to form and compare, UB to dereference (*onePast)
std::ptrdiff_t n = onePast - p; // 5 elements (20 bytes / sizeof(int))
Interview takeaway:

Prefer std::span<T> over raw (T* ptr, size_t len) pairs to prevent array decay bugs.

04

Multi-Level Pointers (int**) & Modifying a Caller's Pointer

A classic interview trap is asking you to allocate memory or advance a pointer inside a helper function:

  1. The By-Value Trap: If a function takes void alloc(int* p), the pointer variable itself is passed by value (copied into a CPU register). Writing p = new int(42); inside the helper only updates the local copy; the caller's pointer remains nullptr.
  2. The Double Pointer Fix (int** pp): By passing the memory address of the caller's pointer (&ptr), the helper can dereference it (*pp = new int(42);) to overwrite the caller's address. This is the classic C-style approach.
  3. The Idiomatic C++ Fix (int*& rp): Taking a reference to a pointer creates an alias to the caller's pointer variable directly. Writing rp = new int(42); updates the caller without double-pointer syntax clutter.
C++23 snippet
// BUG: mutates local copy of p; caller's pointer stays nullptr!
void brokenAlloc(int* p) { p = new int(42); }

// FIX 1 (C++ style — Reference to Pointer):
void allocByRefToPtr(int*& p) { p = new int(42); }

// FIX 2 (C/Systems style — Double Pointer):
void allocByDoublePtr(int** pp) { if (pp) *pp = new int(42); }
Interview takeaway:

To modify where a caller's pointer points, accept T*& (idiomatic C++) or T** (C ABI compatible).

05

Function Pointers & Callbacks

Functions are not just code—they reside at specific byte addresses in the executable's .text segment:

  1. Function Address: A function pointer stores the entry address of that compiled machine code block. Calling fnPtr(arg) issues an indirect jump instruction (call rax in x86_64 assembly).
  2. Zero Allocation Overhead: Unlike heavy wrapper types like std::function, raw function pointers require zero heap allocation and add no dynamic dispatch overhead.
  3. Modern Type Aliases: The classic C syntax int (*fn)(int, int) is notoriously difficult to read. In modern C++, declare aliases with using MathOp = int (*)(int, int);.
  4. Stateless Lambdas: Any C++ lambda that captures no variables ([]) can implicitly convert to a raw C-style function pointer, making it fully compatible with legacy C APIs.
C++23 snippet
using MathOp = int (*)(int, int);
int add(int a, int b) { return a + b; }
int mul(int a, int b) { return a * b; }

MathOp op = &add;         // or just 'add'
int res = op(4, 5);       // 9 — indirect call via register
Interview takeaway:

Stateless lambdas [](int a, int b) { return a + b; } implicitly convert to raw C function pointers!

04

Phase 1 (15-min baseline): Pointer vs Reference Swap, Null-Safety Guards & Address Identity

Pointer vs Reference Swap, Null-Safety Guards & Address IdentityWrite this first in a 30-min interview

In a 30-minute systems interview, do not jump straight into complex 2D contiguous memory matrices or function pointer dispatch tables. First, write this concise, ~40-line whiteboard baseline demonstrating safe pointer dereferencing with null guards, reference swapping, array decay mechanics, and address identity testing. It proves immediate mastery of machine addresses vs compiler aliases in under 12 minutes.

Phase 1 — 15-Minute Whiteboard Baseline (Simple & Interview-Safe)
#include <iostream>
#include <utility>

// 1. Pointer swap requires null & self-aliasing guards
void swapByPtr(int* a, int* b) {
    if (!a || !b || a == b) return;
    int tmp = *a;
    *a = *b;
    *b = tmp;
}

// 2. Reference swap: compiler guarantees non-null binding
void swapByRef(int& a, int& b) {
    if (&a == &b) return; // Address identity check
    int tmp = a;
    a = b;
    b = tmp;
}

// 3. Pointer arithmetic & two-pointer array reversal
void reverseArray(int* begin, int* end) {
    if (!begin || !end || begin >= end) return;
    int* left = begin;
    int* right = end - 1; // STL half-open [begin, end)
    while (left < right) {
        swapByRef(*left, *right);
        ++left;
        --right;
    }
}

void inspectAddresses() {
    int x = 10;
    int& ref = x;
    int* ptr = &x;
    std::cout << "(&ref == &x): " << std::boolalpha << (&ref == &x) << '\n';
    std::cout << "(ptr == &x): " << std::boolalpha << (ptr == &x) << '\n';
    std::cout << "(*ptr == x): " << std::boolalpha << (*ptr == x) << '\n';
    std::cout << "(&ptr != &x): " << std::boolalpha << ((void*)&ptr != (void*)&x) << '\n';
}

int main() {
    std::cout << "--- Address Inspection ---\n";
    inspectAddresses();

    std::cout << "\n--- Pointer Swap ---\n";
    int a = 42, b = 99;
    std::cout << "Before swapByPtr: a=" << a << ", b=" << b << '\n';
    swapByPtr(&a, &b);
    std::cout << "After swapByPtr:  a=" << a << ", b=" << b << '\n';
    swapByPtr(&a, nullptr); // Null guard test: no-op, no crash
    std::cout << "After swapByPtr with nullptr: a=" << a << ", b=" << b << '\n';

    std::cout << "\n--- Reference Swap ---\n";
    swapByRef(a, b);
    std::cout << "After swapByRef:  a=" << a << ", b=" << b << '\n';

    std::cout << "\n--- Array Reversal via Pointer Arithmetic ---\n";
    int arr[] = {1, 2, 3, 4, 5};
    std::cout << "Before reversal: ";
    for (int v : arr) std::cout << v << ' ';
    std::cout << '\n';

    reverseArray(arr, arr + 5);

    std::cout << "After reversal:  ";
    for (int v : arr) std::cout << v << ' ';
    std::cout << '\n';

    return 0;
}
Program output (stdout)
--- Address Inspection ---
(&ref == &x): true
(ptr == &x): true
(*ptr == x): true
(&ptr != &x): true

--- Pointer Swap ---
Before swapByPtr: a=42, b=99
After swapByPtr:  a=99, b=42
After swapByPtr with nullptr: a=99, b=42

--- Reference Swap ---
After swapByRef:  a=42, b=99

--- Array Reversal via Pointer Arithmetic ---
Before reversal: 1 2 3 4 5 
After reversal:  5 4 3 2 1
Starter traps & limitations interviewer will probe
  • Array Decay Loses Size Bounds: When passing int* begin, int* end, the caller must calculate bounds correctly; raw pointers decay without length metadata, risking buffer overruns if end is miscalculated.

  • Reference Rebinding Confusion: Writing ref = y; in swapByRef does NOT rebind ref to y; it mutates the storage of x through the alias. References are permanently bound at initialization.

  • Null vs Dereference Order in Guards: In pointer guards, testing *p != 0 && p != nullptr causes an instant segmentation fault because *p evaluates before the null check. Short-circuit logic mandates p && *p != 0.

  • Passing Pointer by Value for Reallocation: A helper like void alloc(int* p) copies the 8-byte address into a register; overwriting p = new int inside the helper leaves the caller's pointer dangling or null.

The Senior Pivot Script (What to say at minute 15)

"This baseline demonstrates machine-level address identity, safe pointer dereferencing, and reference parameter passing. However, in low-latency systems, using raw pointer-to-pointer structures like int** for multi-dimensional data causes R+1 separate heap allocations and cache-line misses across rows. If you'd like, I can now pivot to Phase 2 and build a production contiguous 2D matrix that delivers 100% cache locality while maintaining clean matrix[r][c] bracket indexing."

05

Phase 2 (Production C++23): Pointer vs Reference Swap, In-Place Array Reversal & Function Pointer Callbacks

Pointer vs Reference Swap, In-Place Array Reversal & Function Pointer CallbacksCompiled & tested C++23

Implement: (1) swapByPtr(int*, int*) with null/self guards and swapByRef(int&, int&), (2) reverseWithPtrs(int* begin, int* end) using two-pointer arithmetic, and (3) a higher-order applyTransform function using a raw function pointer callback.

Implementation walkthrough
  1. 1.

    In swapByPtr(int* a, int* b), guard against !a || !b || a == b before dereferencing; in swapByRef(int& a, int& b), references are guaranteed non-null by the caller.

  2. 2.

    In reverseWithPtrs(int* begin, int* end), follow the STL half-open range convention [begin, end). Initialize left = begin and right = end - 1, swapping *left and *right while left < right.

  3. 3.

    Verify pointer distance vs byte distance: (arr + 5) - arr equals 5 elements, whereas casting to const char* reveals the raw byte offset 5 * sizeof(int) = 20.

  4. 4.

    Define using TransformFn = int (*)(int); and pass doubleVal and squareVal into applyTransform over a std::span<int>.

impl.cpp — Live implementation (C++23)
#include <cstdint>
#include <print>
#include <span>

// ============================================================================
// 1. POINTER SWAP vs REFERENCE SWAP
// Pointers can be null or identical; references are guaranteed non-null aliases.
// ============================================================================

// Pointer swap: MUST check for nullptr before dereferencing (*a), otherwise immediate UB!
void swapByPtr(int* a, int* b) noexcept {
    // Guard 1: Ignore null pointers safely
    // Guard 2: If both pointers point to the exact same address (a == b), nothing to do
    if (!a || !b || a == b) return;

    int tmp = *a; // Step 1: Read value from memory address 'a' into a temporary register
    *a = *b;      // Step 2: Overwrite memory address 'a' with the value from 'b'
    *b = tmp;     // Step 3: Overwrite memory address 'b' with the saved temporary value
}

// Reference swap: Guaranteed valid by the compiler — no null checks needed, zero overhead!
void swapByRef(int& a, int& b) noexcept {
    int tmp = a;  // Step 1: Read value from 'a' (directly compiled into registers)
    a = b;        // Step 2: Assign value of 'b' into 'a'
    b = tmp;      // Step 3: Assign saved value into 'b'
}

// ============================================================================
// 2. POINTER ARITHMETIC IN-PLACE REVERSAL
// Demonstrates that `ptr + 1` does NOT advance by 1 byte; it advances by sizeof(T)!
// ============================================================================
void reverseWithPtrs(int* begin, int* end) noexcept {
    if (!begin || !end) return;

    int* left = begin;     // Points to the very first element (index 0)
    int* right = end - 1;  // Points to the very last element (index N - 1)

    // Two-pointer loop meeting in the middle:
    while (left < right) {
        swapByRef(*left, *right); // Swap the values at the two boundary pointers
        ++left;   // Advances address forward by exactly sizeof(int) = 4 bytes!
        --right;  // Steps address backward by exactly sizeof(int) = 4 bytes!
    }
}

// ============================================================================
// 3. FUNCTION POINTER CALLBACKS
// Modern C++ passes executable code addresses using 8-byte function pointers.
// ============================================================================
using TransformFn = int (*)(int); // Type alias: pointer to a function taking int and returning int

int doubleVal(int x) { return x * 2; }
int squareVal(int x) { return x * x; }

// Applies the given transformation function to every element in the contiguous span
void applyTransform(std::span<int> data, TransformFn fn) {
    if (!fn) return; // Guard: protect against null function pointer call (prevents SIGSEGV)
    for (int& v : data) {
        v = fn(v);   // Indirect call: CPU jumps to the address of 'fn'
    }
}

int main() {
    // Test 1: Swapping via pointers vs references
    int x = 10, y = 25;
    swapByPtr(&x, &y); // Must pass explicit memory addresses using & operator
    std::println("After swapByPtr: x={}, y={}", x, y);

    swapByRef(x, y);   // Passed directly as variables; compiler treats them as aliases
    std::println("After swapByRef: x={}, y={}", x, y);

    // Test 2: Pointer distance & byte arithmetic
    int arr[] = {1, 2, 3, 4, 5};
    std::ptrdiff_t dist = (arr + 5) - arr; // Subtracting pointers yields element count (5), NOT bytes!
    std::println("Pointer distance (elements): {}, byte delta: {}",
                 dist,
                 reinterpret_cast<const char*>(arr + 5) - reinterpret_cast<const char*>(arr));

    // Test 3: Array reversal using two pointers
    reverseWithPtrs(arr, arr + 5);
    std::println("Reversed array: {} {} {} {} {}", arr[0], arr[1], arr[2], arr[3], arr[4]);

    // Test 4: Dynamic dispatch via function pointer
    applyTransform(arr, doubleVal);
    std::println("After doubleVal callback: {} {} {} {} {}", arr[0], arr[1], arr[2], arr[3], arr[4]);

    applyTransform(arr, squareVal);
    std::println("After squareVal callback: {} {} {} {} {}", arr[0], arr[1], arr[2], arr[3], arr[4]);
    return 0;
}
Program output (stdout)
After swapByPtr: x=25, y=10
After swapByRef: x=10, y=25
Pointer distance (elements): 5, byte delta: 20
Reversed array: 5 4 3 2 1
After doubleVal callback: 10 8 6 4 2
After squareVal callback: 100 64 36 16 4
Complexity & implementation notes
  • swapByPtr / swapByRef: O(1) time, O(1) space. At -O2, both compile to identical register exchanges.
  • reverseWithPtrs: O(N) time, O(1) auxiliary space (N/2 swaps).
  • applyTransform: O(N) time, O(1) space, zero heap allocation (unlike std::function with large captures).
06

Interviewer cross-questioning & viva playbook

Q1The auto + Reference + std::move Trap: int a = 20; int& b = a; auto c = b;
Live Viva Grill
Interviewer asks / writes on screen

"Consider int a = 20; int& b = a; auto c = b; — What is the exact type of c? Now what if we write auto c = std::move(b); followed by c = 99;? Does b move to c, does a change, and why does changing c leave a at 20?"

Where candidates get trapped

Candidates often assume auto c = b; deduces int& (a reference), or believe std::move(b) 'steals' the resource from b or empties a.

Senior mechanical explanation
  1. auto Strips References and Top-Level Const: By language design (following template argument deduction rules), plain auto deduces by value. In both auto c = b; and auto c = std::move(b);, the reference qualifier & is completely discarded. The deduced type of c is a standalone int allocated at a distinct 4-byte stack address (e.g. 0x7ffd04), completely independent of a (at 0x7ffd00). To retain a reference, one must explicitly write auto& c = b;.
  2. std::move(b) Performs Zero Runtime Work: std::move is merely a compile-time cast: static_cast<std::remove_reference_t<decltype(b)>&&>(b), which yields an rvalue reference int&& pointing to a.
  3. Moving Primitives is a 4-Byte Copy: Primitive scalar types (int, double, raw pointers) do not manage dynamic heap buffers or ownership handles. For fundamental types, move construction is identical to copy construction: the CPU simply executes a 4-byte memory/register copy (mov eax, [rbp-4]; mov [rbp-8], eax). a remains 20.
  4. Mutation Independence: Because c is an independent stack variable, executing c = 99; writes strictly to c's stack slot (0x7ffd04). a and b at 0x7ffd00 remain unchanged at 20.
Mechanical proof (C++)
int a = 20;            // Stack @ 0x7ffd00 = 20
int& b = a;            // Alias to 0x7ffd00
auto c1 = b;           // c1 is 'int' @ 0x7ffd04 (copies 20)
auto c2 = std::move(b);// c2 is 'int' @ 0x7ffd08: primitive move is just 4-byte copy!
c2 = 99;               // Writes 99 to 0x7ffd08

// a is still 20, c2 is 99!
auto& refD = b;        // refD is 'int&' aliasing 0x7ffd00
refD = 77;             // Now 'a' is 77!
Q2Why std::vector<int&> Fails to Compile & std::reference_wrapper
Live Viva Grill
Interviewer asks / writes on screen

"Why does declaring std::vector<int&> trigger a compilation error? Walk me through the exact language specifications violated, and explain how std::reference_wrapper<int> solves this."

Where candidates get trapped

Assuming the vector cannot store references because references 'take 0 bytes' or guessing that the error only occurs at runtime when resizing or reallocating memory.

Q3How References Work Under the Hood at the Assembly Level (T* const vs Alias)
Live Viva Grill
Interviewer asks / writes on screen

"Does a reference occupy 0 bytes or 8 bytes in memory? Show how the compiler lowers a reference in local scope versus across non-inlined function calls or struct members in x86-64 assembly."

Where candidates get trapped

Dogmatically answering '0 bytes' because sizeof(ref) == sizeof(T) in the standard, or claiming references are purely high-level syntax with no machine representation.

Q4Passing Pointer by Value vs Pointer by Reference (void alloc(int*& p))
Live Viva Grill
Interviewer asks / writes on screen

"Why does void alloc(int* p) { p = new int(42); } fail to return allocated memory to the caller, and what are the two ways to fix it? Explain the stack frame mechanics."

Where candidates get trapped

Assuming that because p is a pointer, modifying p automatically reflects back to the caller's pointer variable.

07

Scenario: Dynamic 2D Matrix in C++: Why int** Is Slow and How to Allocate a Contiguous 2D Block

The interviewer's twist

The interviewer asks: "Write a function to dynamically allocate a 2D rows x cols matrix so the caller can index mat[r][c]. Then tell me what is wrong with the classic new int*[rows] loop in a low-latency system, and rewrite it so the entire 2D matrix lives in a single contiguous memory block while still supporting mat[r][c] syntax!"

How to handle this scenario

The naive new int*[rows] loop calls the OS/heap allocator rows + 1 times, scattering rows across non-adjacent heap addresses (causing CPU cache misses on row transitions) and requiring a loop to delete[]. We fix this by allocating one flat array new int[rows * cols] for all elements, and wiring up rowPtrs_[r] = data_ + r * cols so mat[r][c] still works with 100% contiguous cache locality and just two delete[] calls inside an RAII destructor.

scenario.cpp — Scenario solution (C++23)
#include <cstddef>
#include <print>

// ============================================================================
// SCENARIO: JAGGED 2D ARRAY vs CONTIGUOUS 2D MATRIX
// Demonstrates why jagged arrays thrash CPU caches and how contiguous layout fixes it.
// ============================================================================

// ----------------------------------------------------------------------------
// Approach A: Jagged 2D Array (int**)
// Requires R + 1 separate heap allocations; rows are scattered unpredictably in RAM!
// ----------------------------------------------------------------------------
int** allocateJagged2D(std::size_t rows, std::size_t cols) {
    // Allocation 1: Array of pointers to hold the starting address of each row
    int** mat = new int*[rows];

    // Allocations 2 to R+1: Allocate each individual row separately on the heap
    for (std::size_t r = 0; r < rows; ++r) {
        mat[r] = new int[cols]{}; // Zero-initialized memory for each row
    }
    return mat;
}

// Teardown: Every single row must be deleted before deleting the outer pointer array!
void freeJagged2D(int** mat, std::size_t rows) noexcept {
    if (!mat) return;
    for (std::size_t r = 0; r < rows; ++r) {
        delete[] mat[r]; // Step 1: Free each scattered row buffer
    }
    delete[] mat;        // Step 2: Free the array of row pointers
}

// ----------------------------------------------------------------------------
// Approach B: Contiguous 2D Matrix (Production-Grade High Performance)
// Allocates ALL cells in ONE single flat heap block for 100% L1/L2 cache prefetching!
// ----------------------------------------------------------------------------
class ContiguousMatrix {
    std::size_t rows_;
    std::size_t cols_;
    int* data_;      // 1 single contiguous heap block of (rows * cols) integers
    int** rowPtrs_;  // Lightweight pointer table indexing into data_ so mat[r][c] syntax works!

public:
    ContiguousMatrix(std::size_t rows, std::size_t cols)
        : rows_(rows), cols_(cols),
          data_(new int[rows * cols]{}), // 1 flat allocation for all elements
          rowPtrs_(new int*[rows]) {     // 1 pointer table for O(1) row indexing
        // Point each row pointer to its exact contiguous slice inside data_
        for (std::size_t r = 0; r < rows_; ++r) {
            rowPtrs_[r] = data_ + (r * cols_); // Exact byte offset calculation
        }
    }

    // Clean RAII Destructor: 2 delete calls total (vs R+1 deletes in jagged array!)
    ~ContiguousMatrix() noexcept {
        delete[] rowPtrs_; // Free the pointer lookup table
        delete[] data_;    // Free the single continuous element block
    }

    // Rule of 5: Prevent accidental double-frees by disabling raw copies
    ContiguousMatrix(const ContiguousMatrix&) = delete;
    ContiguousMatrix& operator=(const ContiguousMatrix&) = delete;

    // Subscript operators allowing native mat[r][c] bracket syntax
    int* operator[](std::size_t r) noexcept { return rowPtrs_[r]; }
    const int* operator[](std::size_t r) const noexcept { return rowPtrs_[r]; }

    // Physical proof: distance from row 1 start to row 0 start equals cols exactly
    std::ptrdiff_t rowStrideElements() const noexcept {
        return rowPtrs_[1] - rowPtrs_[0];
    }
};

int main() {
    // Test 1: Jagged 2D array
    int** jagged = allocateJagged2D(3, 4);
    jagged[1][2] = 42;
    std::println("Jagged [1][2] = {}", jagged[1][2]);
    freeJagged2D(jagged, 3);

    // Test 2: Contiguous matrix (Hardware cache friendly)
    ContiguousMatrix mat(3, 4);
    int val = 1;
    for (std::size_t r = 0; r < 3; ++r) {
        for (std::size_t c = 0; c < 4; ++c) {
            mat[r][c] = val++;
        }
    }
    std::println("Contiguous [0][3] = {}, [1][0] = {}", mat[0][3], mat[1][0]);
    std::println("Pointer delta (&mat[1][0] - &mat[0][0]) = {} ints (strictly contiguous!)",
                 mat.rowStrideElements());
    return 0;
}
Program output (stdout)
Jagged [1][2] = 42
Contiguous [0][3] = 4, [1][0] = 5
Pointer delta (&mat[1][0] - &mat[0][0]) = 4 ints (strictly contiguous!)
08

Common C++ mistakes & UB traps

Returning a Reference or Pointer to a Local Stack Variable
Buggy / UB code
int& badRef() {
    int local = 42;
    return local; // UB: stack frame is popped on return!
}
Safe / idiomatic C++
int goodVal() {
    int local = 42;
    return local; // Safe: returned by value in CPU register
}

When badRef() returns, its stack frame is reclaimed immediately. The returned reference (or pointer) dangles into dead stack memory that the next function call will overwrite.

Assuming sizeof(ptr) Inside a Function Returns Array Byte Size
Buggy / UB code
void clear(int arr[10]) {
    // BUG: sizeof(arr) is sizeof(int*) == 8 on 64-bit, NOT 40!
    std::memset(arr, 0, sizeof(arr));
}
Safe / idiomatic C++
void clear(std::span<int> arr) {
    std::fill(arr.begin(), arr.end(), 0);
}

Function parameter int arr[10] is silently rewritten by the C++ compiler to int* arr. Always pass std::span<T>, std::array<T, N>&, or an explicit element count.

Dereferencing Before Checking nullptr in Short-Circuit Conditions
Buggy / UB code
if (*ptr > 0 && ptr != nullptr) { ... }
Safe / idiomatic C++
if (ptr != nullptr && *ptr > 0) { ... }

&& evaluates strictly left-to-right. Dereferencing *ptr on the left crashes with a segmentation fault before ptr != nullptr is ever checked.

09

"What does this print / Why does this crash?"

Puzzle 1: Pointer Arithmetic vs Byte Casting
Predict the behavior
int a[4] = {10, 20, 30, 40};
int* p = a;
int* q = reinterpret_cast<int*>(reinterpret_cast<char*>(p) + sizeof(int) * 2);
std::println("{} {}", *(p + 1), *q);
Puzzle 2: Reference Rebinding Trap
Predict the behavior
int x = 5, y = 12;
int& r = x;
int* p = &r;
r = y;
y = 99;
std::println("x={} y={} *p={}", x, y, *p);
10

Rapid-fire interview Q&A

Can a reference ever be null in C++, and how does that affect API design?

How much memory does a reference take compared to a pointer?

Why can't you create a std::vector<int&>, and what is the standard workaround?

What is the difference between void* in C vs C++, and why can't you do pointer arithmetic on void*?

When should you pass an argument by value (T), by const reference (const T&), by non-const reference (T&), or by pointer (T*)?

Community Doubts & Dynamic Notes

4 Verified Concepts
Click any term to locate its underlined occurrence in the chapter text
Interactive Terms in this Chapter (Hover to Inspect · Click to Locate in Text)
Next In Your Mastery Journey

Apply C++ Internals to LLD & Algorithmic Patterns

Full-stack systems engineering