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::arrayorstd::vectorwith.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 anint*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::optionalor 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 constThe right-to-left rule: Read the declaration from the variable name going left:
| Declaration | Read as |
|---|---|
int* p | p is a pointer to int |
const int* p | p is a pointer to const int |
int* const p | p is a const pointer to int |
const int* const p | p 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
| Scenario | Use Reference | Use 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 factory | — | unique_ptr<T> |
| Operator overloading | T& (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_ptrandshared_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_ptrhas overhead (reference count, control block). Don't use it as a default — reach forunique_ptrfirst.
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:
- Acquire resources in the constructor
- Release resources in the destructor
- The compiler guarantees the destructor runs when the object leaves scope — even during exceptions
- 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 = 2Modern Best Practices Summary
The Golden Rules
- Never use
new/deletedirectly — usemake_uniqueandmake_shared - Default to
unique_ptr— only useshared_ptrwhen ownership is truly shared - Prefer references over pointers for function parameters
- Use
consteverywhere you can —const T&for read-only,const T* constwhen needed - Always initialize pointers —
nullptrif you don't have a value yet - Make destructors
virtualin base classes with virtual functions - Follow the Rule of Five — if you define one of destructor, copy/move constructors, or copy/move assignments, define all five (or
= deletethe ones you don't want)
Modern C++ Pointer Cheat Sheet
| Task | Old Way | Modern Way |
|---|---|---|
| Allocate single object | new T(args) | std::make_unique<T>(args) |
| Allocate shared object | new T(args) | std::make_shared<T>(args) |
| Allocate array | new T[n] | std::vector<T>(n) |
| Pass read-only | const T* | const T& |
| Pass modifiable | T* | T& |
| Optional parameter | T* (null = absent) | std::optional<T> (C++17) |
| Null pointer | NULL or 0 | nullptr |
| C-style cast | (int*)ptr | static_cast<int*>(ptr) |
| Array iteration | for (int i = 0; ...) | Range-based for (auto& x : arr) |
| Function pointer | void (*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 observationPractice 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*orstd::weak_ptr<Node>— child doesn't own its parent
This prevents circular references and makes ownership clear.
Related Posts
- C++ Phase 1: Fundamentals - C++ basics including references and pointer introduction
- C++ Phase 2: OOP in Modern C++ - Classes, destructors, and RAII foundations
- C++ Phase 3: Modern C++ Features - Smart pointers, move semantics, and modern features
- Go Pointers vs C++ Pointers - Cross-language pointer comparison
- C++ Learning Roadmap - Complete C++ learning path
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_ptrhandles 90%+ of your dynamic allocation needs — zero overhead, automatic cleanupshared_ptrcovers 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.