- Pointers represent memory addresses and enable direct control over where and how data is stored and accessed.
- Dereferencing, const correctness and pointer arithmetic are essential to safely using pointers with arrays, structs and dynamic memory.
- Pointers allow passing arguments by reference, building dynamic structures and implementing multi-level indirection like pointer-to-pointer.
- Null checks, correct allocation/deallocation and disciplined initialization are crucial to avoid undefined behavior and crashes.
Pointers in C and C++ have a legendary reputation: powerful, tricky, and capable of crashing your program in the blink of an eye if you are careless. Yet, once you truly understand what a pointer is – just a memory address – a good part of that mystery starts to fade away, and you gain access to one of the most versatile tools in low-level and systems programming.
This article walks you step by step from the basic idea of memory addresses and simple pointers, through references, arrays, classes and dynamic memory, all the way to pointer-to-pointer use cases and common pitfalls. The goal is to make working with pointers feel natural, not like dark magic, so that you can reason about what really happens in memory when your code runs.
Understanding variables and memory addresses
Before touching pointers, you need a clear mental picture of how variables live in memory. A computer’s RAM is conceptually a long array of bytes, each byte tagged with a unique numeric address. When you declare a variable, the compiler reserves one or more of those bytes and associates that address with the variable’s name.
Think of a variable as a labeled box placed somewhere in memory: the name is the label, the address is the physical location on the shelf, and the content is the value stored inside the box. For example, if you have an int on a typical Arduino UNO, it will occupy 2 consecutive bytes in RAM, and the compiler records which exact addresses are reserved for it.
The declaration of a variable tells the compiler what type and size it needs to reserve, while the definition or assignment actually stores a value in that reserved location. For instance, writing int j; merely announces the variable and lets the compiler allocate memory, whereas j = 10; writes the numeric value 10 into the memory cells that belong to j.
Internally, the compiler keeps a symbol table where it maps each variable name to its memory address and type. If the compiler decides that j lives at address 2020, you can conceptually think of the situation like this: the identifier j points to address 2020, and the bytes at address 2020 contain the binary representation of 10.
It is critical to separate the idea of “where something is stored” (its address) from “what is stored there” (its value). In compiler theory and many books, the location is often called the lvalue (from “location value”), while the content is referred to as the rvalue. Pointers are all about manipulating those locations directly.
What exactly is a pointer?

A pointer is simply a variable whose value is a memory address pointing to some other object. It does not store the data itself, but the address of where that data lives. The size of a pointer depends on the machine’s architecture: on 32-bit x86 systems it is typically 4 bytes, on 64-bit x86-64 systems it is usually 8 bytes, and on small microcontrollers like Arduino an address may fit in 2 bytes.
When you declare a pointer, you specify not just that it stores an address, but also the type of the object it will point to. For example, int* p declares a pointer to an int. The star here is part of the type, not a multiplication sign, and it tells the compiler how many bytes to read or write when you later access *p.
The address-of operator & gives you the address of an existing object, which you can store in a pointer variable. Suppose you have int n = 0;; then this code stores the address of n into a pointer:
Example: int n = 0;
int* p = &n; // p now holds the address of n
Once a pointer holds a valid address, the dereference operator * lets you access the object that lives at that address. If p is a pointer to int, then *p behaves like an alias to the actual integer stored in memory. For example:
Snippet: *p = 1; // writes 1 into n through the pointer
std::cout << *p; // reads the current value of n
The key idea is that the star means different things in different contexts: when used in a declaration it forms the pointer type, and when used in an expression it dereferences the pointer. Confusing these two roles is one of the classic beginner mistakes, so always pay attention to whether you are declaring a pointer or using it to access memory.
On small devices such as Arduino, a pointer that is not explicitly initialized either holds a valid 16-bit address or it contains garbage. There is no magical “empty” value unless you deliberately set it to a null pointer constant like nullptr in C++. Dereferencing such a garbage address is a near-certain way to lock up your microcontroller.
Const correctness and different kinds of pointers
Pointers interact with const in ways that can be confusing at first, but mastering this is crucial for writing correct C++. The position of const relative to the star decides whether the pointed-to object, the pointer itself, or both are immutable.
If you have a constant integer, the pointer type must reflect that you can only read through it, not modify it. Imagine this code:
Demo: auto const cn = int{0}; // cn is a constant int
int const* p = &cn; // pointer to const int
The type of p here is “pointer to constant int”: you can read *p but cannot assign to it. Trying int* p = &cn; would be a type error, because that would promise you can modify a constant object, which is forbidden by the language.
Sometimes, the object itself is not const, but you intentionally want a pointer that only allows read access through it. In that case you again use int const*:
Usage: auto n = int{0}; // non-const int
int const* p = &n; // can read n via p, but not write through p
Notice that int const* and const int* mean exactly the same: the integer is read-only through the pointer, but the pointer can still be changed to point somewhere else. On the other hand, if you write int* const p = &n;, you have a constant pointer to a non-const int: the address stored in p cannot be changed after initialization, but the value *p is free to vary.
You can even combine both forms to create a constant pointer to a constant integer: int const* const p. That tells the compiler that neither the address in p nor the value stored at that address is allowed to change. Understanding these variations helps you express intent very clearly, and the compiler will keep you honest.
Pointers to structures and classes
When a pointer refers to a struct or class, you usually want to access its public interface: data members and member functions. Dereferencing with * still works, but the syntax can become a bit verbose, so C++ provides the arrow operator -> as a shorthand.
Consider a simple Student structure with grades and a method that computes the average. If Student* p holds the address of a Student object, you can write (*p).grade_2 to reach the second grade, or (*p).average() to call the member function.
The arrow operator combines dereference and member access in one step: p->grade_2 and p->average() mean exactly the same as (*p).grade_2 and (*p).average(). Under the hood, p->member is simply syntactic sugar for (*p).member. That is why you will almost always see -> used in real-world code when dealing with pointers to objects.
As long as the class does not overload operator* or operator-> with some exotic behavior, you can treat p->member as the standard way to access the object behind a pointer. Many frameworks do rely on overloading these operators for smart pointers, but conceptually, they keep the same meaning: follow the pointer and then access the member.
Null pointers and safety
A pointer that does not currently refer to a valid object is said to be null, and in modern C++ the canonical way to express this is with nullptr. Writing int* p = nullptr; explicitly states that p does not point anywhere meaningful yet.
Dereferencing a null pointer is undefined behavior, which typically leads to crashes, access violations, or on small boards, a frozen system. That is why code that receives a pointer as a parameter often checks whether it is null before using it. If your logic allows “no object” as a meaningful state, a pointer parameter is appropriate, because it can carry that “absent” information via nullptr.
An idiomatic example is a function that converts a C-style string (char const*) to std::string but must gracefully handle the case where the input pointer is null. The function checks whether the pointer is non-null before building the std::string. If it is null, it returns an empty string instead of dereferencing an invalid address.
If the parameter is mandatory and cannot be absent, C++ references are usually a better choice than raw pointers. A reference cannot be reseated and is not meant to be null, so the type system clearly expresses the expectation that the caller must provide a valid object. This makes the API safer and the code easier to reason about.
Pointers as function parameters: by value vs by reference
By default, when you pass a variable to a function in C or C++, it is passed by value: the function receives a copy of the argument’s value, not the original variable. That means any assignments to the parameter inside the function affect only the local copy and leave the caller’s variable unchanged.
This behavior is often desirable – it isolates functions and avoids surprising side effects – but sometimes you really do want a function to modify the caller’s variables. You might think of using global variables, but as programs grow, globals quickly become hard to track and error-prone.
Pointers offer a clean alternative: you pass the address of the variable to the function, and the function can then change the value at that address. This is known as “passing by reference via pointers”. In C++, you can also use reference parameters (int&), which are often even clearer, but understanding the pointer form is still essential.
Imagine a function double_value that should double an integer defined in the caller. Using a pointer-based interface, you would declare it as taking an int*, and call it by passing the address of your variable: double_value(&k);. Inside the function, *k = *k * 2; updates the original value through the pointer.
This technique also allows a function to effectively “return” multiple results by modifying several variables whose addresses were passed as arguments. Instead of returning a complex struct, you can accept several pointer parameters and update all of them. In modern C++ you would typically favor references, tuples or structs for clarity, but pointer parameters remain common in low-level APIs and C libraries.
Pointer arithmetic and arrays
One of the most powerful – and dangerous – aspects of pointers is pointer arithmetic, especially in the context of arrays. In C and C++, an array is stored as a block of contiguous elements in memory, and the array’s name can decay to a pointer to its first element when passed to a function or used in certain expressions.
If you declare char h[] = {'P','r','o','m','e','t','e','c','\n'};, then h can be treated as a pointer to h[0]. Accessing h[i] is conceptually equivalent to computing *(h + i), where h is the base address and i is the offset in elements (not in bytes). The compiler multiplies i by the size of each element (1 byte for char, 4 bytes for int, etc.) before adding it to the pointer.
This means that when you see an expression like *(h + i), you are doing classic pointer arithmetic: you advance the pointer h by i positions and then dereference the result. For performance reasons, compilers are very good at optimizing this pattern, which is why C arrays and pointers have historically been such a popular combination for low-level work.
You can also create an explicit pointer to the first element of an array and increment that pointer to walk through the array. For example, declaring char* ptr = h; and then repeatedly printing *ptr++ in a loop will traverse each character in sequence. The postfix ++ advances the pointer after each access, moving it to the next array element.
This compact style is idiomatic C, but it can be cryptic for newcomers, so in modern C++ many developers prefer more explicit forms such as for loops with indices or range-based for loops. Still, understanding pointer arithmetic is indispensable for reading and maintaining legacy code, as well as for implementing performance-critical routines.
Dynamic memory, new/delete and pointer iteration
Pointers are also the fundamental handle you receive when you allocate objects dynamically on the free store (often informally called the heap). In C++, the operator new returns a pointer to the newly allocated object, and delete frees that memory when you no longer need it.
For example, Student* p = new Student{...}; reserves enough memory for one Student object and returns its address. You then use p->member to access its members or call its methods. When the object is no longer required, delete p; destroys it and releases the memory back to the free store.
C++ also allows allocating arrays dynamically using new[], which returns a pointer to the first element of the array. For instance, Student* p = new Student[100]; allocates space for 100 Student objects laid out contiguously in memory, with p pointing to the element at index 0.
Using pointer arithmetic, the expression p + i points to the i-th element of that array, so (p + 4)->grade_1 is equivalent to p[4].grade_1. Conceptually, p is like an iterator that begins at the first element, and p + i advances that iterator by i steps along the array.
Differences between pointers also carry meaning: if q = p + 4;, then q - p evaluates to 4, which is the number of elements between those two pointers. In this sense, a raw pointer is the simplest form of a random-access iterator. Many STL containers expose iterators that behave similarly, but hide the raw pointer details for safety and flexibility.
Although raw new/delete are powerful, modern C++ strongly encourages using smart pointers and RAII (Resource Acquisition Is Initialization) to manage resources automatically. Smart pointers like std::unique_ptr and std::shared_ptr encapsulate ownership and automatically free memory when it is no longer needed, reducing the risk of leaks and double deletes.
Pointers to pointers and deeper indirection
Once you are comfortable with simple pointers, you will inevitably encounter pointers to pointers (and sometimes even higher levels of indirection). Conceptually, a pointer to pointer is just another variable that holds the address of a pointer variable, instead of directly referencing an int, double or object.
The most obvious use case is managing dynamically allocated arrays of pointers, such as a dynamically built table of dynamically allocated strings. In plain C, a classic example is char** argv in the main function, which is a pointer to an array of C-style strings, each of which is itself a char*.
Another frequent scenario is when a function must modify a pointer provided by the caller, not only the data it points to. Passing a pointer to pointer allows the function to change which object the original pointer refers to, or to initialize it by allocating a new object with new or malloc. The calling code then sees the updated pointer value.
Multiple levels of indirection also appear naturally in certain data structures, especially linked data structures created on the heap. For instance, a dynamically built linked list of nodes allocated with malloc or new may involve pointers to nodes, plus functions that receive pointers to those pointers to insert or remove elements while updating the head pointer.
And of course, multi-dimensional dynamic arrays are typically represented as pointers to pointers in C-style interfaces: a “matrix” is commonly modeled as int**, where each element of the first dimension is a pointer to a row array. In modern C++ you may prefer std::vector<std::vector<T>> or custom matrix classes, but pointer-to-pointer layouts remain fundamental in low-level APIs and bindings.
Common pitfalls and good practices with pointers
Working directly with pointers gives you fine-grained control, but it also opens the door to subtle and hard-to-debug errors if you are not disciplined. Many legendary bugs in C and C++ codebases boil down to mishandling raw addresses, either by writing to memory that does not belong to you or by forgetting to manage object lifetimes.
One classic mistake is writing more bytes than the target type can hold, or misinterpreting the type of the memory location. For example, if you store a long value in an int variable or write a long to a location that is only sized for an int, you end up overwriting adjacent memory, potentially corrupting other variables or even code pointers.
Another danger is assigning arbitrary numeric values directly to pointer variables, like ptrNum = 7;, unless you are doing extremely low-level system work and know exactly what resides at that address. For ordinary application code, treating an integer as an address is a direct line to undefined behavior and erratic crashes.
Forgetting to properly initialize pointers is also risky: a pointer that holds an indeterminate value might look fine but could be pointing anywhere in memory. Always initialize your pointers – either to a valid address or to nullptr – and check them before dereferencing if there is any doubt they might be null.
Finally, with dynamic memory, every raw new should be matched with exactly one corresponding delete, and every new[] with exactly one delete[]. Leaks occur when you lose track of dynamically allocated memory without deleting it, and double deletes (or deleting memory you do not own) corrupt the allocator’s internal structures, which usually surfaces as intermittent and very hard-to-reproduce defects.
Handled with care, pointers are more like a sharp, well-balanced tool than a random source of chaos: they let you reason about your program’s memory layout, design efficient data structures, and build powerful abstractions on top of that low-level control. As you practice, moving between addresses, dereferencing, and understanding how functions and arrays interact with pointers becomes second nature, and the initial fear gives way to a healthy respect and a lot of practical usefulness.

