← Back to all posts

08 C++ Notes

Pointers, Part 2: Safety and Arithmetic

C++ Notes


Dangling Pointers

What Is a Dangling Pointer

Solutions

Master / Owner Pointer Pattern

C++
int* owner { new int{5} };  // owner (master pointer)
int* slave { owner };         // non-owner pointer (same address)

if (owner != nullptr) {
    std::cout << *slave << '\n'; // safe to use because owner is valid
} else {
    std::cout << "memory already released!\n";
}

delete owner;   // only owner calls delete
owner = nullptr;
slave = nullptr; // reset non-owner too to prevent dangling

Code Example: main.cpp

C++
#include <iostream>

int main() {
    // uninitialized pointer (safe way)
    int* p1{};              // nullptr, safe
    // int* p1;             // dangling (junk address), dangerous

    // deleted pointer (reset immediately after delete)
    int* p2 = new int{10};
    delete p2;
    p2 = nullptr;          // prevents dangling

    // multiple pointers: owner model
    int* owner { new int{5} };  // master pointer
    int* slave { owner };        // non-owner, read/write only

    if (owner != nullptr) {
        std::cout << *slave << "\n"; // prints: 5
    }

    delete owner;   // only owner deletes
    owner = nullptr;
    slave = nullptr; // reset to prevent dangling

    return 0;
}

When New Fails

What Happens on Failure

The two code examples below intentionally exhaust memory for learning purposes. Do not run them normally. Infinite loops with heap allocation and no delete will freeze the system, not just crash the one program.

Handling with Try-Catch

C++
#include <iostream>
#include <new>

int main() {
    try {
        while (true) {
            int* block { new int[1'000'000] }; // ~4 MB per iteration
            std::cout << "Allocated memory\n";
        }
    }
    catch (std::exception& e) {
        std::cout << "Memory allocation failed: " << e.what() << '\n';
    }
    return 0;
}

Handling with std::nothrow

C++
#include <iostream>
#include <new>

int main() {
    while (true) {
        int* block { new (std::nothrow) int[1'000'000] }; // no exception on failure
        if (block == nullptr) {
            std::cout << "Memory allocation failed\n"; // caught via nullptr check
            break;
        }
        std::cout << "Allocated memory\n";
    }
    return 0;
}

Null Pointer Safety

Checking Pointer Validity

C++
int* p{};
if (p) {
    std::cout << "pointer contains VALID address\n";
} else {
    std::cout << "pointer contains INVALID address\n";
}

Calling delete on a nullptr multiple times is completely safe, the standard guarantees it's a no-op. What's unsafe is calling delete on a pointer that still holds an address to already-freed memory, which is exactly the case the null-reset habit is meant to prevent.


Code Example: main.cpp

C++
#include <iostream>

int main() {
    int* p{};  // nullptr

    if (p) {
        std::cout << "pointer contains VALID address\n";
    } else {
        std::cout << "pointer contains INVALID address\n"; // prints this
    }

    // deleting nullptr multiple times is completely safe (standard guarantees it)
    delete p;  // safe: no-op
    delete p;  // safe: no-op
    delete p;  // safe: no-op

    return 0;
}

Dynamic Arrays

What Is a Dynamic Array

Limitations vs Stack Arrays

CapabilityStack ArrayDynamic Array
std::size()WorksCompilation error, the array has decayed to a pointer.
Range-based for-each loopWorksNot possible, the compiler needs to know the size.
sizeof()Returns the full array's sizeReturns the size of the pointer, not the array.
Length trackingAutomaticNot stored anywhere. The programmer must track it manually.

When a dynamic array is created, it immediately decays into a pointer to its first element. Only the base address is kept, the array's length is completely lost the moment that happens, which is exactly why every row above breaks the same way.

Creation Forms

C++
double* marks { new double[10] };                        // uninitialized elements
int*    marks { new (std::nothrow) int[10]{} };          // zero-initialized, nothrow
double* marks { new (std::nothrow) double[5]{1,2,3,4,5} }; // partially initialized

Pointer Behavior on Dynamic Arrays

ExpressionMeaning
marksAddress of element 0, the base address.
*marksValue of the first element.
(marks + i)Address of element i.
*(marks + i)Value at element i.
marks[i]Value at element i, same as above.
&marks[i]Address of element i, same as marks + i.

So marks[i] == *(marks + i) and &marks[i] == (marks + i) always hold, indexing is really just pointer arithmetic wearing a friendlier syntax.

Releasing Memory

Use delete[], not delete. Using plain delete on an array is undefined behavior.

C++
delete[] marks;
marks = nullptr;

Size Rule for Dynamic Arrays


Code Example: main.cpp

C++
#include <iostream>

int main() {
    int size = 5; // runtime variable allowed for heap arrays
    double* marks = new (std::nothrow) double[size]{1, 2, 3, 4, 5};

    std::cout << "Base address (marks):   " << marks  << '\n'; // prints: address
    std::cout << "First element (*marks): " << *marks << "\n\n"; // prints: 1

    // accessing elements via index and pointer arithmetic
    for (int i = 0; i < size; ++i) {
        std::cout << "marks[" << i << "] = " << marks[i]
                  << " | address = " << (marks + i) << '\n';
    }

    std::cout << "\nEquivalence check:\n";
    std::cout << "(marks + 2)    = " << (marks + 2)  << '\n'; // address of element 2
    std::cout << "*(marks + 2)   = " << *(marks + 2) << '\n'; // prints: 3
    std::cout << "marks[2]       = " << marks[2]     << '\n'; // prints: 3 (same)
    std::cout << "&marks[2]      = " << &marks[2]    << '\n'; // address of element 2 (same)

    delete[] marks;  // delete[] for arrays, not delete
    marks = nullptr;

    return 0;
}

Pointer Arithmetic

How Pointer Arithmetic Works

C++
int* p;
p + 1  // moves forward by sizeof(int) bytes = 4 bytes

double* q;
q + 1  // moves forward by sizeof(double) bytes = 8 bytes

When you write base_address + index, the compiler silently does base_address + (index * sizeof(type)). Pointer arithmetic is element-based, not byte-based, the compiler handles the scaling for you every time.

Valid and Invalid Operations

ValidInvalid
ptr + n // move forward n elements
ptr - n // move backward n elements
++ptr // move to next element
--ptr // move to previous element
ptr1 - ptr2 // distance in elements (same array only)
ptr + ptr // NOT allowed
ptr * 2 // NOT allowed
ptr / 2 // NOT allowed
void* math // arithmetic on void* not allowed in standard C++

Array and Pointer Relationship

An array name acts like a pointer to its first element: arr == &arr[0], both give the address of element 0.

C++
int arr[5]{10,20,30,40,50};
arr        // address of element 0
arr + 1    // address of element 1
*(arr + 2) // value of element 2 = 30

The &arr vs arr Distinction

Trick: Getting Stack Array Size

A classic trick that exploits the type difference between arr and &arr to compute the number of elements at compile time.

C++
int arr[] = {0,1,2,3,4,5,6,7,8,9};
int size = *(&arr + 1) - arr;
  1. arr vs &arr: arr is type int*, pointing to element 0. &arr is type int(*)[10], pointing to the entire array object. Both hold the same numeric address, but arithmetic scales differently because of their types.
  2. &arr + 1: since &arr is a pointer to the whole array of 10 ints, adding 1 jumps past the entire array. On a system where int is 4 bytes, that's 40 bytes forward. The result is a pointer to the memory location just past arr[9], which is conceptually arr[10].
  3. *(&arr + 1): dereferencing this gives an array object of the same type int[10]. That object immediately decays into a pointer to its first element, so *(&arr + 1) behaves as an int* pointing to what would be arr[10].
  4. Subtract arr: subtracting two pointers of the same type int* gives the number of elements between them, not raw bytes. The compiler divides the byte difference by sizeof(int) automatically. Result: (address of arr[10]) minus (address of arr[0]) equals 40 bytes divided by 4, which is 10 elements.

This trick works only for stack arrays, and only within the same scope where the array was declared. The moment it's passed to a function, &arr behaves as int**, because the compiler no longer knows the array size. For heap arrays, &arr doesn't point to the entire array object at all, so the trick doesn't apply there, for heap arrays, always store the size in a separate variable.


Code Example: main.cpp

C++
#include <iostream>

int main() {
    int arr[]{10, 20, 30, 40, 50};

    // arr and &arr hold the same address but have different types
    std::cout << "arr        = " << arr  << '\n'; // address of element 0
    std::cout << "&arr       = " << &arr << '\n'; // same numeric address, different type
    std::cout << "arr + 1    = " << (arr + 1)  << '\n'; // moves by sizeof(int) = 4 bytes
    std::cout << "&arr + 1   = " << (&arr + 1) << '\n'; // moves by sizeof(int[5]) = 20 bytes

    // pointer arithmetic equivalences
    std::cout << "\nPointer arithmetic:\n";
    std::cout << "*(arr + 0) = " << *(arr + 0) << '\n'; // prints: 10
    std::cout << "*(arr + 2) = " << *(arr + 2) << '\n'; // prints: 30
    std::cout << "arr[2]     = " << arr[2]     << '\n'; // prints: 30 (same as above)
    std::cout << "&arr[2]    = " << &arr[2]    << '\n'; // address of element 2

    // size trick using type difference between arr and &arr
    int size = *(&arr + 1) - arr;
    std::cout << "\nSize via trick: " << size << '\n'; // prints: 5

    // dynamic array: pointer arithmetic only, must track size manually
    int* darr = new int[5]{1, 2, 3, 4, 5};
    std::cout << "\nDynamic array:\n";
    std::cout << "*(darr + 3) = " << *(darr + 3) << '\n'; // prints: 4
    std::cout << "darr[3]     = " << darr[3]     << '\n'; // prints: 4 (same)
    delete[] darr;
    darr = nullptr;

    return 0;
}

Closing

That's the full pointer picture: how they fail silently, how to keep them from failing at all, how arrays and pointers are really the same thing underneath, and one genuinely clever compile-time trick to close it out. Next up, references, the safer cousin of everything covered across these two posts.

← Previous 07 - Pointers, Part 1: Declaration and Memory Next → 09 - References