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
- Lvalue, Rvalue & Forwarding References
- Raw Pointers: The Observer Role
- Pointer Arithmetic: Walking Memory Safely
- unique_ptr in Depth: Factories and Custom Deleters
- shared_ptr in Depth: Control Block and Thread Safety
- std::optional: The Nullable Value Type
- std::observer_ptr: Explicit Non-Ownership (C++26)
- C++ Core Guidelines on Pointer Usage
- Common Pointer Bugs and How to Detect Them
- Frequently Asked Questions
- Key Takeaway
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.
#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 lifetimeLvalue, Rvalue & Forwarding References
Understanding value categories is the key to understanding move semantics:
// 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 rvalueRaw 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).
#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:
#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
#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 destroyedshared_ptr in Depth: Control Block and Thread Safety
#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 intstd::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:
#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
| Bug | Description | Detection |
|---|---|---|
| Dangling pointer | Pointer to freed/scoped-out memory | AddressSanitizer (-fsanitize=address) |
| Use-after-free | Accessing deleted object | ASan + smart pointers |
| Double-free | Deleting already-freed memory | ASan + unique_ptr |
| Null dereference | Dereferencing nullptr | Static analysis + if() checks |
| Reference cycle | shared_ptr <-> shared_ptr | Replace with weak_ptr |
| Buffer overflow | Pointer arithmetic past end | ASan + std::span |
| Wild pointer | Uninitialized pointer | Initialize all pointers (= nullptr) |
# 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.
