Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

C++ Pointers & Dynamic Memory Allocation

Table of Contents

  1. Introduction to Pointers
  2. How Dereferencing Works
  3. Dynamic Memory Allocation
  4. Void Pointers
  5. Pointer Size
  6. Arrays and Pointers
  7. Const Pointers Variations
  8. Breaking Constantness
  9. Placement New Operator
  10. Best Practices
  11. Common Bugs

1. Introduction to Pointers

C++ Pointer Basics

A pointer is a variable that stores the memory address of another variable.

int value = 42;
int* ptr = &value;  // ptr stores the address of value

std::cout << "Value: " << value << std::endl;           // Output: 42
std::cout << "Address of value: " << &value << std::endl;  // Output: 0x7ffc12345678
std::cout << "Pointer ptr: " << ptr << std::endl;       // Output: 0x7ffc12345678
std::cout << "Dereferenced ptr: " << *ptr << std::endl; // Output: 42

Key Operators:

  • & (address-of operator): Gets the memory address of a variable
  • * (dereference operator): Accesses the value at the address stored in the pointer

Real-Life Analogy: Home Addresses

Think of computer memory like a street with houses. Each house has:

  • An address (like “123 Main Street”) - this is the memory address
  • Contents inside (furniture, people, etc.) - this is the actual data
  • A mailbox with the address written on it - this is the pointer
Real Life:                          Computer Memory:
┌─────────────────────────┐        ┌─────────────────────────┐
│  123 Main Street        │        │  Memory Address: 0x1000 │
│  ┌─────────────────┐    │        │  ┌─────────────────┐    │
│  │  John's House   │    │        │  │  Value: 42      │    │
│  │  (The actual    │    │        │  │  (The actual    │    │
│  │   person/data)  │    │        │  │   data)         │    │
│  └─────────────────┘    │        │  └─────────────────┘    │
└─────────────────────────┘        └─────────────────────────┘

Your Friend's Note:                 Your Pointer Variable:
┌─────────────────────────┐        ┌─────────────────────────┐
│ "John lives at          │        │  int* ptr = 0x1000;     │
│  123 Main Street"       │        │                         │
│  (The address, not      │        │  (The address, not      │
│   the person!)          │        │   the value!)           │
└─────────────────────────┘        └─────────────────────────┘

Key Insights from the Analogy:

  1. Address vs Contents:

    • When someone gives you an address “123 Main Street”, they’re not giving you the house or John - just the location
    • When a pointer stores 0x1000, it’s not storing the value 42 - just the location
  2. Using the Address (Dereferencing):

    • If you want to visit John, you go to “123 Main Street” and knock on the door
    • If you want the value, you dereference *ptr (go to address 0x1000 and get the data)
  3. Multiple References:

    • You can have many notes with the same address “123 Main Street”
    • You can have many pointers to the same memory address
  4. Changing the Address:

    • You can update your note to point to a different house: 123 Main Street → 456 Oak Avenue
    • You can change what a pointer points to: ptr = &another_variable;
  5. nullptr is like “No Address”:

    • A blank note with no address written on it
    • You can’t visit a house if you don’t have an address!

Extending the Analogy:

// Real Life                          // Code
int john_age = 25;                    // John (age 25) lives at 123 Main St
int* address_note = &john_age;        // Write down John's address on a note

std::cout << address_note;            // Read the note: "123 Main Street"
std::cout << *address_note;           // Go to that address, find John: age 25

*address_note = 26;                   // Go to 123 Main St, update John's age to 26
// john_age is now 26!                // John's actual age changed!

int mary_age = 30;                    // Mary (age 30) lives at 456 Oak Ave
address_note = &mary_age;             // Update the note to Mary's address
// Now the note points to Mary's house instead of John's house

What Happens Without Pointers?

// Without pointer (making a copy)    // Real Life Analogy
int john_age = 25;                    // John is 25 years old
int copy_of_age = john_age;          // You write "25" on a paper (copy)

copy_of_age = 26;                     // You change the paper to "26"
// john_age is STILL 25!              // But John is STILL 25 years old!
                                      // You only changed your copy

// With pointer (reference)           // Real Life Analogy
int john_age = 25;                    // John is 25 years old
int* ptr = &john_age;                // You write down John's address

*ptr = 26;                            // Go to John's house and change his age
// john_age is NOW 26!                // John himself is now 26!

Why Pointers Are Useful:

  1. Efficiency (Sending Just the Address):

    Real Life: Instead of copying an entire book to send to someone,
               you send them the library address and shelf number
    
    Code: Instead of copying 1GB of data, you pass a pointer (8 bytes)
    
  2. Shared Access:

    Real Life: Multiple people can have the same address and visit
               the same house
    
    Code: Multiple pointers can reference the same data
    
  3. Dynamic Allocation:

    Real Life: Building a new house when you need it (new construction)
               and tearing it down when done (demolition)
    
    Code: Allocating memory with 'new' when needed
          and freeing it with 'delete' when done
    

↑ Back to Table of Contents


2. How Dereferencing Works

Dereferencing is the process of accessing the value stored at the memory address held by a pointer.

Step-by-Step Process:

Memory Layout:
┌─────────────┬──────────┬─────────────┐
│  Address    │   Data   │  Variable   │
├─────────────┼──────────┼─────────────┤
│ 0x1000      │    42    │   value     │
│ 0x1004      │  0x1000  │   ptr       │
└─────────────┴──────────┴─────────────┘

When you dereference *ptr:

  1. Step 1: CPU reads the pointer variable ptr → Gets address 0x1000
  2. Step 2: CPU goes to memory location 0x1000
  3. Step 3: Uses the data type (int) to determine how many bytes to read (4 bytes for int)
  4. Step 4: Reads 4 bytes starting from 0x1000 → Gets value 42
  5. Step 5: Returns the value 42

Visual Representation:

int value = 42;        // Located at address 0x1000
int* ptr = &value;     // ptr contains 0x1000

Memory View:
┌──────────────────────────────────────┐
│  Address: 0x1000                     │
│  ┌────┬────┬────┬────┐               │
│  │ 42 │ 00 │ 00 │ 00 │  (4 bytes)    │ ← value
│  └────┴────┴────┴────┘               │
└──────────────────────────────────────┘
        ↑
        │
    ┌───┴────┐
    │  ptr   │ (stores 0x1000)
    └────────┘

*ptr operation:
1. Read ptr       → 0x1000
2. Go to 0x1000   → Find memory location
3. Type is int    → Read 4 bytes
4. Fetch data     → 42

Example with Different Data Types:

// Different types require different byte reads
char c = 'A';        // 1 byte
short s = 1000;      // 2 bytes
int i = 50000;       // 4 bytes
long long ll = 1e15; // 8 bytes
double d = 3.14;     // 8 bytes

char* ptr_c = &c;         // When dereferencing, read 1 byte
short* ptr_s = &s;        // When dereferencing, read 2 bytes
int* ptr_i = &i;          // When dereferencing, read 4 bytes
long long* ptr_ll = &ll;  // When dereferencing, read 8 bytes
double* ptr_d = &d;       // When dereferencing, read 8 bytes

↑ Back to Table of Contents


3. Dynamic Memory Allocation

Dynamic memory is allocated on the heap at runtime using new and must be manually freed using delete.

Using new and delete

// Single object allocation
int* ptr = new int;        // Allocate memory for one int
*ptr = 100;                // Assign value
std::cout << *ptr << std::endl;
delete ptr;                // Free memory
ptr = nullptr;             // Good practice: nullify after delete

// Allocate with initialization
int* ptr2 = new int(42);   // Allocate and initialize to 42
delete ptr2;

// Array allocation
int* arr = new int[5];     // Allocate array of 5 ints
arr[0] = 10;
arr[1] = 20;
delete[] arr;              // Must use delete[] for arrays
arr = nullptr;

Memory Layout: Stack vs Heap

Stack (automatic storage):          Heap (dynamic storage):
┌─────────────────────┐            ┌─────────────────────┐
│  int x = 10;        │            │  new int(42)        │
│  [cleaned up auto]  │            │  [manual cleanup]   │
│                     │            │                     │
│  Limited size       │            │  Large size         │
│  Fast access        │            │  Slower access      │
│  LIFO structure     │            │  Fragmented         │
└─────────────────────┘            └─────────────────────┘

Key Differences:

AspectStackHeap
AllocationAutomaticManual (new)
DeallocationAutomaticManual (delete)
SizeLimited (~1-8MB)Large (GB)
SpeedFasterSlower
LifetimeScope-basedUntil delete

↑ Back to Table of Contents


4. Void Pointers

A void* is a generic pointer that can point to any data type but cannot be dereferenced directly.

void* void_ptr;
int x = 42;
double y = 3.14;
char c = 'A';

// void* can point to any type
void_ptr = &x;
void_ptr = &y;
void_ptr = &c;

// ERROR: Cannot dereference void*
// std::cout << *void_ptr << std::endl;  // Compiler error!

// Must cast to specific type before dereferencing
void_ptr = &x;
int value = *(static_cast<int*>(void_ptr));  // OK: Cast then dereference
std::cout << value << std::endl;  // Output: 42

Common Use Cases:

// 1. Generic memory allocation functions
void* malloc(size_t size);  // C-style allocation returns void*

// 2. Generic callback functions
void process_data(void* data, void (*callback)(void*)) {
    callback(data);
}

// 3. Type-erased storage
void* user_data = new UserData();
// Later cast back: auto* ud = static_cast<UserData*>(user_data);

Important Notes:

  • Cannot perform pointer arithmetic on void*
  • Cannot dereference without casting
  • Type safety is programmer’s responsibility
  • Modern C++ prefers templates over void pointers

↑ Back to Table of Contents


5. Pointer Size

The size of a pointer depends on the system architecture, not the data type it points to.

// On 64-bit systems: all pointers are 8 bytes
// On 32-bit systems: all pointers are 4 bytes

char* ptr_char;
int* ptr_int;
double* ptr_double;
long long* ptr_ll;
void* ptr_void;

std::cout << "Size of char*:      " << sizeof(ptr_char) << std::endl;    // 8 on 64-bit
std::cout << "Size of int*:       " << sizeof(ptr_int) << std::endl;     // 8 on 64-bit
std::cout << "Size of double*:    " << sizeof(ptr_double) << std::endl;  // 8 on 64-bit
std::cout << "Size of long long*: " << sizeof(ptr_ll) << std::endl;      // 8 on 64-bit
std::cout << "Size of void*:      " << sizeof(ptr_void) << std::endl;    // 8 on 64-bit

// All output: 8 bytes on 64-bit system

Why All Pointers Are The Same Size:

A pointer is just a memory address:

32-bit system:
  Address space: 0x00000000 to 0xFFFFFFFF
  Pointer size: 4 bytes (32 bits)
  
64-bit system:
  Address space: 0x0000000000000000 to 0xFFFFFFFFFFFFFFFF
  Pointer size: 8 bytes (64 bits)

The data type tells the compiler:
  - How many bytes to read when dereferencing
  - How much to increment/decrement in pointer arithmetic
  
But the address itself is always the same size!

Pointer Arithmetic Depends on Type:

int arr[5] = {10, 20, 30, 40, 50};
int* ptr = arr;

std::cout << ptr << std::endl;      // e.g., 0x1000
std::cout << ptr + 1 << std::endl;  // 0x1004 (increments by sizeof(int) = 4)

char* c_ptr = reinterpret_cast<char*>(arr);
std::cout << c_ptr << std::endl;      // 0x1000
std::cout << c_ptr + 1 << std::endl;  // 0x1001 (increments by sizeof(char) = 1)

↑ Back to Table of Contents


6. Arrays and Pointers

Real-Life Analogy: Apartment Building

Think of an array as an apartment building where:

  • The building address is like the array name (constant, never changes)
  • Each apartment is an array element
  • Apartment numbers (1, 2, 3…) are like array indices
Apartment Building:                  Array in Memory:
┌────────────────────────────┐      ┌────────────────────────────┐
│ "Sunset Towers"            │      │ int arr[5]                 │
│ Located at 100 Main St     │      │ Located at 0x1000          │
│ (Building address is FIXED)│      │ (Array name is FIXED)      │
│                            │      │                            │
│ Apt #1: John (age 25)      │      │ arr[0]: 10                 │
│ Apt #2: Mary (age 30)      │      │ arr[1]: 20                 │
│ Apt #3: Bob  (age 35)      │      │ arr[2]: 30                 │
│ Apt #4: Sue  (age 40)      │      │ arr[3]: 40                 │
│ Apt #5: Tom  (age 45)      │      │ arr[4]: 50                 │
└────────────────────────────┘      └────────────────────────────┘

Building Address: 100 Main St       Array Name: arr
  - CANNOT change to different        - CANNOT change to point to
    street address                      different memory location
  - It's a PERMANENT landmark         - It's a CONSTANT POINTER
  
Apartment #1 is at:                 First element at:
  100 Main St, Apt #1                 arr + 0 = 0x1000
  
Apartment #3 is at:                 Third element at:
  100 Main St, Apt #3                 arr + 2 = 0x1008

Why Array Names Are Constant:

// Real Life                           // Code
int arr[5] = {10, 20, 30, 40, 50};    // Build "Sunset Towers" at 100 Main St

// You CAN: Change what's inside apartments
arr[0] = 100;                         // Renovate Apt #1

// You CAN: Get a notecard with building address
int* ptr = arr;                       // Write "100 Main St" on a note
ptr++;                                // Update note to "100 Main St, Apt #2"

// You CANNOT: Move the entire building!
// arr = arr + 1;  ❌ ERROR!            // Can't relocate Sunset Towers!
// arr++;          ❌ ERROR!            // Buildings don't move!

int other[3] = {1, 2, 3};             // Different building: "Oak Plaza"
// arr = other;    ❌ ERROR!            // Can't make Sunset Towers become Oak Plaza!

Pointer vs Array Name:

Scenario: You have two notecards

NOTECARD 1 (Array Name - "arr"):
┌─────────────────────────────────┐
│ "Sunset Towers is permanently   │
│  located at 100 Main Street"    │
│                                 │
│ ❌ You CANNOT erase this and     │
│    write a different address    │
│ ✓ You CAN visit any apartment   │
└─────────────────────────────────┘

NOTECARD 2 (Pointer - "ptr"):
┌─────────────────────────────────┐
│ "Current location: 100 Main St" │
│                                 │
│ ✓ You CAN erase and write:      │
│   "Current location: 456 Oak"   │
│ ✓ You CAN visit any apartment   │
└─────────────────────────────────┘

Arrays and Pointers

Array Name as a Constant Pointer

When you declare an array, the array name acts like a constant pointer to the first element.

int arr[5] = {10, 20, 30, 40, 50};

// arr is equivalent to &arr[0]
std::cout << "Array name (arr):        " << arr << std::endl;         // e.g., 0x1000
std::cout << "Address of first elem:   " << &arr[0] << std::endl;    // e.g., 0x1000
std::cout << "First element (*arr):    " << *arr << std::endl;        // 10
std::cout << "First element (arr[0]):  " << arr[0] << std::endl;      // 10

Memory Layout of Arrays:

Array: int arr[5] = {10, 20, 30, 40, 50};

Memory View:
┌─────────┬─────────┬─────────┬─────────┬─────────┐
│   10    │   20    │   30    │   40    │   50    │
└─────────┴─────────┴─────────┴─────────┴─────────┘
↑         ↑         ↑         ↑         ↑
0x1000    0x1004    0x1008    0x100C    0x1010
│
arr (points here, FIXED location)

arr[0] ≡ *(arr + 0) ≡ *arr
arr[1] ≡ *(arr + 1)
arr[2] ≡ *(arr + 2)
arr[3] ≡ *(arr + 3)
arr[4] ≡ *(arr + 4)

Array vs Pointer: Key Difference

int arr[5] = {10, 20, 30, 40, 50};
int* ptr = arr;  // ptr points to first element

// Similarities:
std::cout << arr[2] << std::endl;    // 30
std::cout << ptr[2] << std::endl;    // 30
std::cout << *(arr + 2) << std::endl; // 30
std::cout << *(ptr + 2) << std::endl; // 30

// KEY DIFFERENCE: arr is a CONSTANT POINTER
ptr = ptr + 1;     // OK: ptr can be reassigned
// arr = arr + 1;  // ERROR: arr is a constant pointer!

int another[3] = {1, 2, 3};
ptr = another;     // OK: ptr can point to different array
// arr = another;  // ERROR: Cannot reassign arr!

Why Array Name is a Constant Pointer:

int arr[5] = {10, 20, 30, 40, 50};

// Think of arr as:
// int* const arr = <address of first element>;

// This is why you CAN:
*arr = 100;        // Modify the value at arr[0]
*(arr + 1) = 200;  // Modify the value at arr[1]

// But you CANNOT:
// arr = arr + 1;     // Change where arr points
// arr++;             // Increment arr
// int other[3];
// arr = other;       // Point arr to different array

// However, a pointer TO the array can be changed:
int* ptr = arr;
ptr++;             // OK: ptr now points to arr[1]
ptr = arr;         // OK: Reset ptr to point to arr[0]

Visualization:

Stack Memory:
┌─────────────────────────────────────┐
│  int arr[5] = {10, 20, 30, ...};    │
│  ┌────┬────┬────┬────┬────┐         │
│  │ 10 │ 20 │ 30 │ 40 │ 50 │         │
│  └────┴────┴────┴────┴────┘         │
│   ↑                                 │
│   │ arr (CONSTANT - can't change)   │
│   │                                 │
│  ┌┴──────┐                          │
│  │  ptr  │ (VARIABLE - can change)  │
│  └───────┘                          │
│   ↓                                 │
│  Can be reassigned to point         │
│  anywhere                           │
└─────────────────────────────────────┘

Dynamic Array Allocation

Unlike static arrays, dynamically allocated arrays use pointers that CAN be reassigned.

Allocating Dynamic Arrays:

// Allocate array of 5 integers
int* arr = new int[5];

// Initialize values
arr[0] = 10;
arr[1] = 20;
arr[2] = 30;
arr[3] = 40;
arr[4] = 50;

// Access like normal array
for (int i = 0; i < 5; i++) {
    std::cout << arr[i] << " ";
}
std::cout << std::endl;

// IMPORTANT: Must use delete[] for arrays
delete[] arr;
arr = nullptr;

Allocate with Initialization:

// C++11 and later: Initialize with values
int* arr = new int[5]{10, 20, 30, 40, 50};

// Zero-initialize
int* zeros = new int[5]();  // All elements set to 0

// Default-initialize (garbage values for primitives)
int* uninitialized = new int[5];

// Cleanup
delete[] arr;
delete[] zeros;
delete[] uninitialized;

Dynamic Array Memory Layout:

Stack:                          Heap:
┌─────────────┐                ┌────┬────┬────┬────┬────┐
│  int* arr   │ ───────────────>│ 10 │ 20 │ 30 │ 40 │ 50 │
│  (8 bytes)  │                └────┴────┴────┴────┴────┘
└─────────────┘                (20 bytes allocated)
     │
     │ Can be reassigned!
     ▼
┌────────────────┐
│ arr = new ...  │  OK: This is a regular pointer
└────────────────┘

Deallocating Arrays: delete vs delete[]

CRITICAL: Always use delete[] for arrays allocated with new[].

// Single object
int* ptr = new int(42);
delete ptr;  // Correct: Use delete for single object

// Array
int* arr = new int[10];
delete[] arr;  // Correct: Use delete[] for arrays

// WRONG - Undefined Behavior:
int* arr2 = new int[10];
delete arr2;  // BUG: Should be delete[]
              // May corrupt heap, leak memory, or crash

int* ptr2 = new int(42);
delete[] ptr2;  // BUG: Should be delete
                // Undefined behavior

Why delete[] is Necessary:

When you use new[]:
┌────────────────────────────────────┐
│ [hidden size info] [10] [20] [30]  │
└────────────────────────────────────┘
         ↑              ↑
         │              └─ Your pointer points here
         └─ Compiler stores array size here

delete[] knows to:
1. Call destructor for each element (for objects)
2. Read the hidden size information
3. Deallocate the entire block

delete (wrong) will:
1. Call destructor only once
2. Deallocate wrong amount of memory
3. Cause undefined behavior

Example with Objects:

class MyClass {
public:
    MyClass() { std::cout << "Constructor" << std::endl; }
    ~MyClass() { std::cout << "Destructor" << std::endl; }
};

// Allocate array of objects
MyClass* arr = new MyClass[3];
// Output:
// Constructor
// Constructor
// Constructor

delete[] arr;  // Calls destructor for ALL 3 objects
// Output:
// Destructor
// Destructor
// Destructor

// If you mistakenly use delete instead of delete[]:
MyClass* arr2 = new MyClass[3];
delete arr2;  // BUG: Only calls destructor ONCE!
              // Other 2 objects not properly destroyed

Passing Arrays to Functions

When you pass an array to a function, it decays to a pointer. The size information is lost!

Array Decay:

void print_array(int arr[], int size) {  // arr[] decays to int*
    std::cout << "Inside function, sizeof(arr): " << sizeof(arr) << std::endl;
    // Output: 8 (size of pointer, not array!)
    
    for (int i = 0; i < size; i++) {
        std::cout << arr[i] << " ";
    }
    std::cout << std::endl;
}

int main() {
    int arr[5] = {10, 20, 30, 40, 50};
    
    std::cout << "In main, sizeof(arr): " << sizeof(arr) << std::endl;
    // Output: 20 (5 elements × 4 bytes each)
    
    print_array(arr, 5);  // Must pass size separately!
    
    return 0;
}

Why You Need to Pass Size:

In main():
┌─────────────────────────────────────┐
│  int arr[5] = {10, 20, 30, 40, 50}; │
│                                     │
│  sizeof(arr) = 20 bytes             │
│  Compiler KNOWS it's 5 elements     │
└─────────────────────────────────────┘

When passed to function:
┌─────────────────────────────────────┐
│  void func(int arr[])               │
│                                     │
│  arr is now just int*               │
│  sizeof(arr) = 8 (pointer size)     │
│  No size information!               │
│  Could point to 1, 5, 100 elements  │
└─────────────────────────────────────┘

Solution: Pass size explicitly!
func(arr, 5);

Different Ways to Pass Arrays:

// Method 1: Array notation (still decays to pointer)
void func1(int arr[], int size) {
    // arr is int*
}

// Method 2: Pointer notation (equivalent to method 1)
void func2(int* arr, int size) {
    // More honest about what it is
}

// Method 3: Reference to array (preserves size!)
void func3(int (&arr)[5]) {
    // Size is part of type - no decay!
    // But only works for arrays of exactly 5 elements
    std::cout << sizeof(arr) << std::endl;  // 20 (actual array size)
}

// Method 4: Template (best for generic code)
template<size_t N>
void func4(int (&arr)[N]) {
    // Works for any size array
    std::cout << "Array size: " << N << std::endl;
}

// Method 5: Modern C++ - use std::array or std::vector
void func5(const std::vector<int>& vec) {
    // vec.size() always available!
    for (size_t i = 0; i < vec.size(); i++) {
        std::cout << vec[i] << " ";
    }
}

int main() {
    int arr[5] = {10, 20, 30, 40, 50};
    
    func1(arr, 5);           // OK
    func2(arr, 5);           // OK
    func3(arr);              // OK: size deduced from type
    func4(arr);              // OK: N = 5 automatically
    
    std::vector<int> vec = {10, 20, 30, 40, 50};
    func5(vec);              // Best: size is always known
    
    return 0;
}

Why Array Size is Not Passed Automatically:

void mystery_function(int* arr) {
    // From the pointer alone, we cannot tell:
    // - Is this an array or single element?
    // - If array, how many elements?
    // - Where does it end?
    
    // This is dangerous:
    for (int i = 0; i < 100; i++) {  // What if array has < 100 elements?
        arr[i] = 0;  // Could write past array bounds!
    }
}

// Solution: Always pass size
void safe_function(int* arr, int size) {
    for (int i = 0; i < size; i++) {
        arr[i] = 0;  // Safe: we know the bounds
    }
}

Multi-dimensional Arrays

Static Multi-dimensional Arrays:

int matrix[3][4] = {
    {1, 2, 3, 4},
    {5, 6, 7, 8},
    {9, 10, 11, 12}
};

// Memory layout is contiguous:
// [1][2][3][4][5][6][7][8][9][10][11][12]

std::cout << matrix[1][2] << std::endl;  // Output: 7
std::cout << *(*(matrix + 1) + 2) << std::endl;  // Also: 7

Dynamic 2D Arrays (Method 1: Array of Pointers):

// Allocate array of pointers
int** matrix = new int*[3];  // 3 rows

// Allocate each row
for (int i = 0; i < 3; i++) {
    matrix[i] = new int[4];  // 4 columns
}

// Use it
matrix[1][2] = 42;

// Deallocate (must free in reverse order)
for (int i = 0; i < 3; i++) {
    delete[] matrix[i];  // Free each row
}
delete[] matrix;  // Free array of pointers

Memory Layout:

Stack:        Heap:
┌────────┐    ┌─────┐    ┌────┬────┬────┬────┐
│ matrix │───>│ ptr │───>│ 1  │ 2  │ 3  │ 4  │  Row 0
└────────┘    ├─────┤    └────┴────┴────┴────┘
              │ ptr │───>┌────┬────┬────┬────┐
              ├─────┤    │ 5  │ 6  │ 7  │ 8  │  Row 1
              │ ptr │─┐  └────┴────┴────┴────┘
              └─────┘ │  ┌────┬────┬────┬────┐
                      └─>│ 9  │ 10 │ 11 │ 12 │  Row 2
                         └────┴────┴────┴────┘
Not contiguous in memory!

Dynamic 2D Arrays (Method 2: Contiguous Memory):

// Allocate as single block (better for cache performance)
int* matrix = new int[3 * 4];  // Total elements

// Access using index calculation: matrix[row * cols + col]
int rows = 3, cols = 4;
matrix[1 * cols + 2] = 42;  // matrix[1][2] = 42

// Helper function for cleaner access
auto at = [&](int r, int c) -> int& {
    return matrix[r * cols + c];
};

at(1, 2) = 42;

// Cleanup is simple
delete[] matrix;

Memory Layout:

Contiguous block in heap:
┌────┬────┬────┬────┬────┬────┬────┬────┬────┬────┬────┬────┐
│ 1  │ 2  │ 3  │ 4  │ 5  │ 6  │ 7  │ 8  │ 9  │ 10 │ 11 │ 12 │
└────┴────┴────┴────┴────┴────┴────┴────┴────┴────┴────┴────┘
 └─── Row 0 ───┘ └─── Row 1 ───┘ └─── Row 2 ───┘

Access: matrix[row * num_cols + col]

Summary Table: Arrays vs Pointers

FeatureStatic ArrayDynamic ArrayPointer
Declarationint arr[5]int* arr = new int[5]int* ptr
Size known at compile-time✓ Yes✗ No✗ No
Can be reassigned✗ No (constant pointer)✓ Yes✓ Yes
Stored onStackHeapStack (pointer itself)
Automatic cleanup✓ Yes✗ No (need delete[])✗ No
Sizeof givesArray sizePointer sizePointer size
Passed to functionDecays to pointerAlready pointerPointer

Best Practices for Arrays:

// ❌ Avoid: C-style arrays for new code
int arr[100];

// ✅ Prefer: std::array (fixed size)
#include <array>
std::array<int, 100> arr;  // Size is part of type
arr.size();  // Always available

// ✅ Prefer: std::vector (dynamic size)
#include <vector>
std::vector<int> vec(100);  // Dynamic, resizable
vec.size();  // Always available
vec.push_back(42);  // Can grow

// ✅ For passing arrays to functions
void process(const std::vector<int>& data) {
    // Size is always available via data.size()
}

// ✅ For 2D data
std::vector<std::vector<int>> matrix(rows, std::vector<int>(cols));
// Or for better performance:
std::vector<int> matrix(rows * cols);

↑ Back to Table of Contents


7. Const Pointers Variations

There are three types of const pointer declarations, each with different meanings.

1. Pointer to Constant (const T* or T const*)

int value = 42;
const int* ptr = &value;  // Pointer to constant int

// *ptr = 100;  // ERROR: Cannot modify the value through ptr
value = 100;    // OK: Can modify value directly

int another = 50;
ptr = &another; // OK: Can change where ptr points

Memory View:

┌──────────────┐
│  value = 42  │ ← Can't modify via ptr
└──────────────┘
      ↑
      │ (can change this pointer)
   ┌──┴──┐
   │ ptr │
   └─────┘

2. Constant Pointer (T* const)

int value = 42;
int* const ptr = &value;  // Constant pointer to int

*ptr = 100;     // OK: Can modify the value
// ptr = &another; // ERROR: Cannot change where ptr points

Memory View:

┌──────────────┐
│ value = 100  │ ← Can modify via ptr
└──────────────┘
      ↑
      │ (FIXED - cannot change)
   ┌──┴──┐
   │ ptr │
   └─────┘

3. Constant Pointer to Constant (const T* const)

int value = 42;
const int* const ptr = &value;  // Constant pointer to constant int

// *ptr = 100;     // ERROR: Cannot modify the value
// ptr = &another; // ERROR: Cannot change where ptr points

Memory View:

┌──────────────┐
│  value = 42  │ ← Can't modify via ptr
└──────────────┘
      ↑
      │ (FIXED - cannot change)
   ┌──┴──┐
   │ ptr │
   └─────┘

Summary Table:

DeclarationCan Modify Value?Can Change Pointer?Read as
int* ptr✓ Yes✓ YesPointer to int
const int* ptr✗ No✓ YesPointer to const int
int* const ptr✓ Yes✗ NoConst pointer to int
const int* const ptr✗ No✗ NoConst pointer to const int

Mnemonic: Read Right to Left

const int* ptr;        // ptr is a pointer to const int
int* const ptr;        // ptr is a const pointer to int
const int* const ptr;  // ptr is a const pointer to const int

↑ Back to Table of Contents


8. Breaking Constantness (The Hack)

While const is meant to protect data, C++ provides ways to remove const-ness. Use with extreme caution!

Using const_cast

const int value = 42;
const int* const_ptr = &value;

// Remove const using const_cast
int* mutable_ptr = const_cast<int*>(const_ptr);
*mutable_ptr = 100;  // Undefined Behavior if value was truly const!

std::cout << value << std::endl;  // May still print 42 due to optimization
std::cout << *mutable_ptr << std::endl;  // May print 100

Why This Is Dangerous:

// Case 1: Originally non-const (OK)
int x = 42;
const int* ptr = &x;
int* mutable_ptr = const_cast<int*>(ptr);
*mutable_ptr = 100;  // OK: x was not const originally

// Case 2: Originally const (UNDEFINED BEHAVIOR)
const int y = 42;
const int* ptr2 = &y;
int* mutable_ptr2 = const_cast<int*>(ptr2);
*mutable_ptr2 = 100;  // UNDEFINED BEHAVIOR! Compiler may have optimized assuming y never changes

Compiler Optimizations Can Break Your Code:

const int value = 42;

// Compiler might replace all uses of 'value' with literal 42
if (value == 42) {
    std::cout << "Always true!" << std::endl;
}

// Even if you modify via const_cast, the if statement
// might still use the literal 42 due to optimization!

Legitimate Use Case:

// Working with legacy C APIs that don't use const correctly
void legacy_function(char* str);  // Doesn't modify str, but signature is wrong

void modern_code() {
    const char* message = "Hello";
    // We know legacy_function won't modify str
    legacy_function(const_cast<char*>(message));  // Acceptable if you're sure
}

Other Ways to Break Const (All bad):

const int value = 42;

// Method 1: C-style cast (discouraged)
int* ptr1 = (int*)&value;

// Method 2: reinterpret_cast (very dangerous)
int* ptr2 = reinterpret_cast<int*>(const_cast<void*>(static_cast<const void*>(&value)));

// Method 3: memcpy (also undefined behavior)
int copy;
memcpy(&copy, &value, sizeof(int));
copy = 100;
memcpy(const_cast<int*>(&value), &copy, sizeof(int));

Bottom Line: If you’re using const_cast, you’re probably doing something wrong. Reconsider your design.

↑ Back to Table of Contents


9. Placement New Operator

Placement new constructs an object at a pre-allocated memory address without allocating new memory.

Basic Syntax:

#include <new>  // Required for placement new

// Allocate raw memory buffer
char buffer[sizeof(int)];

// Construct an int at the buffer location
int* ptr = new (buffer) int(42);  // Placement new

std::cout << *ptr << std::endl;  // Output: 42

// Must manually call destructor (no delete needed for placement new)
ptr->~int();  // Destructor call (trivial for int, but important for classes)

Complex Example with Classes:

class MyClass {
public:
    int x;
    double y;
    
    MyClass(int x_val, double y_val) : x(x_val), y(y_val) {
        std::cout << "Constructor called" << std::endl;
    }
    
    ~MyClass() {
        std::cout << "Destructor called" << std::endl;
    }
};

// Pre-allocate memory
alignas(MyClass) char buffer[sizeof(MyClass)];

// Construct object in buffer
MyClass* obj = new (buffer) MyClass(10, 3.14);

std::cout << "x: " << obj->x << ", y: " << obj->y << std::endl;

// Must manually call destructor
obj->~MyClass();

// No delete needed - we didn't allocate memory with new

Memory Diagram:

Regular new:
┌────────────────────────────────────┐
│ new MyClass(10, 3.14)              │
├────────────────────────────────────┤
│ 1. Allocate memory (heap)          │
│ 2. Construct object in that memory │
│ 3. Return pointer                  │
└────────────────────────────────────┘

Placement new:
┌────────────────────────────────────┐
│ char buffer[sizeof(MyClass)];      │ ← Memory already exists
│ new (buffer) MyClass(10, 3.14);    │
├────────────────────────────────────┤
│ 1. Use provided address (buffer)   │
│ 2. Construct object there          │
│ 3. Return pointer                  │
└────────────────────────────────────┘

Use Cases:

1. Memory Pools

// Pre-allocate a pool of memory
const size_t POOL_SIZE = 1024;
char memory_pool[POOL_SIZE];
size_t offset = 0;

// Allocate objects from the pool
MyClass* obj1 = new (memory_pool + offset) MyClass(1, 1.1);
offset += sizeof(MyClass);

MyClass* obj2 = new (memory_pool + offset) MyClass(2, 2.2);
offset += sizeof(MyClass);

// Cleanup
obj1->~MyClass();
obj2->~MyClass();

2. Reconstructing Objects In-Place

MyClass* obj = new MyClass(10, 3.14);

// Destroy and reconstruct with new values
obj->~MyClass();
new (obj) MyClass(20, 6.28);  // Reuse same memory

delete obj;  // Now delete is OK because original memory was from new

3. Custom Allocators (std::vector, etc.)

template<typename T>
class CustomAllocator {
public:
    void construct(T* ptr, const T& value) {
        new (ptr) T(value);  // Placement new
    }
    
    void destroy(T* ptr) {
        ptr->~T();  // Manual destructor call
    }
};

Important Rules:

  1. Never delete placement new memory unless the original memory was allocated with regular new
  2. Always call destructor manually for non-trivial types
  3. Ensure proper alignment using alignas
  4. Be careful with memory lifetime - the buffer must outlive the object

↑ Back to Table of Contents


10. Best Practices

1. Always Initialize Pointers

// Bad
int* ptr;  // Uninitialized - contains garbage

// Good
int* ptr = nullptr;  // Explicitly null
int* ptr2 = new int(42);  // Immediately initialized

2. Check for nullptr Before Dereferencing

int* ptr = get_some_pointer();

if (ptr != nullptr) {
    *ptr = 100;  // Safe
}

// Or use modern syntax
if (ptr) {
    *ptr = 100;
}

3. Always Set to nullptr After delete

int* ptr = new int(42);
delete ptr;
ptr = nullptr;  // Prevents dangling pointer

// Now safe to delete again (no-op)
delete ptr;  // OK: deleting nullptr is safe

4. Use Smart Pointers (Modern C++ : Will cover in detail later)

#include <memory>

// Use unique_ptr for exclusive ownership
std::unique_ptr<int> ptr1 = std::make_unique<int>(42);

// Use shared_ptr for shared ownership
std::shared_ptr<int> ptr2 = std::make_shared<int>(100);

// No need to delete - automatic cleanup!

5. Match new/delete and new[]/delete[]

// Single object
int* ptr = new int;
delete ptr;  // Correct

// Array
int* arr = new int[10];
delete[] arr;  // Correct - must use delete[]

// WRONG combinations:
// int* ptr = new int;
// delete[] ptr;  // WRONG!

// int* arr = new int[10];
// delete arr;  // WRONG!

6. Avoid Raw Pointers for Ownership

// Bad: Who owns this? Who deletes it?
int* create_resource() {
    return new int(42);
}

// Good: Clear ownership
std::unique_ptr<int> create_resource() {
    return std::make_unique<int>(42);
}

7. Use References When You Don’t Need nullptr

// If something must exist, use reference
void process(int& value) {  // Cannot be null
    value = 42;
}

// Use pointer only if nullptr is meaningful
void process(int* value) {  // Can be null
    if (value) {
        *value = 42;
    }
}

8. Const Correctness

// Promise not to modify through pointer
void read_only(const int* ptr) {
    std::cout << *ptr << std::endl;
}

// Clear intent to modify
void modify(int* ptr) {
    *ptr = 100;
}

10. Common Bugs

1. Dangling Pointer

int* create_dangling() {
    int x = 42;
    return &x;  // BUG: x is destroyed when function returns
}

int* ptr = create_dangling();
*ptr = 100;  // Undefined behavior! Memory is invalid

Fix:

int* create_safe() {
    int* ptr = new int(42);
    return ptr;  // OK: Memory persists
}

// Or better: use smart pointer
std::unique_ptr<int> create_safer() {
    return std::make_unique<int>(42);
}

2. Double Delete

int* ptr = new int(42);
delete ptr;
delete ptr;  // BUG: Double delete - undefined behavior!

Fix:

int* ptr = new int(42);
delete ptr;
ptr = nullptr;  // Set to null after delete
delete ptr;  // OK: Deleting nullptr is safe (no-op)

3. Memory Leak

void leak_memory() {
    int* ptr = new int(42);
    // Forgot to delete!
}  // BUG: Memory is leaked

void leak_on_exception() {
    int* ptr = new int(42);
    some_function_that_throws();  // If this throws...
    delete ptr;  // ...this never executes - LEAK!
}

Fix:

void no_leak() {
    std::unique_ptr<int> ptr = std::make_unique<int>(42);
}  // Automatically cleaned up

void no_leak_on_exception() {
    std::unique_ptr<int> ptr = std::make_unique<int>(42);
    some_function_that_throws();  // Even if this throws, ptr is cleaned up
}

4. Array Delete Mismatch

int* arr = new int[10];
delete arr;  // BUG: Should be delete[]

int* ptr = new int;
delete[] ptr;  // BUG: Should be delete

Fix:

int* arr = new int[10];
delete[] arr;  // Correct

// Or better: use std::vector
std::vector<int> arr(10);  // No manual delete needed

5. Using After Delete

int* ptr = new int(42);
delete ptr;
*ptr = 100;  // BUG: Use after free - undefined behavior!

Fix:

int* ptr = new int(42);
delete ptr;
ptr = nullptr;  // Set to null

if (ptr) {
    *ptr = 100;  // Won't execute - safe
}

6. Lost Pointer

int* ptr = new int(42);
ptr = new int(100);  // BUG: Lost reference to first allocation - LEAK!

Fix:

int* ptr = new int(42);
delete ptr;  // Clean up first
ptr = new int(100);

// Or use smart pointer
std::unique_ptr<int> ptr = std::make_unique<int>(42);
ptr = std::make_unique<int>(100);  // Old memory automatically deleted

7. Null Pointer Dereference

int* ptr = nullptr;
*ptr = 42;  // BUG: Dereferencing null pointer - crash!

Fix:

int* ptr = nullptr;
if (ptr) {
    *ptr = 42;  // Safe
}

// Or use assert for debugging
#include <cassert>
assert(ptr != nullptr);
*ptr = 42;

8. Uninitialized Pointer

int* ptr;  // Uninitialized - contains garbage
*ptr = 42;  // BUG: Writing to random memory!

Fix:

int* ptr = nullptr;  // Always initialize
if (ptr) {
    *ptr = 42;
}

// Or initialize immediately
int* ptr = new int;
*ptr = 42;

9. Pointer Arithmetic Out of Bounds

int arr[5] = {1, 2, 3, 4, 5};
int* ptr = arr;
ptr += 10;  // BUG: Points outside array
*ptr = 100;  // Undefined behavior!

Fix:

int arr[5] = {1, 2, 3, 4, 5};
int* ptr = arr;

// Check bounds
if (ptr + 10 < arr + 5) {
    ptr += 10;
    *ptr = 100;
}

// Or use std::vector with at()
std::vector<int> vec = {1, 2, 3, 4, 5};
try {
    vec.at(10) = 100;  // Throws exception if out of bounds
} catch (const std::out_of_range& e) {
    std::cerr << "Out of bounds!" << std::endl;
}

10. Mixing malloc/free with new/delete

int* ptr = (int*)malloc(sizeof(int));
delete ptr;  // BUG: Must use free()

int* ptr2 = new int;
free(ptr2);  // BUG: Must use delete

Fix:

// C-style
int* ptr = (int*)malloc(sizeof(int));
free(ptr);

// C++-style (preferred)
int* ptr2 = new int;
delete ptr2;

↑ Back to Table of Contents


Summary

Key Takeaways:

  1. Pointers store memory addresses, not values
  2. Dereferencing accesses the value at the stored address
  3. Dynamic memory requires manual management (new/delete)
  4. All pointers are the same size regardless of type
  5. Const pointers have three variations with different restrictions
  6. Smart pointers are preferred in modern C++ for automatic memory management
  7. Always initialize pointers and check for nullptr
  8. Match allocation/deallocation methods (new/delete, new[]/delete[], malloc/free)

Modern C++ Recommendations:

  • ✅ Use std::unique_ptr and std::shared_ptr
  • ✅ Use std::vector instead of arrays
  • ✅ Use references when ownership isn’t involved
  • ✅ Use RAII (Resource Acquisition Is Initialization) principles(Will cover later)
  • ❌ Avoid raw pointers for ownership
  • ❌ Avoid manual memory management when possible
  • ❌ Avoid const_cast unless absolutely necessary

Remember: With great pointer power comes great responsibility. 🎯