C++Memory

C++ Modern Pointers & References: Raw Pointers, Smart Pointers, References & Ownership Semantics

TT
TopicTrick Team
C++ Modern Pointers & References: Raw Pointers, Smart Pointers, References & Ownership Semantics

C++ Modern Pointers & References: Raw Pointers, Smart Pointers, References & Ownership Semantics


Table of Contents


The Ownership Decision Tree


References: Non-Null Aliases

A C++ reference is an alias — another name for an already-existing object. References are:

  • Always valid: Cannot be null. Cannot be uninitialized. Cannot "dangle" if used correctly.
  • Cannot be reseated: Once bound to an object, always refers to that object.
  • Zero overhead: References are typically implemented as pointers internally, but the compiler guarantees the aliasing semantics.
cpp
#include <string>
#include <vector>

// lvalue reference — alias to an existing object
int x = 42;
int& rx = x;   // rx is another name for x
rx = 100;      // x is now 100

// const reference — read-only alias, can extend temporary lifetime
const std::string& s = std::string("Hello"); // Extends temporary's lifetime
std::cout << s << '\n'; // Safe — s still valid here

// Reference parameters — the C++ way to modify caller's data
void increment(int& value) { value++; }

int counter = 0;
increment(counter); // counter is now 1

// Reference return — return an alias to a data member (careful!)
class Container {
    std::vector<int> data_;
public:
    int& operator[](size_t i) { return data_[i]; }       // Allows: c[0] = 42;
    const int& operator[](size_t i) const { return data_[i]; } // Read-only
};

// Binding rules:
std::string s1 = "Hello";
std::string& r1 = s1;       // OK: non-const lvalue ref to non-const lvalue
const std::string& r2 = s1; // OK: const ref to non-const lvalue (restricts modification)
// std::string& r3 = "temp";   // ERROR: non-const ref to rvalue
const std::string& r4 = "temp"; // OK: const ref extends temporary lifetime

Lvalue, Rvalue & Forwarding References

Understanding value categories is the key to understanding move semantics:

cpp
// Lvalue: has a name, has an address, can appear on left of =
int x = 5;     // x is lvalue
int& lref = x; // lvalue reference binds to lvalue

// Rvalue: temporary, no persistent address, cannot appear on left of =
int&& rref = 5;                // rvalue reference to literal 5
int&& rr2 = x * 2;            // rvalue reference to temporary result

// std::move: cast lvalue to rvalue (enables move semantics)
std::vector<int> v1 = {1, 2, 3};
std::vector<int> v2 = std::move(v1); // Move v1's data to v2 (v1 now empty)

// Forwarding reference (template + auto&&): binds to lvalue OR rvalue
template<typename T>
void wrap(T&& arg) {
    // arg is a forwarding reference — deduces to T& (lvalue) or T&& (rvalue)
    actual_function(std::forward<T>(arg)); // Preserve value category
}

// auto&& is also a forwarding reference:
auto&& mystery = get_something(); // lvalue if get_something() returns ref, else rvalue

Raw Pointers: The Observer Role

In Modern C++, raw pointers T* have a specific semantic meaning: non-owning observer. If you see T* in Modern C++ code, it means:

  • The pointer does NOT own the object (someone else manages its lifetime).
  • The pointer might be null (nullable).
  • The pointer might be an array (pointer arithmetic context).
cpp
#include <memory>

class Engine {
public:
    void start();
    void stop();
};

class Car {
    std::unique_ptr<Engine> engine_; // OWNS the engine
    Engine* current_engine_observer_; // Observer — doesn't own
    
public:
    Car() : engine_(std::make_unique<Engine>()),
            current_engine_observer_(engine_.get()) {}
    
    // Pass raw pointer to read-only observer — caller doesn't own
    void attach_diagnostic_tool(Engine* tool) { /*...*/ }
};

// C++ Core Guidelines: Use T* to express a nullable, non-owning pointer
void process(int* data, size_t count) {
    if (!data) return; // Must check null for raw pointer
    for (size_t i = 0; i < count; ++i) {
        data[i] *= 2;
    }
}

// Better alternative with std::span:
#include <span>
void process_span(std::span<int> data) {
    for (auto& d : data) d *= 2; // No null check needed, has size built in
}

Pointer Arithmetic: Walking Memory Safely

Pointer arithmetic is still valid C++ for buffer manipulation — but std::span is the modern wrapper:

cpp
#include <span>
#include <cstddef>

// Raw pointer arithmetic (still valid, but use span when possible)
void copy_bytes(const char* src, char* dst, size_t count) {
    for (size_t i = 0; i < count; ++i) {
        dst[i] = src[i];  // Equivalent to *(dst + i) = *(src + i)
    }
}

// Modern equivalent with std::span (bounds-safe):
void copy_bytes_safe(std::span<const char> src, std::span<char> dst) {
    if (src.size() > dst.size()) throw std::length_error("dst too small");
    std::ranges::copy(src, dst.begin());
}

// Pointer as iterator — common in performance code:
void simd_sum(const float* begin, const float* end, float* out) {
    float sum = 0.0f;
    for (const float* p = begin; p != end; ++p) {
        sum += *p;
    }
    *out = sum;
}

unique_ptr in Depth: Factories and Custom Deleters

cpp
#include <memory>
#include <cstdio>

// Factory pattern — preferred way to return heap objects
template<typename T, typename... Args>
std::unique_ptr<T> make(Args&&... args) {
    return std::make_unique<T>(std::forward<Args>(args)...);
}

// Custom deleter for non-standard cleanup
struct FileDeleter {
    void operator()(FILE* f) const noexcept {
        if (f) std::fclose(f);
    }
};

std::unique_ptr<FILE, FileDeleter> open_file(const char* path, const char* mode) {
    FILE* f = std::fopen(path, mode);
    if (!f) return nullptr; // Null unique_ptr — closed not opened
    return std::unique_ptr<FILE, FileDeleter>(f);
}

// Lambda as custom deleter:
auto close_pipe = [](FILE* p) { pclose(p); };
std::unique_ptr<FILE, decltype(close_pipe)> pipe_handle(popen("ls", "r"), close_pipe);

// Array unique_ptr:
auto buffer = std::make_unique<char[]>(1024); // unique_ptr<char[]>
buffer[0] = 'H'; buffer[1] = 'i'; // Operator[] available for arrays
// Automatically calls delete[] when destroyed

shared_ptr in Depth: Control Block and Thread Safety

cpp
#include <memory>
#include <atomic>

// shared_ptr internal layout:
// ┌─────────────────────┐    ┌────────────────────────┐
// │ shared_ptr object   │    │ Control Block          │
// │  - ptr to T ────────┼───>│  - strong ref count    │ (atomic)
// │  - ptr to CB ───────┼──┐ │  - weak ref count      │ (atomic)
// └─────────────────────┘  └>│  - deleter             │
//                            │  - T object (if        │
//                            │    make_shared used)   │
//                            └────────────────────────┘

// make_shared: ONE allocation for control block + T
auto sp1 = std::make_shared<int>(42); // CB + int in one block — cache friendly

// Two-arg constructor: TWO allocations (less efficient)
auto sp2 = std::shared_ptr<int>(new int(42)); // T separate from CB

// Thread safety:
// shared_ptr's reference COUNT is thread-safe (atomic increment/decrement)
// The POINTED-TO OBJECT is NOT thread-safe via shared_ptr alone
// You still need mutex protection to access the underlying data concurrently

// Use-count inspection (avoid in production — racy in MT context):
std::cout << sp1.use_count() << '\n'; // 1

auto sp3 = sp1; // Copy: reference count becomes 2
std::cout << sp1.use_count() << '\n'; // 2

// Aliasing constructor:
auto pair_ptr = std::make_shared<std::pair<int, std::string>>(42, "hello");
std::shared_ptr<int> int_part(pair_ptr, &pair_ptr->first); // Shares ownership, points to first
// int_part keeps the pair alive, but you access only the int

std::optional: The Nullable Value Type

std::optional<T> is a value type that may or may not contain a T. It's the type-safe replacement for nullable pointers to value types:

cpp
#include <optional>
#include <string>

// Return value or nothing — better than returning a magic -1
std::optional<int> find_user_id(const std::string& name) {
    if (name == "Alice") return 42;
    if (name == "Bob")   return 101;
    return std::nullopt; // No value
}

void demonstrate_optional() {
    auto id = find_user_id("Alice");
    
    if (id) {                        // Check if value present
        std::cout << "ID: " << *id << '\n';  // Dereference
    }
    
    auto missing = find_user_id("Zara");
    std::cout << missing.value_or(-1) << '\n'; // -1 if no value
    
    // Monadic operations (C++23):
    auto name_len = find_user_id("Alice")
        .and_then([](int id) -> std::optional<std::string> {
            return "user_" + std::to_string(id);
        })
        .transform([](const std::string& s) { return s.size(); });
    
    // name_len is optional<size_t>
    if (name_len) std::cout << "Name length: " << *name_len << '\n';
}

Common Pointer Bugs and How to Detect Them

BugDescriptionDetection
Dangling pointerPointer to freed/scoped-out memoryAddressSanitizer (-fsanitize=address)
Use-after-freeAccessing deleted objectASan + smart pointers
Double-freeDeleting already-freed memoryASan + unique_ptr
Null dereferenceDereferencing nullptrStatic analysis + if() checks
Reference cycleshared_ptr <-> shared_ptrReplace with weak_ptr
Buffer overflowPointer arithmetic past endASan + std::span
Wild pointerUninitialized pointerInitialize all pointers (= nullptr)
bash
# Detect pointer bugs at runtime:
clang++ -fsanitize=address,undefined -g source.cpp -o debug_build
./debug_build
# ASan prints: ERROR: heap-use-after-free / stack-buffer-overflow / etc.

# Static analysis:
clang-tidy source.cpp --checks='bugprone-*,clang-analyzer-*'

Frequently Asked Questions

Is it ever OK to use new and delete in Modern C++? Almost never — only in two scenarios: when implementing a custom allocator or smart pointer itself, or when interfacing with a legacy C API that requires raw allocation. In all other cases, make_unique, make_shared, or stack allocation are correct.

What's the difference between T*, T&, and std::span<T>? T* is a nullable, possibly-array, non-owning observer. T& is a non-null, non-owning alias to a single object. std::span<T> is a non-owning view over a contiguous sequence with explicit size — the "correct" replacement for (T* ptr, size_t count) pairs.

Does using smart pointers have runtime overhead vs raw pointers? unique_ptr<T> with default deleter: zero overhead — identical to raw T* in release builds. shared_ptr<T>: two atomic operations per copy (reference count increment/decrement) — ~10ns on modern hardware. For hot paths, pass const shared_ptr<T>& instead of copying.


Key Takeaway

Modern C++ pointer semantics are about expressing ownership and lifetime in the type system. When you see unique_ptr, you know the object has one owner and will die with it. When you see shared_ptr, multiple owners collaborate. When you see T*, nothing is owned — it's an observer. When you see T&, it's an alias that cannot be null. This semantic clarity is what makes Modern C++ codebases maintainable at scale.

Read next: RAII: Resource Acquisition Is Initialization →


Part of the C++ Mastery Course — 30 modules from modern C++ basics to expert systems engineering.