Originally published on Code Beneath.
If you come from Java or Go, pointers and memory management in C++ feel like someone handed you a loaded gun without a safety. The language will not stop you from dereferencing null, reading freed memory, or leaking gigabytes. But pointers are not evil. They are the mechanism underneath every network socket, every memory pool, every high-performance system. Once you see what actually happens in memory, the fear dissolves into understanding. This article shows you pointers and memory management C++ example code that prints real addresses, demonstrates the stack, the heap, and the specific mistakes that burn production services.
The Stack and Heap, Visible
In Java, you think in references. In Go, the language decides whether a value lives on the stack or heap, and you rarely care. In C++, you decide, and you see the consequences immediately. Let us start with a program that prints actual memory addresses so you can watch the stack and heap in action.
#include <iostream>
#include <string>
using namespace std;
int main() {
// Stack allocation
int stack_int = 42;
int* ptr_to_stack = &stack_int;
cout << "Stack int address: " << ptr_to_stack << endl;
cout << "Stack int value: " << *ptr_to_stack << endl;
// Heap allocation
int* heap_int = new int(100);
cout << "Heap int address: " << heap_int << endl;
cout << "Heap int value: " << *heap_int << endl;
// Another stack variable
int another_stack = 999;
cout << "Another stack int address: " << &another_stack << endl;
// Typical output on a 64-bit system:
// Stack int address: 0x7ffc6b7ff65c
// Stack int value: 42
// Heap int address: 0x55a7c1a19260
// Heap int value: 100
// Another stack int address: 0x7ffc6b7ff658
delete heap_int;
return 0;
}
Compile and run this: the stack addresses are close together and large (typically 0x7fff...). The heap address is different and typically smaller. That is not magic. The operating system maps these regions. When your program starts, it gets a stack that grows downward (on most systems) and a heap that grows upward. Variables declared directly in a function live on the stack. Memory allocated with new lives on the heap. The pointer itself (the address value) lives wherever you declare it. The thing it points to lives on the heap or stack depending on how it was created.
Stack variables are fast and automatic. They vanish when the function returns. Heap memory persists until you explicitly deallocate it with delete. That freedom is also a trap.
Ownership and the Use-After-Free Bug
Here is the most common nightmare in C++: you free memory, then accidentally read or write it anyway.
#include <iostream>
using namespace std;
int* create_number() {
int* num = new int(42);
return num;
}
int main() {
int* my_number = create_number();
cout << "Value: " << *my_number << endl; // OK
delete my_number;
cout << "After delete: " << *my_number << endl; // UNDEFINED BEHAVIOR
return 0;
}
Compile and run this. It might print garbage, crash, or appear to work (do not trust that). The memory is gone, but the pointer is still valid in the sense that it holds an address. You can still dereference it. The operating system did not magic away the bits. It just marked that memory as "available to reuse." When you read or write to freed memory, you are corrupting whatever now lives there. In a server, this is a data corruption bug that shows up as silent, random failures weeks later.
Who owns the memory? In the code above, create_number allocates but does not deallocate. The caller gets the pointer and must remember to delete it. If you forget, memory leaks. If you delete twice, crash. If you delete in the wrong place, the other place crashes. This is why everyone dreads C++ in large codebases.
Smart Pointers: Automatic Ownership
In C++11 and later, smart pointers solve ownership by using a concept borrowed from Rust: the memory is owned by an object that cleans itself up. The two main types are unique_ptr (exclusive ownership) and shared_ptr (reference counted).
#include <iostream>
#include <memory>
using namespace std;
int* create_number_raw() {
return new int(42);
}
unique_ptr<int> create_number_smart() {
return make_unique<int>(42);
}
int main() {
// The old way: you manage
int* raw_num = create_number_raw();
cout << "Raw: " << *raw_num << endl;
delete raw_num; // Must not forget
// The smart way: the pointer manages itself
unique_ptr<int> smart_num = create_number_smart();
cout << "Smart: " << *smart_num << endl;
// Automatically deleted when smart_num goes out of scope
return 0;
}
A unique_ptr owns its memory exclusively. When the unique_ptr object is destroyed (at the end of the function, or when a variable goes out of scope), it automatically calls delete. You cannot copy it. You can only move it (transfer ownership). This makes ownership explicit in the code: if a function returns a unique_ptr<int>, you know who owns the memory.
#include <iostream>
#include <memory>
#include <string>
using namespace std;
class Config {
public:
Config(const string& name) : name(name) {
cout << "Config created: " << name << endl;
}
~Config() {
cout << "Config destroyed: " << name << endl;
}
string name;
};
unique_ptr<Config> load_config() {
return make_unique<Config>("production");
}
int main() {
cout << "Before scope" << endl;
{
auto config = load_config();
cout << "Inside scope, config name: " << config->name << endl;
}
cout << "After scope" << endl;
return 0;
}
Run this and watch the output. You will see "Config created", then "Inside scope", then "Config destroyed", then "After scope". The destructor runs automatically when the unique_ptr goes out of scope. No manual cleanup, no leak, no use-after-free possible.
shared_ptr is similar but counts references. If multiple parts of your code own a piece of data, use shared_ptr. When the last reference is released, the memory is freed.
#include <iostream>
#include <memory>
using namespace std;
class Resource {
public:
Resource(int id) : id(id) {
cout << "Resource " << id << " allocated" << endl;
}
~Resource() {
cout << "Resource " << id << " freed" << endl;
}
int id;
};
int main() {
shared_ptr<Resource> r1 = make_shared<Resource>(1);
cout << "r1 use_count: " << r1.use_count() << endl; // 1
{
shared_ptr<Resource> r2 = r1; // Copy, increment reference count
cout << "r1 use_count after r2 = r1: " << r1.use_count() << endl; // 2
}
cout << "r1 use_count after r2 destroyed: " << r1.use_count() << endl; // 1
return 0;
}
This prints: "Resource 1 allocated", "r1 use_count: 1", "r1 use_count after r2 = r1: 2", "r1 use_count after r2 destroyed: 1". When you exit main, r1 is destroyed, use_count drops to 0, and "Resource 1 freed" prints. You never write a destructor or delete. The shared_ptr handles it. This is closest to Go's garbage collection, but deterministic and transparent.
Stack vs. Heap Performance and Semantics
When should you allocate on the stack versus the heap? The rule is simple: stack when the size and lifetime are known at compile time, heap for large objects, long-lived data, or polymorphism. Here is a comparison.
| Aspect | Stack | Heap |
|---|---|---|
| Speed | Very fast, one instruction | Slower, involves allocator |
| Lifetime | Automatic, function scope | Manual (you decide when to free) |
| Size limits | Limited (usually 1-8 MB) | Large (limited by RAM) |
| Fragmentation | None | Possible |
| Suitable for | Small, local values | Large structures, containers |
In practice, on a native language like C++, you allocate small local structures on the stack and wrap heap allocations in smart pointers. Containers like vector and map manage their own heap memory internally, so you do not have to think about it. When you need polymorphism (virtual functions), you use pointers to base classes, and again, wrap them in smart pointers.
Common Mistakes
Mistake 1: Returning a pointer to a stack variable.
int* bad_function() {
int x = 42;
return &x; // WRONG: x is destroyed when function returns
}
The caller gets a pointer to memory that no longer belongs to the program. This is a classic dangling pointer. Always return by value or return a unique_ptr.
Mistake 2: Deleting the same pointer twice.
int* p = new int(42);
delete p;
delete p; // CRASH: undefined behavior
If you use smart pointers, this is impossible. A unique_ptr can only be deleted once because it cannot be copied.
Mistake 3: Mixing new and delete with stack allocation.
int x = 42;
delete &x; // WRONG: x is on the stack, not heap
Only delete memory you allocated with new. Stack variables clean themselves up.
Mistake 4: Creating a shared_ptr from a raw pointer twice.
int* raw = new int(42);
shared_ptr<int> sp1(raw);
shared_ptr<int> sp2(raw); // WRONG: both think they own it
// When sp1 is destroyed, it deletes raw
// When sp2 is destroyed, it tries to delete it again: crash
If you have a raw pointer, create one smart pointer and use that. If you need multiple references, copy the smart pointer.
Pointers to Pointers and Indirection
Sometimes you need a pointer to a pointer. This is rare but essential for callbacks and certain data structures. Here is what is happening at the memory level.
#include <iostream>
using namespace std;
int main() {
int value = 42;
int* ptr = &value; // Pointer to int
int** ptr_ptr = &ptr; // Pointer to pointer
cout << "value: " << value << endl;
cout << "ptr (address of value): " << ptr << endl;
cout << "ptr_ptr (address of ptr): " << ptr_ptr << endl;
cout << "*ptr (dereference once): " << *ptr << endl;
cout << "**ptr_ptr (dereference twice): " << **ptr_ptr << endl;
return 0;
}
ptr_ptr holds the address of ptr, which holds the address of value. Dereference once to get the pointer, twice to get the value. You will see this in C APIs (like sorting callbacks) and when working with arrays of pointers. In modern C++, avoid it when possible. Use references or containers instead.
References vs. Pointers
C++ has references, which are safer than pointers because they cannot be null and cannot be reseated (changed after initialization). A reference is a compiled-away alias to the original variable.
#include <iostream>
using namespace std;
void modify_by_pointer(int* p) {
*p = 100;
}
void modify_by_reference(int& r) {
r = 100;
}
int main() {
int x = 42;
modify_by_pointer(&x);
cout << "After pointer call: " << x << endl;
x = 42;
modify_by_reference(x);
cout << "After reference call: " << x << endl;
return 0;
}
Both produce the same machine code. Use pointers when you need to store an address, pass a pointer to another function, or use nullptr as a sentinel. Use references for function parameters and return values. This makes the calling code cleaner and the intent clearer.
Arrays and Pointer Decay
In C++, an array name decays to a pointer to its first element. This is where many off-by-one bugs and buffer overruns live.
#include <iostream>
using namespace std;
int main() {
int arr[5] = {10, 20, 30, 40, 50};
int* ptr = arr; // arr decays to pointer to first element
cout << "arr[2]: " << arr[2] << endl;
cout << "ptr[2]: " << ptr[2] << endl; // Same
cout << "*(ptr + 2): " << *(ptr + 2) << endl; // Same
cout << "Size of arr: " << sizeof(arr) << endl; // 20 (5 ints)
cout << "Size of ptr: " << sizeof(ptr) << endl; // 8 (one address)
return 0;
}
When arr is assigned to ptr, you lose the size information. ptr is now just a pointer. In a function, if you pass an array as a parameter, it also decays. This is why C APIs that take arrays also take a size parameter. In C++, use std::vector or std::array to avoid this pitfall. They maintain bounds and size.
Function Pointers and Callbacks
Function pointers are addresses of executable code. They enable callbacks and late binding without virtual functions.
#include <iostream>
using namespace std;
int add(int a, int b) {
return a + b;
}
int multiply(int a, int b) {
return a * b;
}
int operate(int x, int y, int (*func)(int, int)) {
return func(x, y);
}
int main() {
int (*func_ptr)(int, int) = add;
cout << "Using add: " << func_ptr(5, 3) << endl;
func_ptr = multiply;
cout << "Using multiply: " << func_ptr(5, 3) << endl;
cout << "Using operate: " << operate(5, 3, add) << endl;
return 0;
}
Function pointers are rare in modern C++. Use std::function or lambdas instead. They are cleaner and type-safer.
The const Pointer
Const pointers confuse everyone. Remember: read from right to left. const int* p means "pointer to const int" (you cannot change the int). int* const p means "const pointer to int" (you cannot change the pointer, but can change the int).
#include <iostream>
using namespace std;
int main() {
int x = 42;
int y = 100;
// Pointer to const int: cannot modify x through this pointer
const int* p1 = &x;
// *p1 = 50; // COMPILE ERROR
p1 = &y; // OK, can change where p1 points
// Const pointer to int: cannot change where p2 points
int* const p2 = &x;
*p2 = 50; // OK, can modify x
// p2 = &y; // COMPILE ERROR
// Const pointer to const int: cannot do either
const int* const p3 = &x;
// *p3 = 50; // COMPILE ERROR
// p3 = &y; // COMPILE ERROR
return 0;
}
This matters for function signatures. If a function takes const int*, it promises not to modify the data, and you can safely pass a pointer to const data. This is a key tool for communicating intent and preventing bugs.
Memory Alignment and Padding
Pointers and memory addresses must respect alignment. On a 64-bit system, a 64-bit value must be at an address divisible by 8. Smaller types have looser constraints. The compiler adds padding to structures to maintain alignment.
#include <iostream>
using namespace std;
struct Bad {
char a; // 1 byte
long b; // 8 bytes
char c; // 1 byte
};
struct Good {
long b; // 8 bytes
char a; // 1 byte
char c; // 1 byte
};
int main() {
cout << "sizeof(Bad): " << sizeof(Bad) << endl; // 24 (not 10)
cout << "sizeof(Good): " << sizeof(Good) << endl; // 16 (not 10)
Bad b;
cout << "Address of b.a: " << (void*)&b.a << endl;
cout << "Address of b.b: " << (void*)&b.b << endl;
cout << "Address of b.c: " << (void*)&b.c << endl;
return 0;
}
The compiler reorders and pads to keep b aligned to an 8-byte boundary. You do not control this directly (without compiler attributes), but you can optimize by grouping similar-sized fields. This is not usually critical for correctness, but it matters for tight memory constraints or cache-heavy workloads.
Common Debugging with Valgrind
When you suspect a memory bug, use Valgrind (on Linux). It detects use-after-free, leaks, and invalid writes.
g++ -g -o program program.cpp
valgrind --leak-check=full ./program
The -g flag includes debug symbols so Valgrind can report line numbers. Valgrind runs your program in an emulator and tracks every memory operation. On a native language project, this is your safety net until you can refactor to smart pointers.
FAQ
What is the difference between a pointer and a reference?
A reference is an alias that cannot be null or reseated. A pointer holds an address and can be reassigned or null. Use references in function signatures and return types for clarity. Use pointers when you need to store addresses, check for null, or interface with C code.
When should I use unique_ptr vs. shared_ptr?
Use unique_ptr by default. It has zero overhead and clear ownership. Use shared_ptr only when multiple objects need to own the same resource. Reference counting has a small performance cost.
Can I return a raw pointer from a function?
Only if the caller is not responsible for deleting it. For example, returning a pointer to a member of an object that owns it. If the function allocates and the caller must delete, return a unique_ptr instead. It makes ownership explicit.
What causes a segmentation fault?
Reading or writing memory you do not own. Common causes: dereferencing null, using a dangling pointer, buffer overflow, or corrupted heap. Use Valgrind to find the exact line. Use smart pointers to prevent most causes.
Is C++ memory management faster than garbage collection?
Yes, but only if you write correct code. Smart pointers have negligible overhead compared to garbage collection, and you control exactly when cleanup happens. However, manual memory management is error-prone. For most code, choose clarity and correctness over micro-optimized manual cleanup.
How do I know if a pointer is valid?
You do not, without additional bookkeeping. This is why smart pointers are essential. A unique_ptr guarantees the memory is valid or null. For raw pointers, you must document ownership and lifetime in code comments or use static analysis tools.
What about null pointers?
A null pointer (address 0) is not valid to dereference. Always check before dereferencing a raw pointer: if (ptr != nullptr) { ... }. Smart pointers and references eliminate most null checks by design.
Should I worry about memory alignment when writing network code?
Yes, for parsing binary protocols. Misaligned casts can cause crashes or undefined behavior. Use memcpy for unaligned reads and writes, or use #pragma pack carefully. See native language guides for serialization best practices.
How do I learn more about low-level memory in Go or languages like it?
Go abstracts memory, but reading about Go runtime and escape analysis is enlightening. Go shows you that memory decisions are still being made, just by the compiler, not you. C++ teaches you to make those decisions explicitly and understand the cost.
Top comments (0)