“Pointers, the heart of programmers.”
In the world of C++, pointers are an unavoidable “double-edged sword”—they are the core tool for efficient memory operations and system-level programming, but they also become the easiest pitfall for beginners due to direct memory address manipulation. From dynamic memory allocation to function callbacks, from the underlying implementation of arrays to the design of smart pointers, pointers run through almost all scenarios in C++ from basic to advanced.
In this issue, we will start from the essence of “What is a pointer”, dissect the binding relationship between pointers and memory, detail the core logic of dynamic memory management, and explore modern C++ smart pointer solutions, ultimately helping you establish a cognitive system of “Using pointers safely and managing memory efficiently”.
1. What is a pointer? — Starting from “address variable”
Many beginners understand pointers as “special variables”, but a more precise definition is:A pointer is a variable that stores a “memory address”. Its core value lies in “indirectly accessing memory”—by storing the address, it finds and manipulates the corresponding data in memory, just like using a key (address) to open a locker (memory) to retrieve an item (data).
1. Pointer declaration: Why specify the “pointing type”?
The syntax for declaring a pointer is <span>type* pointer_name</span>, where the “type” is not the type of the pointer itself, but the type of data that the pointer points to in memory. This is crucial because it determines how the compiler interprets the binary data in memory.
// Correct declaration: Clearly specify the data type pointed to by the pointer
int* ptr_int; // Pointer to int type data (int occupies 4 bytes)
double* ptr_double; // Pointer to double type data (double occupies 8 bytes)
char* ptr_char; // Pointer to char type data (char occupies 1 byte)
// Incorrect example: Pointer without specified type, a "wild pointer" in the making
// *ptr; // Compilation error, the compiler cannot determine the size and interpretation of the pointed data
Why must the pointing type be specified? For example, if the pointer <span>ptr_int</span> stores the address <span>0x1000</span>, the compiler knows that starting from this address, 4 bytes in a row represent an int data; whereas if <span>ptr_double</span> points to <span>0x1000</span>, the compiler will read 8 bytes in a row as double data— the pointing type determines the “range of memory access”.
Additionally, the size of the pointer itself is fixed (related to platform bitness):
- In a 32-bit system, all pointers are 4 bytes
- In a 64-bit system, all are 8 bytes, regardless of the pointing type. You can verify with
<span>sizeof</span>:
cout << "Size of int*: " << sizeof(int*) << endl; // 64-bit system outputs 8
cout << "Size of double*: " << sizeof(double*) << endl; // 64-bit system outputs 8
2. Pointer initialization: The first line of defense against “wild pointers”
The most dangerous state of a pointer is “uninitialized”— at this point, it stores a random address, and accessing such a pointer will trigger undefined behavior (UB), potentially leading to program crashes, data corruption, or even security vulnerabilities. This uninitialized pointer is called a “wild pointer”, and proper initialization is the key to eliminating wild pointers.
There are 3 legal ways to initialize a pointer:
(1) Pointing to an existing variable (address-of operator &)
Using <span>&variable_name</span> can obtain the memory address of a variable, assigning this address to the pointer, anchoring the pointer to valid memory:
int a = 10; // Allocate int variable a on the stack, assume address is 0x7ffd8b3a7a5c
int* ptr = &a; // Pointer ptr stores the address of a, now ptr points to a
cout << "Address stored in ptr: " << ptr << endl; // Outputs 0x7ffd8b3a7a5c
cout << "Address of a: " << &a << endl; // Outputs the same as ptr
(2) Pointing to dynamically allocated memory (new keyword)
When manually allocating memory with <span>new</span>, <span>new</span> returns the address of this memory, which can be directly assigned to the pointer:
int* ptr = new int; // Allocate 4 bytes of memory on the heap, ptr stores the address of this memory
*ptr = 20; // Write the value 20 into the dynamic memory
cout << *ptr << endl; // Outputs 20
(3) Pointing to “null address” (nullptr)
If there is no definite memory to point to temporarily, the pointer should be initialized to <span>nullptr</span> (introduced in C++11, replacing the old <span>NULL</span>), clearly indicating that “the pointer currently does not point to any memory”:
int* ptr = nullptr; // Null pointer, does not point to any valid memory
// cout << *ptr << endl; // Error: null pointer cannot be dereferenced, will trigger a crash
⚠️ Note:
<span>NULL</span>is essentially<span>(void*)0</span>, which can have type conversion issues in certain scenarios; while<span>nullptr</span>is a dedicated null pointer type, safer, and recommended for use.
3. Dereferencing operation: “Manipulating” memory through pointers
The dereference operator <span>*</span> is the core usage of pointers—its function is to “access the corresponding data in memory based on the address stored in the pointer”. You can think of a pointer as an “address label”, and <span>*</span> is the action of “finding the locker based on the label”.
(1) Reading the content pointed to by the pointer
int a = 10;
int* ptr = &a;
cout << *ptr << endl; // Dereference ptr, read the content at address 0x7ffd8b3a7a5c, outputs 10
(2) Modifying the content pointed to by the pointer
Through dereferencing, you can also directly modify the value in the corresponding memory—this is also the core value of pointers “indirectly operating on variables”:
int a = 10;
int* ptr = &a;
*ptr = 20; // Assigning after dereferencing: write 20 into the memory pointed to by ptr (i.e., a's memory)
cout << a << endl; // Outputs 20, a's value has been indirectly modified by the pointer
(3) Dereferencing “boundary traps”
It must be remembered:Only pointers pointing to valid memory can be dereferenced. The following two situations will trigger undefined behavior:
// Situation 1: Dereferencing a wild pointer (uninitialized)
int* ptr;
// *ptr = 10; // Error: ptr stores a random address, modifying random memory
// Situation 2: Dereferencing a null pointer
int* ptr = nullptr;
// cout << *ptr << endl; // Error: nullptr does not point to any memory
2. Pointers and Memory — The “Underlying Logic” of Dynamic Memory Management
In C++, memory is divided into “stack memory” and “heap memory”:
- Stack memory is automatically managed by the compiler (variables are automatically released when they go out of scope)
- Heap memory needs to be manually managed by the programmer (allocated with
<span>new</span>/ released with<span>delete</span>)
Pointers are the only tool for operating heap memory, which is why pointers are deeply bound to memory management.
1. Dynamic memory allocation: Two usages of new
<span>new</span> is the keyword in C++ for allocating heap memory, and it has two core forms:Allocating a single variable and Allocating an array.
(1) Allocating a single variable (new type)
Syntax:<span>pointer = new type(initial_value)</span> (initial_value is optional, if not specified, it will be a random value)
// Allocate a single int, initial value is 30
int* ptr1 = new int(30);
cout << *ptr1 << endl; // Outputs 30
// Allocate a single double, without specifying initial value (memory contains a random value)
double* ptr2 = new double;
*ptr2 = 3.14;
cout << *ptr2 << endl; // Outputs 3.14
(2) Allocating an array (new type[])
Syntax:<span>pointer = new type[array_size]</span> (Note: the array cannot be initialized directly, values must be assigned manually)
// Allocate an array containing 5 ints (occupying 5*4=20 bytes on the heap)
int* arr_ptr = new int[5];
// Assign values to the array (access elements through pointer offset)
for (int i = 0; i < 5; i++) {
*(arr_ptr + i) = i * 10; // arr_ptr+i is the address of the i-th element, assign after dereferencing
}
// Traverse the array (two equivalent forms)
for (int i = 0; i < 5; i++) {
cout << arr_ptr[i] << " "; // Array subscript form (the compiler will convert to pointer offset)
// cout << *(arr_ptr + i) << " "; // Pointer offset form
}
// Outputs: 0 10 20 30 40
⚠️ Key differences:
- Allocate a single variable with
<span>new type</span>, release with<span>delete pointer</span>- Allocate an array with
<span>new type[]</span>, release must use<span>delete[] pointer</span>If mixed (for example, using
<span>delete</span>to release an array), it will lead to “memory leaks” or “heap corruption”.
2. Dynamic memory release: The “rules and traps” of delete
The lifecycle of heap memory is not limited by scope— even if the pointer that allocated the memory goes out of scope, the heap memory still exists until released with <span>delete</span>. Forgetting to use <span>delete</span> will lead to memory leaks (memory is occupied but cannot be used until the program ends).
(1) Example of correct release
void func() {
// 1. Allocate a single variable
int* ptr1 = new int(10);
delete ptr1; // Correct release: single variable uses delete
ptr1 = nullptr; // After release, set pointer to null to avoid becoming a "dangling pointer"
// 2. Allocate an array
int* ptr2 = new int[5];
delete[] ptr2; // Correct release: array uses delete[]
ptr2 = nullptr;
}
(2) Common release traps
Trap 1: Double release Releasing the same block of memory with <span>delete</span> multiple times will corrupt the heap structure, causing the program to crash:
int* ptr = new int;
delete ptr;
// delete ptr; // Error: double release, triggers heap corruption
Trap 2: Releasing non-heap memory<span>delete</span> can only release memory allocated with <span>new</span>, and cannot release stack memory (variables):
int a = 10;
int* ptr = &a; // ptr points to stack memory
// delete ptr; // Error: releasing stack memory, leads to program crash
Trap 3: Dangling pointer A pointer that points to memory that has been released, but the pointer itself has not been set to null, still stores an invalid address— this pointer is called a “dangling pointer”, accessing it will trigger undefined behavior:
int* ptr = new int(10);
delete ptr; // Memory has been released, but ptr still stores the old address
// cout << *ptr << endl; // Error: accessing dangling pointer, result is random
✅ Solution: After releasing memory, immediately set the pointer to
<span>nullptr</span>, and later you can check if the pointer is valid with<span>if (ptr != nullptr)</span>.
3. Memory leaks: How to identify and avoid?
Memory leaks are one of the most common memory issues in C++ programs, especially in long-running service programs, where leaked memory gradually accumulates, eventually leading to program crashes.
(1) Typical scenarios of memory leaks
// Scenario 1: Forgetting to release memory
void func1() {
int* ptr = new int[1000]; // Allocate 4000 bytes of heap memory
// No delete[] ptr; // After the function ends, ptr is destroyed, but heap memory is not released, leading to leaks
}
// Scenario 2: Pointer overwritten before release
void func2() {
int* ptr = new int(10);
ptr = new int(20); // ptr now points to new memory, the original memory containing 10 cannot be accessed, leading to leaks
delete ptr;
}
(2) Tools for detecting memory leaks
-
Valgrind (Linux): The most commonly used memory debugging tool, can detect memory leaks, dangling pointers, array out-of-bounds, and other issues. Use the command:
<span>valgrind --leak-check=full ./your_program</span> -
Visual Studio Memory Diagnostics (Windows): Monitors memory usage in real-time through the “diagnostic tools”, locating leak points.
-
Xcode Instruments (macOS): Uses the “Leaks” tool to detect memory leaks, visually displaying leak locations.
(3) Core principles to avoid memory leaks
- Pairing new/delete: After allocation, immediately think about when to release, preferably release within the same scope (e.g., allocate within a function, release before the function ends).
- Prefer using smart pointers: Modern C++ recommends using
<span>unique_ptr</span>/<span>shared_ptr</span>instead of raw pointers, as smart pointers automatically release memory, fundamentally avoiding leaks (see section five). - Avoid passing raw pointers: When passing heap memory pointers between functions, clarify “who is responsible for releasing”, to avoid confusion leading to leaks.
3. Pointers and Arrays — The underlying concept of “continuous access to addresses”
In C++, the relationship between arrays and pointers is very close—the array name is essentially a “constant pointer” pointing to the first element of the array (the array name cannot be modified to point elsewhere, but elements can be accessed through pointer offsets). Understanding this relationship allows you to see through the underlying implementation of arrays.
1. Equivalence of array names and pointers
In most scenarios, the array name will be “implicitly converted” to a pointer to the first element, so array subscript access and pointer offset access are equivalent.
int arr[3] = {10, 20, 30}; // Address of the array on the stack: arr[0] = 0x7ffd8b3a7a54, arr[1] = 0x7ffd8b3a7a58, arr[2] = 0x7ffd8b3a7a5c
// The array name arr is implicitly converted to &arr[0] (pointer to the first element)
int* ptr = arr; // Equivalent to int* ptr = &arr[0];
// The following four access methods are completely equivalent (all access arr[1])
cout << arr[1] << endl; // Array subscript: compiler converts to *(arr + 1)
cout << *(arr + 1) << endl; // Array name offset + dereference
cout << ptr[1] << endl; // Pointer subscript: compiler converts to *(ptr + 1)
cout << *(ptr + 1) << endl; // Pointer offset + dereference
// All output 20
Why are they equivalent? Because arrays are stored in memory in “contiguous storage”— the address of each element = base address of the array + index × size of the element. For example, the address of <span>arr[1]</span> = <span>&arr[0] + 1×4</span> (int occupies 4 bytes), while <span>ptr + 1</span> is the base address plus 4 bytes, dereferencing naturally gives the value of <span>arr[1]</span>.
⚠️ Exceptions:
<span>sizeof(arr)</span>will not convert the array name to a pointer, but will return the size of the entire array (e.g.,<span>sizeof(arr)</span><code><span> outputs 12, i.e., 3×4 bytes); while </span><code><span>sizeof(ptr)</span>returns the size of the pointer itself (8 in a 64-bit system).
2. Pointers and Multidimensional Arrays: “Arrays of Arrays” and Pointer Offsets
Multidimensional arrays are essentially “arrays of arrays”, for example, <span>int arr[2][3]</span> represents “an array containing 2 elements, each element is an array containing 3 ints”. The corresponding pointer operations need to consider the “dimensional hierarchy”.
(1) Address structure of multidimensional arrays
int arr[2][3] = {{1, 2, 3}, {4, 5, 6}};
// Memory layout (contiguous storage): 1 2 3 4 5 6
// Addresses: arr[0][0] = 0x7ffd8b3a7a50, arr[0][1] = 0x7ffd8b3a7a54, ..., arr[1][2] = 0x7ffd8b3a7a5c
(2) Accessing multidimensional arrays with pointers
The array name of a multidimensional array will convert to “a pointer to a subarray”, for example, <span>arr</span> is of type <span>int(*)[3]</span> (pointer to an array containing 3 ints), not <span>int*</span>:
int arr[2][3] = {{1, 2, 3}, {4, 5, 6}};
// Correct: ptr is a pointer to "an array of 3 ints"
int (*ptr)[3] = arr; // Equivalent to &arr[0], i.e., pointing to the first row
// Accessing elements through pointers:
cout << ptr[0][0] << endl; // Outputs 1, accessing row 0 column 0
cout << ptr[1][2] << endl; // Outputs 6, accessing row 1 column 2
// Pointer offset form:
cout << *(*(ptr + 1) + 2) << endl; // Equivalent to ptr[1][2], outputs 6
// Explanation:
// ptr + 1: points to the second row (address offset 3 * sizeof(int) = 12 bytes)
// *(ptr + 1): dereferences to get the name of the second row (i.e., &arr[1][0])
// *(ptr + 1) + 2: address of the second row's second element
// *(*(ptr + 1) + 2): dereferences to get the value 6
⚠️ Common mistake: Misassigning a multidimensional array name to an
<span>int*</span>type pointer
int* bad_ptr = arr; // Compilation error or warning: type mismatch!
// arr is of type int(*)[3], while bad_ptr is of type int*
// Although the address of arr[0][0] and arr are numerically the same, the type system does not allow direct assignment
If you want to use <span>int*</span> to operate on a multidimensional array, you need to explicitly convert and manually calculate offsets:
int* flat_ptr = (int*)arr; // Force conversion to a one-dimensional pointer
cout << flat_ptr[4] << endl; // Outputs 5, i.e., arr[1][1]
// Offset calculation: arr[i][j] corresponds to flat_ptr[i * column_count + j]
3. Pointer Arrays vs Array Pointers — Two Confusing Declarations
| Type | Example | Meaning |
|---|---|---|
| Pointer Array | <span>int* ptr_array[3];</span> |
Array containing 3 <span>int*</span>s |
| Array Pointer | <span>int (*array_ptr)[3];</span> |
Pointer to an array containing 3 ints |
// Pointer array
int* ptr_array[3]; // Array containing 3 int*
int a = 1, b = 2, c = 3;
ptr_array[0] = &a;
pointer_array[1] = &b;
pointer_array[2] = &c;
// Array pointer
int arr[3] = {1, 2, 3};
int (*array_ptr)[3] = &arr; // Pointer to an array containing 3 ints
📌 Memory tip:
<span>int* ptr[3]</span>has higher precedence than<span>*</span>, so it is an “array”; while<span>int (*ptr)[3]</span>uses parentheses to elevate<span>*</span>precedence, so it is a “pointer”.
4. Functions and Pointers — Callbacks, Parameter Passing, and Function Pointers
Pointers are not only used for data but can also point to functions—this is the basis for implementing callback mechanisms, strategy patterns, and event handling.
1. Declaration and usage of function pointers
Function pointers store the entry address of functions, calling them is equivalent to calling the function itself.
// Define a function
int add(int a, int b) {
return a + b;
}
int multiply(int a, int b) {
return a * b;
}
// Declare function pointer: return type (*pointer_name)(parameter list)
int (*func_ptr)(int, int);
// Assign: point to a specific function (function name is the address)
func_ptr = add;
cout << func_ptr(3, 4) << endl; // Outputs 7
func_ptr = multiply;
cout << func_ptr(3, 4) << endl; // Outputs 12
✅ Function names automatically convert to function pointers in expressions.
2. Function pointers as parameters — Implementing the “strategy pattern”
// Higher-order function: accepts function pointer as a parameter
int compute(int x, int y, int (*operation)(int, int)) {
return operation(x, y);
}
// Usage
cout << compute(5, 3, add) << endl; // Outputs 8
cout << compute(5, 3, multiply) << endl; // Outputs 15
This is very common in sorting, comparison, and event handling, for example, <span>qsort</span>:
int compare(const void* a, const void* b) {
return (*(int*)a - *(int*)b);
}
int arr[] = {3, 1, 4, 1, 5};
qsort(arr, 5, sizeof(int), compare); // compare is a function pointer
3. Practical application of callback functions
A callback is “passing a function as a parameter to another function, which will be called at some point in the future”.
void on_success() {
cout << "Operation succeeded!" << endl;
}
void on_failure() {
cout << "Operation failed!" << endl;
}
void async_operation(bool success, void (*success_callback)(), void (*failure_callback)()) {
if (success) {
success_callback();
} else {
failure_callback();
}
}
// Call
async_operation(true, on_success, on_failure); // Outputs: Operation succeeded!
5. Modern C++ Solutions — Smart Pointers
Although raw pointers are powerful, manually managing memory is prone to errors. C++11 introduced smart pointers, which automatically manage memory through the RAII (Resource Acquisition Is Initialization) mechanism, greatly enhancing safety.
1. <span>std::unique_ptr</span> — Exclusive ownership
- Only one
<span>unique_ptr</span>can point to a block of memory at a time. - Automatically releases memory when going out of scope.
- Cannot be copied, but can be moved.
#include <memory>
// Create unique_ptr
std::unique_ptr<int> ptr1 = std::make_unique<int>(42);
// Or: auto ptr1 = std::make_unique<int>(42);
cout << *ptr1 << endl; // Outputs 42
// Move ownership
std::unique_ptr<int> ptr2 = std::move(ptr1);
// cout << *ptr1 << endl; // Error! ptr1 is now null
if (ptr2) {
cout << *ptr2 << endl; // Outputs 42
} // ptr2 goes out of scope, automatically deletes
✅ It is recommended to use
<span>std::make_unique</span>for creation, avoiding raw<span>new</span>.
2. <span>std::shared_ptr</span> — Shared ownership
- Multiple
<span>shared_ptr</span>s can share the same block of memory. - Uses reference counting, automatically recovers memory when the last
<span>shared_ptr</span>is released. - Suitable for scenarios requiring shared resources.
std::shared_ptr<int> sp1 = std::make_shared<int>(100);
{
std::shared_ptr<int> sp2 = sp1; // Reference count +1
cout << "Reference count: " << sp1.use_count() << endl; // Outputs 2
} // sp2 goes out of scope, reference count -1
cout << "Reference count: " << sp1.use_count() << endl; // Outputs 1
// sp1 goes out of scope, reference count is 0, automatically releases memory
3. <span>std::weak_ptr</span> — Avoiding circular references
- Used in conjunction with
<span>shared_ptr</span>, does not increase reference count. - Used to solve the circular reference problem of
<span>shared_ptr</span>.
std::shared_ptr<int> sp = std::make_shared<int>(200);
std::weak_ptr<int> wp = sp; // Does not increase reference count
// Safe access
if (auto locked = wp.lock()) {
cout << *locked << endl; // Outputs 200
} else {
cout << "Object has been released" << endl;
}
🚫 Circular reference example (error):
struct Node { std::shared_ptr<Node> parent; std::shared_ptr<Node> child; };If
<span>parent</span>and<span>child</span>hold each other with<span>shared_ptr</span>, the reference count will never reach zero, leading to memory leaks. One should be changed to<span>weak_ptr</span>.
6. Conclusion: Building a “Safe and Efficient” Perspective on Pointer Usage
| Issue | Traditional Raw Pointers | Modern C++ Smart Pointers |
|---|---|---|
| Memory Leaks | Prone to occur (forgetting delete) | Automatically released, almost avoided |
| Dangling Pointers | Common (not null after delete) | Smart pointers automatically become invalid |
| Wild Pointers | Caused by uninitialized | make_unique/make_shared safe creation |
| Ownership Confusion | Unclear responsibility | unique_ptr clearly exclusive, shared_ptr shared |
| Circular References | No mechanism to prevent | weak_ptr solves |
✅ Best Practice Recommendations:
- Prefer using smart pointers:
<span>std::unique_ptr</span>is the first choice,<span>std::shared_ptr</span>for shared scenarios. - Avoid raw
<span>new/delete</span>: Use<span>make_unique</span>/<span>make_shared</span>instead. - Prefer passing references for function parameters: Do not pass pointers unless necessary, for safety.
- Understand the essence of pointers: Address + type + dereference, the cornerstone of low-level operations.
- Use debugging tools: Valgrind, ASan (AddressSanitizer) help detect memory issues.
“Pointers, the heart of programmers.”
They carry the most direct control over memory and require the most rigorous thinking from programmers. Mastering pointers is not only about mastering the syntax of a language but also understanding the essence of how computers store and manipulate data.
From
<span>int* ptr = &a;</span>to<span>std::unique_ptr<int> ptr = std::make_unique<int>(a);</span>, C++ has evolved, but our understanding of memory should not degrade.May you master the sharpness of raw pointers and wisely use the wisdom of smart pointers, finding your own programming path between efficiency and safety.
—— Issue 2 · End ——
📌 Welcome to follow the 【Lime World】 public account, taking you deep into C++ exploration, obtaining more system-level programming insights, source code analysis, and practical skills.
Next issue preview:Issue 3: RAII and Resource Management — Why is C++’s soul said to be “construction is acquisition”?