C++ Pointers

28 minute read

Published:

C++ Pointers

Welcome to this guide on C++ pointers. Pointers are one of the most powerful and sometimes feared features of C++. They allow for direct memory manipulation, dynamic memory allocation, and efficient handling of complex data structures. A deep understanding of pointers, memory management, and modern C++ practices like smart pointers is crucial for any serious C++ developer.

This blog post will demystify pointers and cover the following topics:

  • Direct vs. Indirect Reference
  • Declaring and Initializing Pointers
  • Stack vs. Heap Memory
  • Dynamic Memory with new and delete
  • The Dangers of Raw Pointers
  • Modern Memory Management with Smart Pointers (unique_ptr, shared_ptr, weak_ptr)
  • Advanced Memory Layouts with Classes and Pointers
  • The this Pointer
  • const Correctness with Pointers
  • Pointer Variables vs. Pointer Constants (Array Names)
  • The Size of Pointer Variables

Let’s get started!

Direct vs. Indirect Reference

In C++, you can access the value of a variable in two ways: directly or indirectly.

Direct Reference: This is the most common way. You simply use the variable’s name.

#include <iostream>

int main() {
    int direct_value = 100;
    // Expected output: Accessing value directly: 100
    std::cout << "Accessing value directly: " << direct_value << std::endl;
    return 0;
}

Indirect Reference: This involves using a pointer. A pointer holds the memory address of another variable. You get this address using the address-of operator (&). To get the value stored at that address, you “dereference” the pointer using the indirection operator (*).

#include <iostream>

int main() {
    int value = 200;
    int* pointer_to_value = &value; // Pointer holds the address of 'value'

    // Expected output: A memory address, e.g., Memory address of value: 0x7ffeed5a1a54
    std::cout << "Memory address of value: " << pointer_to_value << std::endl;
    // Expected output: Accessing value indirectly: 200
    std::cout << "Accessing value indirectly: " << *pointer_to_value << std::endl;

    // You can also modify the original variable's value through the pointer
    *pointer_to_value = 250;
    // Expected output: New value of 'value': 250
    std::cout << "New value of 'value': " << value << std::endl;
    return 0;
}

Declaring and Initializing Pointers

Declaring a pointer is similar to declaring a regular variable, but with an asterisk * between the data type and the variable name.

Syntax: dataType *pointerName;

It is crucial that the pointer’s data type matches the type of the variable it points to.

int* p_int;         // A pointer to an integer
double* p_double;   // A pointer to a double
char* p_char;       // A pointer to a character

int i = 10;
double d = 3.14;

p_int = &i; // Correct
// p_int = &d; // ERROR: cannot assign a 'double*' to an 'int*'

An uninitialized pointer holds a garbage address and is dangerous. Pointing it to a known address or setting it to nullptr is essential before use. The nullptr keyword, introduced in C++11, provides a type-safe null pointer, preventing ambiguities that existed with the older NULL macro.

#include <iostream>

int main() {
    int* p_safe_int = nullptr; // Good practice: always initialize pointers

    if (p_safe_int) {
        // This code will not execute, so there is no output.
        std::cout << *p_safe_int << std::endl;
    }
    return 0;
}

Stack vs. Heap Memory

To understand dynamic memory allocation, you must know the two main memory regions: the stack and the heap.

The Stack

The stack stores local variables and function call information. It’s fast, organized, and memory is managed automatically.

  • Memory Allocation: Automatic. The compiler manages allocation and deallocation.
  • Lifecycle: Memory is allocated when a variable comes into scope and deallocated when it goes out of scope (LIFO - Last-In, First-Out).
  • Speed: Very fast due to its simple, predictable structure.
  • Size: Relatively small and fixed. Exceeding it causes a “stack overflow.”
void myFunction() {
    int stack_variable = 50; // This variable is on the stack
} // stack_variable is automatically destroyed here
The Heap

The heap is a large pool of memory for dynamic allocation. You, the programmer, are in control.

  • Memory Allocation: Manual. You allocate memory using new and must deallocate it using delete.
  • Lifecycle: Memory persists until it is explicitly deallocated.
  • Speed: Slower than the stack due to more complex memory management.
  • Size: Much larger than the stack, limited by available system memory.
rectangle "Program Memory Layout" {
  rectangle "Code Area" as code_area
  rectangle "Data Area (Globals/Statics)" as data_area
  rectangle "Heap Area" as heap_area
  rectangle "Stack Area" as stack_area
  
  stack_area --[hidden]- heap_area
  heap_area --[hidden]- data_area
  data_area --[hidden]- code_area
  
  note right of data_area : Global variables, Static variables
  note right of heap_area : Grows upwards. For dynamic allocation
(new, malloc). Managed by the programmer.
  note right of stack_area : Grows downwards. For local variables,
function parameters. Managed by the compiler.
}

Dynamic Memory with new and delete

Dynamic memory allocation allows your program to request memory at runtime.

Single Objects

Use new to create an object on the heap and delete to destroy it.

#include <iostream>

int main() {
    // Allocate an integer on the heap
    int* p_heap_int = new int(150); 

    // Expected output: 150
    std::cout << *p_heap_int << std::endl;

    // Deallocate the memory to prevent a memory leak
    delete p_heap_int;
    p_heap_int = nullptr; // Best practice
    return 0;
}
Arrays

For arrays, use new type[size] and delete[]. Mismatching delete and delete[] leads to undefined behavior.

#include <iostream>
#include <cstdlib>
#include <ctime>

int main() {
    // Create a dynamic array of 10 integers
    int* p_array = new int[10];

    // Fill it with random numbers
    srand(time(0));
    for (int i = 0; i < 10; ++i) {
        p_array[i] = rand() % 100;
        // Expected output: A random number, e.g., 83
        std::cout << p_array[i] << " ";
    }
    // Expected output: A newline character
    std::cout << std::endl;

    // CRITICAL: Use delete[] for arrays
    delete[] p_array;
    p_array = nullptr;

    return 0;
}

Forgetting to delete allocated memory causes a memory leak. The program loses the pointer to the heap memory, making it impossible to free, and that memory remains unusable for the program’s duration.


Modern C++: Smart Pointers

Manual memory management with new and delete is error-prone. C++ provides smart pointers in the <memory> header to automate this process, ensuring that memory is deallocated correctly when the pointer goes out of scope. This principle is called RAII (Resource Acquisition Is Initialization).

std::unique_ptr

A unique_ptr provides exclusive ownership of a heap-allocated object. It’s lightweight and has virtually no performance overhead compared to a raw pointer.

  • Only one unique_ptr can point to an object at any time.
  • You cannot copy a unique_ptr, but you can move it using std::move().
  • The memory is automatically freed when the unique_ptr is destroyed.
#include <iostream>
#include <memory>

class Dog {
public:
    Dog() { std::cout << "Dog created\n"; } 
    ~Dog() { std::cout << "Dog destroyed\n"; } 
    void bark() { std::cout << "Woof!\n"; } 
};

int main() {
    // Create a Dog object on the heap owned by a unique_ptr
    // Expected output: Dog created
    std::unique_ptr<Dog> p_dog = std::make_unique<Dog>();
    // Expected output: Woof!
    p_dog->bark();

    // This design choice, where only one `unique_ptr` can own an object at a time,
    // simplifies memory management by preventing ambiguous ownership.
    // While you cannot copy a `unique_ptr` (which would imply shared ownership), 
    // its ownership can be transferred to another `unique_ptr` using `std::move()`.
    std::unique_ptr<Dog> p_dog_moved = std::move(p_dog); // Ownership transferred to p_dog_moved

    // p_dog is now null and no longer owns the Dog object.
    // p_dog->bark(); // This would cause a runtime error! 

    // Expected output: Woof!
    p_dog_moved->bark(); // p_dog_moved now owns and can access the Dog object.

    // No need to call delete. The Dog is automatically destroyed when p_dog_moved goes out of scope.
    // Expected output (at end of main): Dog destroyed
    return 0;
}
Releasing and Resetting with reset()

The reset() method allows you to manually control the lifetime of the managed object before the unique_ptr goes out of scope. Calling reset() does two things:

  1. It destroys the object that the unique_ptr currently owns (if any). The object’s destructor is called.
  2. It sets the unique_ptr itself to a nullptr.

You can also pass a new raw pointer to reset(new_ptr). In this case, the unique_ptr first destroys the old object and then takes ownership of the new_ptr.

#include <iostream>
#include <memory>

class Resource {
public:
    Resource(int id) : id_(id) { std::cout << "Resource " << id_ << " acquired.\n"; } 
    ~Resource() { std::cout << "Resource " << id_ << " destroyed.\n"; } 
private:
    int id_;
};

int main() {
    // Expected output: Resource 1 acquired.
    auto ptr = std::make_unique<Resource>(1);

    // Expected output: Calling reset()...
    std::cout << "Calling reset()\n";
    // Expected output: Resource 1 destroyed.
    ptr.reset(); 
    // Expected output: ptr is now null
    std::cout << "ptr is now " << (ptr ? "not null" : "null") << std::endl;

    // Expected output: (a blank line)
    //                  Resetting to a new resource...
    std::cout << "\nResetting to a new resource...\n";
    // Expected output: Resource 2 acquired.
    ptr.reset(new Resource(2)); // Takes ownership of a new Resource

    // Expected output: Program ending...
    std::cout << "Program ending...\n";
    // Expected output (at end of main): Resource 2 destroyed.
    return 0;
}
Transferring Ownership with release()

The release() method is different from reset(). It gives up ownership of the managed object without destroying it.

Calling release() does two things:

  1. It returns the raw pointer to the object that the unique_ptr was managing.
  2. It sets the unique_ptr itself to a nullptr.

CRITICAL: After calling release(), you become responsible for the memory of the object. You must manually call delete on the raw pointer that was returned to prevent a memory leak. This is often used when passing ownership to legacy code that expects a raw pointer.

release() vs. reset()

  • reset(): Destroys the object.
  • release(): Does not destroy the object; it just hands you the raw pointer and the responsibility to clean it up later.
#include <iostream>
#include <memory>

class LegacyConnector {
public:
    LegacyConnector(int* raw_ptr) : raw_ptr_(raw_ptr) {
        std::cout << "LegacyConnector takes ownership of raw pointer.\n";
    }
    ~LegacyConnector() {
        std::cout << "LegacyConnector deleting raw pointer.\n";
        delete raw_ptr_; // Manages its own memory
    }
private:
    int* raw_ptr_;
};

int main() {
    auto u_ptr = std::make_unique<int>(123);

    // Release ownership to pass the raw pointer to legacy code
    int* raw_p = u_ptr.release();

    // Expected output: u_ptr is now null
    std::cout << "u_ptr is now " << (u_ptr ? "not null" : "null") << std::endl;

    // The legacy code now owns the memory
    // Expected output: LegacyConnector takes ownership of raw pointer.
    LegacyConnector legacy(raw_p);

    // We don't need to delete raw_p, because LegacyConnector will do it.
    // If we weren't passing it to another owner, we would need to call:
    // delete raw_p;

    // Expected output (at end of main): LegacyConnector deleting raw pointer.
    return 0;
}
Observing the Pointer with get()

Sometimes you need to interact with code that doesn’t understand smart pointers, like a C-style library that expects a raw pointer. For this, unique_ptr provides the get() method.

  • get() returns a raw pointer to the managed object.
  • It does not affect ownership in any way. The unique_ptr continues to own the object and is responsible for its deletion.
  • The returned pointer is a temporary, non-owning, “observing” pointer.

CRITICAL: The raw pointer returned by get() is only valid for as long as the unique_ptr owns the object. If the unique_ptr is destroyed or reset, the raw pointer becomes a dangling pointer, and using it will lead to undefined behavior.

#include <iostream>
#include <memory>

// A legacy function that only understands raw pointers
void legacy_print_value(const int* p_value) {
    if (p_value) {
        std::cout << "Legacy function sees value: " << *p_value << std::endl;
    } else {
        std::cout << "Legacy function sees a null pointer.\n";
    }
}

int main() {
    auto ptr = std::make_unique<int>(42);

    // Pass a raw pointer to the legacy function
    // Expected output: Legacy function sees value: 42
    legacy_print_value(ptr.get());

    // The unique_ptr still owns the object
    *ptr = 100;
    // Expected output: Legacy function sees value: 100
    legacy_print_value(ptr.get());

    return 0; // The memory is correctly freed here by the unique_ptr
}

std::shared_ptr

A shared_ptr allows multiple pointers to share ownership of a heap-allocated object. It maintains a reference count to track how many shared_ptrs are pointing to the object.

  • The object is destroyed only when the last shared_ptr owning it is destroyed.
  • Useful when an object’s lifetime needs to be managed by multiple, non-hierarchical owners.

std::make_shared vs. Direct new

It is highly recommended to use std::make_shared to create shared_ptrs instead of directly using new and then passing the raw pointer to the shared_ptr constructor.

  1. Efficiency (Single Allocation): std::make_shared performs a single memory allocation for both the object and the shared_ptr’s control block (which holds the reference count). When you use new directly, it typically involves two separate allocations, leading to potential performance overhead and increased memory fragmentation.

  2. Exception Safety: Consider function(std::shared_ptr<T>(new T()), some_other_function()). If some_other_function() throws an exception after new T() but before the std::shared_ptr constructor is called, the memory allocated for T will leak because no shared_ptr ever took ownership. std::make_shared avoids this by ensuring the object and its control block are constructed atomically.

#include <iostream>
#include <memory>

class Project {
public:
    // Note: Destructors in C++ should not print directly. This is for demonstration.
    ~Project() { std::cout << "Project finished.\n"; } 
};

class Employee {
public:
    std::shared_ptr<Project> p_project;
    Employee(std::shared_ptr<Project> project) : p_project(project) {}
};

int main() {
    std::shared_ptr<Project> p_shared_project = std::make_shared<Project>();
    // Expected output: Reference count: 1
    std::cout << "Reference count: " << p_shared_project.use_count() << std::endl;

    {
        Employee e1(p_shared_project);
        Employee e2(p_shared_project);
        // Expected output: Reference count: 3
        std::cout << "Reference count: " << p_shared_project.use_count() << std::endl;
    } // e1 and e2 are destroyed, their shared_ptr copies are gone

    // Expected output: Reference count: 1
    std::cout << "Reference count: " << p_shared_project.use_count() << std::endl;

    // Expected output (at end of main): Project finished.
    return 0;
}
Understanding use_count()

The shared_ptr provides a useful method called use_count() which returns the number of shared_ptr instances that are currently sharing ownership of the managed object. This number is also known as the “reference count.”

  • When a new shared_ptr is created to point to the object (e.g., through copy construction or copy assignment), the use_count() increases by one.
  • When a shared_ptr is destroyed or reset, the use_count() decreases by one.

CRITICAL: The object’s destructor is called and its memory is deallocated at the exact moment the use_count() drops to zero. This happens when the very last shared_ptr owning the object is destroyed.

#include <iostream>
#include <memory>

class Widget {
public:
    Widget() { std::cout << "Widget created.\n"; }
    ~Widget() { std::cout << "Widget destroyed.\n"; }
};

int main() {
    // Expected output: Widget created.
    std::shared_ptr<Widget> main_ptr = std::make_shared<Widget>();
    // Expected output: Current use_count: 1
    std::cout << "Current use_count: " << main_ptr.use_count() << std::endl;

    {
        // Expected output: Entering inner scope...
        std::cout << "Entering inner scope...\n";
        std::shared_ptr<Widget> inner_ptr = main_ptr; // Copying increases the count
        // Expected output: Current use_count: 2
        std::cout << "Current use_count: " << main_ptr.use_count() << std::endl;
        // Expected output: Leaving inner scope...
        std::cout << "Leaving inner scope...\n";
    } // inner_ptr goes out of scope, use_count decreases

    // Expected output: Current use_count: 1
    std::cout << "Current use_count: " << main_ptr.use_count() << std::endl;

    // Expected output: main_ptr is about to be destroyed...
    std::cout << "main_ptr is about to be destroyed...\n";
    // Expected output (at end of main): Widget destroyed.
    return 0; // main_ptr is destroyed, use_count becomes 0, Widget is destroyed
}
The reset() Method with shared_ptr

Similar to unique_ptr, shared_ptr has a reset() method. However, its behavior is critically tied to the reference count.

Calling reset() on a shared_ptr does two things:

  1. It gives up its own ownership of the object, decrementing the reference count.
  2. It then checks if the reference count has become zero.

The managed object is destroyed if and only if that reset() call caused the reference count to drop to zero. If other shared_ptrs still own the object, it will not be destroyed.

#include <iostream>
#include <memory>

class Resource {
public:
    Resource() { std::cout << "Resource acquired.\n"; }
    ~Resource() { std::cout << "Resource DESTROYED.\n"; }
};

int main() {
    // Expected output: Resource acquired.
    std::shared_ptr<Resource> ptr1 = std::make_shared<Resource>();
    // Expected output: Use count is now: 1
    std::cout << "Use count is now: " << ptr1.use_count() << std::endl;

    std::shared_ptr<Resource> ptr2 = ptr1; // Copy, count becomes 2
    // Expected output: Use count is now: 2
    std::cout << "Use count is now: " << ptr1.use_count() << std::endl;

    // Expected output: Calling reset() on ptr1...
    std::cout << "\nCalling reset() on ptr1...\n";
    ptr1.reset(); // Decrements count to 1. Object is NOT destroyed.

    // Expected output: Use count is now: 1
    std::cout << "Use count is now: " << ptr2.use_count() << std::endl;
    
    // Expected output: Calling reset() on ptr2...
    std::cout << "\nCalling reset() on ptr2...\n";
    // This decrements the count from 1 to 0. The object is destroyed.
    // Expected output: Resource DESTROYED.
    ptr2.reset();

    // Expected output: Program finished.
    std::cout << "\nProgram finished.\n";
    return 0;
}

std::weak_ptr

A std::weak_ptr is a non-owning, “weak” reference to an object managed by a std::shared_ptr. It allows you to observe an object without affecting its lifetime.

  • It does not increase the reference count of the shared_ptr.
  • It is used to break circular dependency patterns that can occur with std::shared_ptr.
  • To access the underlying object, you must convert the weak_ptr to a shared_ptr using the lock() method. This is a safe way to check if the object still exists before trying to access it.

Circular References: A Dangerous Problem

A circular reference occurs when two or more objects hold shared_ptrs to each other. Because their reference counts will never drop to zero, they will never be deallocated, causing a memory leak.

Here’s an example of the problem:

#include <iostream>
#include <memory>

class Person; // Forward declaration

class Apartment {
public:
    std::shared_ptr<Person> tenant;
    // Note: Destructors in C++ should not print directly. This is for demonstration.
    ~Apartment() { std::cout << "Apartment destroyed.\n"; } 
};

class Person {
public:
    std::shared_ptr<Apartment> apartment;
    // Note: Destructors in C++ should not print directly. This is for demonstration.
    ~Person() { std::cout << "Person destroyed.\n"; } 
};

int main() {
    std::shared_ptr<Apartment> p_apt = std::make_shared<Apartment>();
    std::shared_ptr<Person> p_person = std::make_shared<Person>();

    // Create a circular reference
    p_apt->tenant = p_person;
    p_person->apartment = p_apt;

    // Both p_apt and p_person go out of scope here.
    // However, the objects they point to are not destroyed!
    // The Apartment holds a shared_ptr to the Person, and the Person holds one to the Apartment.
    // Their reference counts are both 1.
    // MEMORY LEAK: Neither destructor is called, so there is no output.
    return 0;
}

Solving Circular References with std::weak_ptr

To fix this, one of the objects should hold a weak_ptr instead of a shared_ptr. This breaks the cycle of ownership.

#include <iostream>
#include <memory>

class Person; // Forward declaration

class Apartment {
public:
    // The tenant doesn't own the apartment, so a weak_ptr is appropriate.
    std::weak_ptr<Person> tenant;
    // Note: Destructors in C++ should not print directly. This is for demonstration.
    ~Apartment() { std::cout << "Apartment destroyed.\n"; } 
};

class Person {
public:
    std::shared_ptr<Apartment> apartment;
    // Note: Destructors in C++ should not print directly. This is for demonstration.
    ~Person() { std::cout << "Person destroyed.\n"; } 
};

int main() {
    std::shared_ptr<Apartment> p_apt = std::make_shared<Apartment>();
    std::shared_ptr<Person> p_person = std::make_shared<Person>();

    p_apt->tenant = p_person; // The weak_ptr does not increase the ref count
    p_person->apartment = p_apt;

    // Now, when main ends, the Person's ref count is not increased by p_apt->tenant.
    // The p_person shared_ptr is destroyed, Person's ref count becomes 0, and the Person object is destroyed.
    // This in turn destroys the Person's apartment member, which was the last shared_ptr to the Apartment.
    // The Apartment's ref count becomes 0, and it is destroyed.
    // Expected output (order may vary slightly based on implementation):
    // Person destroyed.
    // Apartment destroyed.
    return 0;
}

Choosing the Right Smart Pointer

The core of modern C++ memory management is choosing the right tool for the job. The decision should be based on the ownership semantics your design requires.

std::unique_ptr: The Default Choice

You should always prefer unique_ptr as your default smart pointer. It is lightweight, has virtually no performance overhead compared to a raw pointer, and clearly expresses exclusive ownership.

Use unique_ptr when:

  1. You need exclusive, single ownership. The object should have one clear owner responsible for its lifetime. Think of a Car that owns its Engine. If the Car is destroyed, the Engine is destroyed too.
  2. You are transferring ownership. A common pattern is for a “factory” function to create an object and return a unique_ptr, effectively handing off ownership to the caller.
  3. You are implementing the PIMPL (Pointer to Implementation) idiom. The outer class holds a unique_ptr to its hidden implementation details.
  4. You are storing pointers in STL containers (like std::vector<std::unique_ptr<MyObject>>) where each object in the container is uniquely owned.

Rule of Thumb: Start with unique_ptr. Only switch to shared_ptr if you can prove you need shared ownership.

std::shared_ptr: The Specific Choice

Use shared_ptr only when you have a situation where an object’s lifetime is legitimately tied to multiple, non-hierarchical owners, and it’s not clear which one will be the last to finish using it.

Use shared_ptr when:

  1. You have shared ownership of a resource. For example, multiple Employee objects might share a pointer to the same Project they are working on. The Project should only be “finished” (destroyed) when the last Employee working on it is gone.
  2. You are implementing graph-like data structures. A node in a graph might be pointed to by several other nodes. shared_ptr can manage the node’s lifetime. (But beware of cycles!)
  3. You need to keep an object alive for callbacks or asynchronous operations. You can pass a shared_ptr to a callback, ensuring the object it points to remains valid until the callback is executed, even if the original owner has moved on.

The Trade-offs: shared_ptr is more expensive than unique_ptr. It requires an extra memory allocation for its control block and uses atomic operations to manage the reference count, which adds a small performance overhead every time a shared_ptr is copied or destroyed.

std::weak_ptr: The Observer

As we saw, weak_ptr is the essential companion to shared_ptr. It solves the main problem with shared ownership: circular references. Use a weak_ptr when you need to observe or access an object managed by a shared_ptr without actually participating in its ownership or extending its lifetime.

Advanced Memory Layout: Classes and Pointers

Understanding where objects and their data live is key.

Scenario 1: Stack-allocated object with a heap-allocated member

Consider a class where a member variable is a pointer to dynamically allocated memory.

#include <iostream>

class DataHolder {
    int* p_data;
public:
    DataHolder() {
        p_data = new int[50]; // Allocate memory on the heap
        std::cout << "DataHolder created, member allocated on heap.\n";
    }
    ~DataHolder() {
        delete[] p_data; // CRUCIAL: free the heap memory
        std::cout << "DataHolder destroyed, member deallocated from heap.\n";
    }
};

int main() {
    // Expected output: DataHolder created, member allocated on heap.
    DataHolder holder; // 'holder' object is created on the stack
    // Expected output (at end of main): DataHolder destroyed, member deallocated from heap.
    return 0;
}

Memory Layout:

  • The DataHolder object holder itself resides on the stack.
  • The member holder.p_data (a pointer) is part of the holder object, so it is also on the stack.
  • The large array of 50 integers allocated by new int[50] resides on the heap. The p_data pointer on the stack holds the address of this heap memory block.

Scenario 2: Heap-allocated object with a heap-allocated member

Now let’s allocate the DataHolder object itself on the heap.

#include <iostream>

class DataHolder {
    int* p_data;
public:
    DataHolder() {
        p_data = new int[50]; // Allocate memory on the heap
        std::cout << "DataHolder created, member allocated on heap.\n";
    }
    ~DataHolder() {
        delete[] p_data; // CRUCIAL: free the heap memory
        std::cout << "DataHolder destroyed, member deallocated from heap.\n";
    }
};

int main() {
    // p_holder pointer is on the stack
    // Expected output: DataHolder created, member allocated on heap.
    DataHolder* p_holder = new DataHolder(); // DataHolder object is on the heap
    
    // Expected output: DataHolder destroyed, member deallocated from heap.
    delete p_holder; // Manually delete the heap object
    return 0;
}

Memory Layout:

  • The pointer p_holder is a local variable, so it lives on the stack.
  • The DataHolder object it points to is on the heap (created by new DataHolder()).
  • The member p_data (the pointer inside the DataHolder object) resides with its object on the heap.
  • The integer array that p_data points to is in a separate block of memory, also on the heap.

The this Pointer

Every member function of a class has a hidden parameter: the this pointer. this holds the address of the object on which the member function was called. It’s useful for distinguishing between member variables and parameters, and for returning a reference to the current object.

class Box {
    double length;
public:
    Box(double length) {
        this->length = length; // 'this->length' is the member, 'length' is the parameter
    }

    Box& setLength(double new_length) {
        this->length = new_length;
        return *this; // Return a reference to the current object to chain calls
    }
};

const Correctness with Pointers

The const keyword can be used with pointers in several ways, and its position matters.

  1. Pointer to a Constant Value: You cannot change the value through the pointer.
    const int val = 10;
    const int* p1 = &val;
    // *p1 = 20; // ERROR: p1 points to a constant value
    int val2 = 30;
    p1 = &val2; // OK: The pointer itself can be changed
    
  2. Constant Pointer: The pointer itself cannot be changed to point to another address, but the value it points to can be modified.
    int val = 10;
    int* const p2 = &val;
    *p2 = 20; // OK: The value can be changed
    int val2 = 30;
    // p2 = &val2; // ERROR: p2 is a constant pointer
    
  3. Constant Pointer to a Constant Value: Neither the pointer nor the value it points to can be changed.
    int val = 10;
    const int* const p3 = &val;
    // *p3 = 20; // ERROR
    // p3 = &val2; // ERROR
    

Pointer Variables vs. Pointer Constants (Array Names)

A regular pointer is a variable that stores an address. You can reassign it. An array’s name acts as a constant pointer to its first element. You cannot reassign it.

#include <iostream>

int main() {
    int my_array[3] = {5, 10, 15};
    int another_var = 25;

    // my_array points to the first element, &my_array[0]
    // Expected output: 5
    std::cout << *my_array << std::endl;

    // The following line will cause a compilation error
    // my_array = &another_var; // ILLEGAL!
    return 0;
}

The Size of Pointer Variables

The size of a pointer depends on the system’s architecture, not the data type it points to. Its job is to hold a memory address, and the size of an address is fixed for a given architecture.

  • On a 32-bit system, a pointer is 4 bytes.
  • On a 64-bit system, a pointer is 8 bytes.
#include <iostream>

int main() {
    // Expected output on a 64-bit system: Size of int pointer: 8 bytes
    std::cout << "Size of int pointer: " << sizeof(int*) << " bytes" << std::endl;
    // Expected output on a 64-bit system: Size of double pointer: 8 bytes
    std::cout << "Size of double pointer: " << sizeof(double*) << " bytes" << std::endl;
    // Expected output on a 64-bit system: Size of char pointer: 8 bytes
    std::cout << "Size of char pointer: " << sizeof(char*) << " bytes" << std::endl;
    return 0;
}

On a 64-bit system, this will output 8 for all lines.


Conclusion

Pointers are a fundamental concept in C++. They provide a way to work directly with memory, which is essential for high-performance applications, dynamic data structures, and low-level programming. While raw pointers require careful handling, modern C++ offers powerful tools like smart pointers (unique_ptr, shared_ptr, and weak_ptr) that eliminate most of the risks associated with manual memory management. By mastering both the underlying concepts and the modern tools, you will become a much more effective and confident C++ programmer.