Back to blog

C++ Pointers: The Complete Guide from Raw to Smart

cppc++pointersmemory-managementmodern-cpp
C++ Pointers: The Complete Guide from Raw to Smart

Pointers are the feature that makes C++ both incredibly powerful and notoriously difficult. They give you direct access to memory — the ability to manipulate addresses, build complex data structures, and write code that runs close to the hardware. But with that power comes responsibility: misuse a pointer, and you get crashes, memory leaks, or security vulnerabilities.

This guide takes you from raw pointer basics through modern smart pointers, covering everything you need to write safe, efficient C++ code.

What You'll Learn

✅ Raw pointer fundamentals: declaration, dereferencing, and address-of
✅ Pointer arithmetic and how it maps to memory layout
✅ The relationship between pointers and arrays
✅ References vs pointers: when to use each
✅ Dynamic memory with new/delete and why it's dangerous
✅ Smart pointers: unique_ptr, shared_ptr, weak_ptr
✅ RAII and ownership semantics
✅ Common pointer bugs and how to avoid them
✅ Modern C++ best practices for memory safety


Raw Pointer Fundamentals

A pointer is a variable that stores a memory address. That's it. Every pointer, regardless of type, holds an address — a number that identifies a location in memory.

Declaration and Initialization

#include <iostream>
 
int main() {
    int value = 42;
 
    // Three equivalent declaration styles
    int* p1 = &value;   // Preferred: * with the type
    int *p2 = &value;   // C-style: * with the variable
    int * p3 = &value;  // Also valid, less common
 
    // & is the "address-of" operator
    std::cout << "value:   " << value << '\n';    // 42
    std::cout << "address: " << &value << '\n';   // 0x7ffd... (some hex address)
    std::cout << "p1:      " << p1 << '\n';       // same address
 
    // * is the "dereference" operator (follow the address)
    std::cout << "*p1:     " << *p1 << '\n';      // 42
 
    // Modify the original through the pointer
    *p1 = 100;
    std::cout << "value:   " << value << '\n';    // 100
 
    return 0;
}

The Mental Model

Think of memory as a giant array of bytes. Each byte has an index (its address). A pointer simply stores one of those indices.

When you write *p1, you're saying: "Go to the address stored in p1, and give me the value there."

Null Pointers

A pointer that doesn't point to anything valid should be set to nullptr:

int* ptr = nullptr;  // Modern C++ (C++11+)
int* old = NULL;     // C-style, avoid in modern code
int* bad = 0;        // Works but ambiguous — is 0 an int or pointer?
 
// Always check before dereferencing
if (ptr != nullptr) {
    std::cout << *ptr << '\n';
} else {
    std::cout << "ptr is null\n";
}
 
// Shorthand (pointers are truthy/falsy)
if (ptr) {
    std::cout << *ptr << '\n';
}

Rule: Always initialize pointers. An uninitialized pointer contains a garbage address — dereferencing it is undefined behavior.


Pointer Arithmetic

Pointer arithmetic is one of C++'s most powerful (and dangerous) features. When you add or subtract from a pointer, the compiler automatically scales by the size of the pointed-to type.

How It Works

#include <iostream>
 
int main() {
    int arr[] = {10, 20, 30, 40, 50};
    int* p = arr;  // Points to arr[0]
 
    // Adding 1 moves by sizeof(int) bytes, not 1 byte
    std::cout << "*p:       " << *p << '\n';        // 10
    std::cout << "*(p + 1): " << *(p + 1) << '\n';  // 20
    std::cout << "*(p + 2): " << *(p + 2) << '\n';  // 30
 
    // Increment moves to next element
    p++;
    std::cout << "*p after p++: " << *p << '\n';  // 20
 
    // Pointer subtraction gives element count, not byte count
    int* start = arr;
    int* end = arr + 5;
    std::cout << "Elements: " << (end - start) << '\n';  // 5
 
    // Comparison operators work on pointers
    if (start < end) {
        std::cout << "start is before end\n";
    }
 
    return 0;
}

Memory Layout Visualization

p + 1 doesn't add 1 byte — it adds sizeof(int) (typically 4 bytes). So p + 1 moves from 0x1000 to 0x1004.

The Danger Zone

int arr[] = {10, 20, 30};
 
int* p = arr;
int* danger = p + 100;  // Compiles! No bounds checking
// *danger = 42;         // Undefined behavior: writing to random memory
 
// This is the root cause of buffer overflow vulnerabilities
char buffer[10];
char* cp = buffer;
// Writing past the buffer corrupts adjacent memory
// *(cp + 100) = 'X';  // Buffer overflow!

Modern alternative: Use std::array or std::vector with .at() for bounds-checked access. Reserve pointer arithmetic for performance-critical code where you've verified bounds yourself.


Pointers and Arrays

In C++, arrays and pointers have a deep relationship. An array name decays to a pointer to its first element in most contexts.

Array Decay

#include <iostream>
 
void printSize(int* arr) {
    // sizeof(arr) is size of a pointer, NOT the array!
    std::cout << "sizeof in function: " << sizeof(arr) << '\n';  // 8 (on 64-bit)
}
 
int main() {
    int arr[] = {1, 2, 3, 4, 5};
 
    // sizeof works correctly in the declaring scope
    std::cout << "sizeof arr: " << sizeof(arr) << '\n';  // 20 (5 * 4 bytes)
 
    // Array decays to pointer when passed to function
    printSize(arr);  // Loses size information!
 
    // Array name and pointer to first element are interchangeable
    int* p = arr;           // Implicit decay
    int* p2 = &arr[0];     // Explicit — same result
 
    // These are equivalent
    std::cout << arr[2] << '\n';     // 30 — subscript notation
    std::cout << *(arr + 2) << '\n'; // 30 — pointer arithmetic
    std::cout << *(p + 2) << '\n';   // 30 — via pointer
    std::cout << p[2] << '\n';       // 30 — subscript on pointer works!
 
    return 0;
}

Why std::array and std::vector Are Better

#include <array>
#include <vector>
#include <iostream>
 
// std::array: fixed size, stack-allocated, knows its size
void printArray(const std::array<int, 5>& arr) {
    std::cout << "Size: " << arr.size() << '\n';  // 5 — size preserved!
    for (int val : arr) {
        std::cout << val << ' ';
    }
    std::cout << '\n';
}
 
// std::vector: dynamic size, heap-allocated, knows its size
void printVector(const std::vector<int>& vec) {
    std::cout << "Size: " << vec.size() << '\n';
 
    // Bounds-checked access
    try {
        std::cout << vec.at(100) << '\n';  // Throws std::out_of_range
    } catch (const std::out_of_range& e) {
        std::cout << "Out of range: " << e.what() << '\n';
    }
}
 
int main() {
    std::array<int, 5> arr = {10, 20, 30, 40, 50};
    printArray(arr);
 
    std::vector<int> vec = {10, 20, 30, 40, 50};
    printVector(vec);
 
    return 0;
}

Pointers to Pointers

A pointer can point to another pointer. This creates multiple levels of indirection.

#include <iostream>
 
int main() {
    int value = 42;
    int* ptr = &value;      // Pointer to int
    int** pptr = &ptr;      // Pointer to pointer to int
    int*** ppptr = &pptr;   // Three levels deep (rare in practice)
 
    std::cout << "value:    " << value << '\n';     // 42
    std::cout << "*ptr:     " << *ptr << '\n';      // 42
    std::cout << "**pptr:   " << **pptr << '\n';    // 42
    std::cout << "***ppptr: " << ***ppptr << '\n';  // 42
 
    // Modify through double pointer
    **pptr = 100;
    std::cout << "value after **pptr = 100: " << value << '\n';  // 100
 
    return 0;
}

Common use cases for pointer-to-pointer:

  • Modifying a pointer inside a function (pass int** to change where an int* points)
  • 2D dynamic arrays (array of pointers to arrays)
  • C-style APIs that return data through output parameters

Modern alternative: std::vector<std::vector<int>> for 2D arrays, std::optional or return values instead of output parameters.


Function Pointers

Functions live in memory too, so you can have pointers to them:

#include <iostream>
#include <functional>
 
// Regular functions
int add(int a, int b) { return a + b; }
int multiply(int a, int b) { return a * b; }
 
// Function that takes a function pointer
int apply(int a, int b, int (*operation)(int, int)) {
    return operation(a, b);
}
 
int main() {
    // Function pointer declaration
    int (*op)(int, int) = &add;  // & is optional for functions
    std::cout << "add: " << op(3, 4) << '\n';  // 7
 
    op = multiply;  // Reassign to different function
    std::cout << "multiply: " << op(3, 4) << '\n';  // 12
 
    // Pass function pointer as argument
    std::cout << "apply add: " << apply(3, 4, add) << '\n';        // 7
    std::cout << "apply multiply: " << apply(3, 4, multiply) << '\n';  // 12
 
    // Modern alternative: std::function (more flexible)
    std::function<int(int, int)> func = add;
    std::cout << "std::function: " << func(3, 4) << '\n';  // 7
 
    // Works with lambdas too
    func = [](int a, int b) { return a - b; };
    std::cout << "lambda: " << func(10, 3) << '\n';  // 7
 
    return 0;
}

Type alias makes function pointers readable:

// Without alias — hard to read
int (*getOperation(char op))(int, int);
 
// With alias — much cleaner
using BinaryOp = int (*)(int, int);
BinaryOp getOperation(char op);
 
// Or even better with std::function
using BinaryOp = std::function<int(int, int)>;

const and Pointers

const with pointers has four variations. Read them right-to-left to understand:

int value = 42;
int other = 99;
 
// 1. Pointer to non-const: can modify value and pointer
int* p1 = &value;
*p1 = 100;        // OK: modify value
p1 = &other;      // OK: change what p1 points to
 
// 2. Pointer to const: can't modify value, can change pointer
const int* p2 = &value;    // Read: "p2 is a pointer to const int"
// *p2 = 100;              // ERROR: value is const
p2 = &other;               // OK: pointer itself isn't const
 
// 3. Const pointer to non-const: can modify value, can't change pointer
int* const p3 = &value;    // Read: "p3 is a const pointer to int"
*p3 = 100;                 // OK: value isn't const
// p3 = &other;            // ERROR: pointer is const
 
// 4. Const pointer to const: can't modify either
const int* const p4 = &value;  // Read: "p4 is a const pointer to const int"
// *p4 = 100;                  // ERROR: value is const
// p4 = &other;                // ERROR: pointer is const

The right-to-left rule: Read the declaration from the variable name going left:

DeclarationRead as
int* pp is a pointer to int
const int* pp is a pointer to const int
int* const pp is a const pointer to int
const int* const pp is a const pointer to const int

Best practice: Use const wherever possible. If a function doesn't modify the pointed-to data, use const:

// Good: promises not to modify the string
void print(const std::string* s) {
    std::cout << *s << '\n';
}
 
// Even better: use const reference
void print(const std::string& s) {
    std::cout << s << '\n';
}

References vs Pointers

References are aliases — another name for an existing variable. They look like pointers but behave differently:

#include <iostream>
 
int main() {
    int value = 42;
 
    // Reference: must be initialized, can't be null, can't be reseated
    int& ref = value;
    ref = 100;
    std::cout << value << '\n';  // 100 — ref IS value
 
    // Pointer: can be null, can be reseated, must dereference
    int* ptr = &value;
    *ptr = 200;
    std::cout << value << '\n';  // 200
 
    int other = 50;
 
    // Reference can't be reseated
    ref = other;  // This ASSIGNS other's value to value, doesn't rebind ref
    std::cout << value << '\n';  // 50 (value changed, ref still aliases value)
 
    // Pointer can be reseated
    ptr = &other;  // Now ptr points to other
    *ptr = 999;
    std::cout << other << '\n';  // 999
    std::cout << value << '\n';  // 50 (unchanged)
 
    return 0;
}

When to Use Which

ScenarioUse ReferenceUse Pointer
Function parameter (read-only)const T&
Function parameter (modify)T&
Optional parameter (might be null)T*
Reseatable (change what it refers to)T*
Class member (optional)T* or smart pointer
Returning from factoryunique_ptr<T>
Operator overloadingT& (chaining)

Rule of thumb: Prefer references. Use pointers when you need null or reseating.

Function Parameter Patterns

#include <string>
#include <iostream>
 
// Pattern 1: Read-only access — const reference
void display(const std::string& name) {
    std::cout << "Hello, " << name << '\n';
}
 
// Pattern 2: Modify the original — non-const reference
void toUpper(std::string& s) {
    for (char& c : s) {
        c = std::toupper(c);
    }
}
 
// Pattern 3: Optional parameter — pointer
void greet(const std::string& name, const std::string* title = nullptr) {
    if (title) {
        std::cout << *title << " " << name << '\n';
    } else {
        std::cout << name << '\n';
    }
}
 
// Pattern 4: Transfer ownership — smart pointer (see next section)
 
int main() {
    std::string name = "alice";
 
    display(name);            // "Hello, alice"
    toUpper(name);
    display(name);            // "Hello, ALICE"
 
    std::string dr = "Dr.";
    greet(name);              // "ALICE"
    greet(name, &dr);         // "Dr. ALICE"
 
    return 0;
}

Dynamic Memory: new and delete

new allocates memory on the heap (free store) and returns a pointer. delete frees it. This is the old way — you should rarely use these directly in modern C++.

Basic Usage

#include <iostream>
 
int main() {
    // Allocate a single int
    int* p = new int(42);
    std::cout << *p << '\n';  // 42
    delete p;                 // Free the memory
 
    // Allocate an array
    int* arr = new int[5]{10, 20, 30, 40, 50};
    for (int i = 0; i < 5; i++) {
        std::cout << arr[i] << ' ';
    }
    std::cout << '\n';
    delete[] arr;  // Must use delete[] for arrays!
 
    return 0;
}

The Five Deadly Sins of Manual Memory Management

// Sin 1: Memory Leak — forgetting to delete
void leak() {
    int* p = new int(42);
    // Oops, function returns without delete
    // Memory is leaked — gone until program exits
}
 
// Sin 2: Double Delete — deleting twice
void doubleFree() {
    int* p = new int(42);
    delete p;
    // delete p;  // UNDEFINED BEHAVIOR! Crash, corruption, or worse
}
 
// Sin 3: Use After Free — accessing deleted memory
void useAfterFree() {
    int* p = new int(42);
    delete p;
    // std::cout << *p;  // UNDEFINED BEHAVIOR! May print 42, crash, or anything
}
 
// Sin 4: Mismatched delete — using delete instead of delete[]
void mismatchedDelete() {
    int* arr = new int[100];
    // delete arr;    // WRONG! Must use delete[] for arrays
    delete[] arr;     // Correct
}
 
// Sin 5: Dangling Pointer — pointing to expired local
int* dangling() {
    int local = 42;
    return &local;  // WARNING: local dies when function returns
    // The returned pointer points to garbage
}

This is why smart pointers exist. Every one of these bugs is prevented by using unique_ptr and shared_ptr.


Smart Pointers: The Modern Way

Smart pointers are RAII wrappers that automatically manage heap memory. They were introduced in C++11 and should be your default for dynamic allocation.

std::unique_ptr — Exclusive Ownership

unique_ptr owns its memory exclusively. When the unique_ptr is destroyed (goes out of scope), the memory is freed. It cannot be copied, only moved.

#include <memory>
#include <iostream>
 
class Database {
public:
    Database(const std::string& name) : name_(name) {
        std::cout << "Connected to " << name_ << '\n';
    }
    ~Database() {
        std::cout << "Disconnected from " << name_ << '\n';
    }
    void query(const std::string& sql) {
        std::cout << "[" << name_ << "] " << sql << '\n';
    }
private:
    std::string name_;
};
 
int main() {
    // Create with make_unique (preferred — exception safe)
    auto db = std::make_unique<Database>("production");
    db->query("SELECT * FROM users");
 
    // Can't copy — ownership is exclusive
    // auto db2 = db;  // COMPILE ERROR
 
    // Can move — transfers ownership
    auto db2 = std::move(db);
    // db is now nullptr
    if (!db) {
        std::cout << "db is empty after move\n";
    }
    db2->query("SELECT * FROM orders");
 
    // db2 destroyed here — "Disconnected from production" printed
    return 0;
}

Use unique_ptr when:

  • There's a single clear owner of the resource
  • You're implementing the factory pattern
  • You need polymorphism (base pointer to derived objects)
  • This covers 90%+ of dynamic allocation needs

std::shared_ptr — Shared Ownership

shared_ptr uses reference counting. The memory is freed when the last shared_ptr pointing to it is destroyed.

#include <memory>
#include <iostream>
#include <vector>
 
class Config {
public:
    Config(std::string env) : env_(std::move(env)) {
        std::cout << "Config created for " << env_ << '\n';
    }
    ~Config() {
        std::cout << "Config destroyed for " << env_ << '\n';
    }
    const std::string& env() const { return env_; }
private:
    std::string env_;
};
 
class Service {
public:
    Service(std::string name, std::shared_ptr<Config> config)
        : name_(std::move(name)), config_(std::move(config)) {}
 
    void run() {
        std::cout << name_ << " running in " << config_->env() << '\n';
    }
private:
    std::string name_;
    std::shared_ptr<Config> config_;
};
 
int main() {
    auto config = std::make_shared<Config>("production");
    std::cout << "ref count: " << config.use_count() << '\n';  // 1
 
    {
        Service auth("AuthService", config);
        Service api("ApiService", config);
        std::cout << "ref count: " << config.use_count() << '\n';  // 3
 
        auth.run();
        api.run();
    }  // auth and api destroyed, ref count drops to 1
 
    std::cout << "ref count: " << config.use_count() << '\n';  // 1
    // Config destroyed when config goes out of scope
    return 0;
}

Use shared_ptr when:

  • Multiple parts of the code genuinely need shared ownership
  • Caching systems where objects are referenced from multiple places
  • Observer patterns with shared state

Warning: shared_ptr has overhead (reference count, control block). Don't use it as a default — reach for unique_ptr first.

std::weak_ptr — Breaking Cycles

weak_ptr is a non-owning reference to a shared_ptr-managed object. It solves the circular reference problem.

#include <memory>
#include <iostream>
 
class Node {
public:
    std::string name;
    std::shared_ptr<Node> next;
    std::weak_ptr<Node> prev;  // weak_ptr breaks the cycle!
 
    Node(std::string n) : name(std::move(n)) {
        std::cout << "Node " << name << " created\n";
    }
    ~Node() {
        std::cout << "Node " << name << " destroyed\n";
    }
};
 
int main() {
    auto a = std::make_shared<Node>("A");
    auto b = std::make_shared<Node>("B");
    auto c = std::make_shared<Node>("C");
 
    // Forward links: shared_ptr (ownership)
    a->next = b;
    b->next = c;
 
    // Backward links: weak_ptr (non-owning)
    c->prev = b;
    b->prev = a;
 
    // Access weak_ptr safely
    if (auto locked = c->prev.lock()) {
        std::cout << "C's prev: " << locked->name << '\n';  // B
    }
 
    // Check if the referenced object still exists
    std::weak_ptr<Node> observer = a;
    if (!observer.expired()) {
        auto locked = observer.lock();
        std::cout << "Node still alive: " << locked->name << '\n';
    }
 
    return 0;
    // All nodes destroyed — no memory leak!
}

Without weak_ptr: If prev were shared_ptr, A→B→C→B would create a cycle — none of the nodes would ever be freed.

Smart Pointer Decision Flowchart


RAII: The C++ Memory Philosophy

RAII (Resource Acquisition Is Initialization) is the single most important idiom in C++. The idea: tie resource lifetime to object lifetime.

#include <fstream>
#include <mutex>
#include <memory>
#include <iostream>
 
// RAII example 1: File handle
void processFile(const std::string& path) {
    std::ifstream file(path);  // Opens file in constructor
    if (!file.is_open()) {
        std::cerr << "Failed to open " << path << '\n';
        return;
    }
 
    std::string line;
    while (std::getline(file, line)) {
        std::cout << line << '\n';
    }
    // file closed automatically by destructor — even if exception thrown!
}
 
// RAII example 2: Mutex lock
std::mutex mtx;
 
void safeIncrement(int& counter) {
    std::lock_guard<std::mutex> lock(mtx);  // Locks in constructor
    counter++;
    // Unlocked automatically by destructor — even if exception thrown!
}
 
// RAII example 3: Custom resource wrapper
class Socket {
public:
    Socket(const std::string& host, int port) {
        // Simulate connecting
        fd_ = 42;  // In reality, this would be a socket() + connect() call
        std::cout << "Connected to " << host << ":" << port << '\n';
    }
 
    ~Socket() {
        if (fd_ >= 0) {
            // close(fd_);  // In reality, close the socket
            std::cout << "Socket closed\n";
        }
    }
 
    // Prevent copying (one socket = one connection)
    Socket(const Socket&) = delete;
    Socket& operator=(const Socket&) = delete;
 
    // Allow moving
    Socket(Socket&& other) noexcept : fd_(other.fd_) {
        other.fd_ = -1;
    }
 
private:
    int fd_ = -1;
};
 
int main() {
    {
        Socket conn("localhost", 8080);
        // Use the connection...
    }  // Socket closed automatically here
 
    return 0;
}

The RAII pattern:

  1. Acquire resources in the constructor
  2. Release resources in the destructor
  3. The compiler guarantees the destructor runs when the object leaves scope — even during exceptions
  4. Smart pointers are the RAII wrapper for heap memory

Common Patterns and Idioms

The Pimpl Pattern (Pointer to Implementation)

Hide implementation details behind a pointer. Reduces compile-time dependencies and keeps headers clean.

// widget.h — public header
#include <memory>
#include <string>
 
class Widget {
public:
    Widget(const std::string& name);
    ~Widget();  // Must declare — unique_ptr needs complete type for delete
 
    // Move operations
    Widget(Widget&&) noexcept;
    Widget& operator=(Widget&&) noexcept;
 
    void doWork();
 
private:
    struct Impl;  // Forward declaration — no #include needed!
    std::unique_ptr<Impl> pImpl_;
};
 
// widget.cpp — implementation
#include "widget.h"
#include <iostream>
// Include heavy headers only here, not in the .h file
 
struct Widget::Impl {
    std::string name;
    int counter = 0;
    // Could include heavy types: database connections, network sockets, etc.
};
 
Widget::Widget(const std::string& name) : pImpl_(std::make_unique<Impl>()) {
    pImpl_->name = name;
}
 
Widget::~Widget() = default;  // Must be in .cpp where Impl is complete
Widget::Widget(Widget&&) noexcept = default;
Widget& Widget::operator=(Widget&&) noexcept = default;
 
void Widget::doWork() {
    pImpl_->counter++;
    std::cout << pImpl_->name << " work #" << pImpl_->counter << '\n';
}

Observer Pattern with weak_ptr

#include <memory>
#include <vector>
#include <algorithm>
#include <iostream>
 
class EventEmitter {
public:
    using Listener = std::shared_ptr<std::function<void(const std::string&)>>;
 
    void subscribe(Listener listener) {
        listeners_.push_back(listener);
    }
 
    void emit(const std::string& event) {
        // Clean up expired listeners and notify active ones
        listeners_.erase(
            std::remove_if(listeners_.begin(), listeners_.end(),
                [](const std::weak_ptr<std::function<void(const std::string&)>>& wp) {
                    return wp.expired();
                }),
            listeners_.end()
        );
 
        for (auto& wp : listeners_) {
            if (auto sp = wp.lock()) {
                (*sp)(event);
            }
        }
    }
 
private:
    std::vector<std::weak_ptr<std::function<void(const std::string&)>>> listeners_;
};

Common Pitfalls and How to Avoid Them

Pitfall 1: Returning Reference to Local

// WRONG: reference to destroyed local
const std::string& getName() {
    std::string name = "Alice";
    return name;  // Dangling reference! name is destroyed here
}
 
// CORRECT: return by value (copy elision makes this efficient)
std::string getName() {
    std::string name = "Alice";
    return name;  // Moved/elided — no copy in practice
}

Pitfall 2: Slicing with Pointers and References

class Base {
public:
    virtual void speak() { std::cout << "Base\n"; }
    virtual ~Base() = default;  // Always virtual destructor!
};
 
class Derived : public Base {
public:
    void speak() override { std::cout << "Derived\n"; }
};
 
// WRONG: slicing (copies only Base part)
void sliced(Base b) {
    b.speak();  // Always prints "Base"!
}
 
// CORRECT: use reference or pointer for polymorphism
void polymorphic(Base& b) {
    b.speak();  // Prints "Derived" if b is actually Derived
}
 
void polymorphicPtr(Base* b) {
    b->speak();  // Also works with pointer
}
 
// BEST: use smart pointer for ownership
void owned(std::unique_ptr<Base> b) {
    b->speak();
}

Pitfall 3: Forgetting Virtual Destructor

class Base {
public:
    // WITHOUT virtual destructor:
    ~Base() { std::cout << "Base destroyed\n"; }
};
 
class Derived : public Base {
    int* data_ = new int[1000];
public:
    ~Derived() {
        delete[] data_;
        std::cout << "Derived destroyed\n";
    }
};
 
// This leaks memory!
Base* p = new Derived();
delete p;  // Only calls ~Base(), not ~Derived()! data_ is leaked
 
// FIX: make destructor virtual
// virtual ~Base() { ... }

Pitfall 4: Sharing unique_ptr

// WRONG: trying to share unique_ptr
auto ptr = std::make_unique<Widget>("w1");
// auto copy = ptr;  // COMPILE ERROR — can't copy unique_ptr
 
// If you truly need sharing, upgrade to shared_ptr
auto shared = std::make_shared<Widget>("w1");
auto copy = shared;  // OK — ref count = 2

Modern Best Practices Summary

The Golden Rules

  1. Never use new/delete directly — use make_unique and make_shared
  2. Default to unique_ptr — only use shared_ptr when ownership is truly shared
  3. Prefer references over pointers for function parameters
  4. Use const everywhere you canconst T& for read-only, const T* const when needed
  5. Always initialize pointersnullptr if you don't have a value yet
  6. Make destructors virtual in base classes with virtual functions
  7. Follow the Rule of Five — if you define one of destructor, copy/move constructors, or copy/move assignments, define all five (or = delete the ones you don't want)

Modern C++ Pointer Cheat Sheet

TaskOld WayModern Way
Allocate single objectnew T(args)std::make_unique<T>(args)
Allocate shared objectnew T(args)std::make_shared<T>(args)
Allocate arraynew T[n]std::vector<T>(n)
Pass read-onlyconst T*const T&
Pass modifiableT*T&
Optional parameterT* (null = absent)std::optional<T> (C++17)
Null pointerNULL or 0nullptr
C-style cast(int*)ptrstatic_cast<int*>(ptr)
Array iterationfor (int i = 0; ...)Range-based for (auto& x : arr)
Function pointervoid (*fp)(int)std::function<void(int)>

When Raw Pointers Are Still OK

Raw pointers aren't evil — they're just non-owning. Use them when:

  • Observing without ownership (function parameters that don't take ownership)
  • Interfacing with C APIs (must convert smart pointers with .get())
  • Performance-critical inner loops where smart pointer overhead matters
  • Implementing your own data structures (linked lists, trees)

The key distinction: owning raw pointers are dangerous; non-owning raw pointers are fine.

// OK: non-owning raw pointer (doesn't manage lifetime)
void process(const Widget* widget) {
    if (widget) {
        widget->doWork();
    }
}
 
// OK: caller manages lifetime with smart pointer
auto widget = std::make_unique<Widget>("w1");
process(widget.get());  // Pass raw pointer for observation

Practice Problems

Test your understanding with these exercises:

Problem 1: Spot the Bug

int* createArray(int size) {
    int arr[size];  // VLA — not standard C++!
    for (int i = 0; i < size; i++) {
        arr[i] = i * 10;
    }
    return arr;  // What's wrong here?
}
Answer

arr is a local array on the stack. Returning a pointer to it creates a dangling pointer — the memory is invalid after the function returns. Fix: use std::vector<int> and return by value, or std::make_unique<int[]>(size).

Problem 2: Fix the Leak

void processData() {
    int* data = new int[1000];
    // ... some processing that might throw ...
    if (data[0] < 0) {
        return;  // Leak!
    }
    delete[] data;
}
Answer

If the early return is hit, delete[] is never called. Fix: use auto data = std::make_unique<int[]>(1000); — the destructor runs regardless of how the function exits.

Problem 3: Choose the Right Smart Pointer

You're building a scene graph for a game. Each node has one parent and multiple children. Which smart pointer should you use for parent vs children?

Answer
  • Children: std::vector<std::unique_ptr<Node>> — parent owns its children
  • Parent: raw pointer Node* or std::weak_ptr<Node> — child doesn't own its parent

This prevents circular references and makes ownership clear.



Conclusion

Pointers are C++'s double-edged sword. Raw pointers give you direct control over memory — essential for systems programming, game engines, and embedded systems. But that control demands discipline.

Modern C++ has evolved to keep the power while drastically reducing the danger:

  • unique_ptr handles 90%+ of your dynamic allocation needs — zero overhead, automatic cleanup
  • shared_ptr covers the remaining cases where ownership is genuinely shared
  • References replace pointers entirely for most function parameters
  • RAII ensures resources are always cleaned up, even during exceptions

The progression is clear: start with stack allocation → use unique_ptr when you need the heap → use shared_ptr only when ownership is truly shared → use raw pointers only for non-owning observation.

Master this hierarchy, and you'll write C++ that's both high-performance and memory-safe.

📬 Subscribe to Newsletter

Get the latest blog posts delivered to your inbox every week. No spam, unsubscribe anytime.

We respect your privacy. Unsubscribe at any time.

💬 Comments

Sign in to leave a comment

We'll never post without your permission.