Hi everyone ✋
In the previous post we learned about pass by value and pass by reference. We also briefly touched pointer arithmetic along the way. If you haven’t read the earlier posts in this series, please go through them first, because today we are going to combine almost everything we have learned so far.
Today’s topic is one of the most beautiful ─ and most confusing ─ relationships in C++ ─ pointers and arrays.
A lot of beginners use arrays for months without ever realizing that under the hood, arrays and pointers are practically siblings. Once we see this connection, a lot of C++ “magic” suddenly starts making sense 🪄
Let’s take a deep dive 🦴
A quick refresher on arrays
Before we connect arrays with pointers, let’s quickly remember what an array is.
An array is a collection of elements of the same type, stored side by side in memory.
#include <iostream>
using namespace std;
int main()
{
int marks[5] = {90, 85, 70, 60, 95};
cout << marks[0] << endl;
cout << marks[1] << endl;
cout << marks[2] << endl;
return 0;
}
The output will be ─
90 85 70
Nothing surprising here. We made an array called marks with 5 numbers, and we accessed them using their index ─ marks[0], marks[1], and so on.
But here’s the question that opens today’s whole topic ─ what is marks actually? 🤔
The big secret ─ an array name is an address
Let’s run a tiny experiment. We’ll print the array name itself, and we’ll print the address of the first element.
#include <iostream>
using namespace std;
int main()
{
int marks[5] = {90, 85, 70, 60, 95};
cout << "marks : " << marks << endl;
cout << "&marks[0] : " << &marks[0] << endl;
return 0;
}
The output will be something like ─
marks : 0x7ffe5a3c &marks[0] : 0x7ffe5a3c
😲 They are the same address!
This is the big secret. When we just write the array name marks (without any index), C++ treats it as the address of the first element of the array.
In other words ─ the array name itself behaves like a pointer to the first element.
So all this time we have been writing marks[0], we were actually holding a pointer without even knowing it 😄
Pointing a pointer at an array
Since the array name is basically an address, we can store it in a pointer variable. Let’s do that.
#include <iostream>
using namespace std;
int main()
{
int marks[5] = {90, 85, 70, 60, 95};
// marks is the address of the first element
// so we can assign it directly to an int pointer
int *ptr = marks;
cout << "first element using ptr: " << *ptr << endl;
return 0;
}
The output will be ─
first element using ptr: 90
Notice something important ─ we did not write int *ptr = &marks;. We just wrote int *ptr = marks; with no & sign.
Why?
Because marks is already an address. Putting & in front of an array name is unnecessary here ─ the array name decays into a pointer to its first element all by itself. This automatic behavior even has a name ─ it’s called array-to-pointer decay.
And when we did *ptr, we dereferenced the pointer and got 90, which is exactly marks[0]. So far so good 🥳
Walking through the array with pointer arithmetic
Now here is where it gets really fun. Remember pointer arithmetic from the last post? When we add 1 to a pointer, it does not jump by 1 byte ─ it jumps forward by one whole element, based on the type.
For an int pointer, ptr + 1 moves ahead by sizeof(int) bytes (usually 4 bytes) and lands exactly on the next integer.
Since an array stores its elements side by side, this means ptr + 1 lands on marks[1], ptr + 2 lands on marks[2], and so on 🤯
Let’s prove it.
#include <iostream>
using namespace std;
int main()
{
int marks[5] = {90, 85, 70, 60, 95};
int *ptr = marks;
cout << "*(ptr + 0): " << *(ptr + 0) << endl;
cout << "*(ptr + 1): " << *(ptr + 1) << endl;
cout << "*(ptr + 2): " << *(ptr + 2) << endl;
cout << "*(ptr + 3): " << *(ptr + 3) << endl;
cout << "*(ptr + 4): " << *(ptr + 4) << endl;
return 0;
}
The output will be ─
*(ptr + 0): 90 *(ptr + 1): 85 *(ptr + 2): 70 *(ptr + 3): 60 *(ptr + 4): 95
🥳🥳 We just walked through the entire array using nothing but a pointer and some addition!
Let’s understand what happened in memory. Imagine the array sitting in memory like little boxes next to each other, and each box is 4 bytes wide (for an int).
| Element | marks[0] | marks[1] | marks[2] | marks[3] | marks[4] |
|---|---|---|---|---|---|
| Value | 90 | 85 | 70 | 60 | 95 |
| Address | 1000 | 1004 | 1008 | 1012 | 1016 |
| Reached by | ptr + 0 | ptr + 1 | ptr + 2 | ptr + 3 | ptr + 4 |
See how the address jumps by 4 each time, not by 1?
That’s pointer arithmetic respecting the size of int. The + 1 means “one element forward”, not “one byte forward”. Then *(ptr + 1) dereferences that new address and gives us the value living there.
The mind-blowing equivalence
Now brace yourself for the cleanest idea in this entire post.
We have two ways to reach the value at index i ─
- the array way ─
marks[i] - the pointer way ─
*(marks + i)
And here’s the secret ─ they are exactly the same thing. In fact, when we write marks[i], the C++ compiler internally rewrites it as *(marks + i). The square-bracket syntax is just sugar on top of pointer arithmetic 🍬
Let’s confirm it.
#include <iostream>
using namespace std;
int main()
{
int marks[5] = {90, 85, 70, 60, 95};
cout << "marks[2] : " << marks[2] << endl;
cout << "*(marks + 2) : " << *(marks + 2) << endl;
return 0;
}
The output will be ─
marks[2] : 70 *(marks + 2) : 70
Identical 🥳
And because marks[i] is really *(marks + i), and addition doesn’t care about order (a + b is the same as b + a), this strange-looking line is actually legal C++ ─
cout << 2[marks] << endl; // prints 70 !!
Yes, 2[marks] works and gives 70, because the compiler turns it into *(2 + marks), which is the same as *(marks + 2). Please don’t write code like this in real projects 😅 ─ but it’s a fun proof that arrays really are just pointer arithmetic in disguise.
Using a pointer with array index syntax
Since arrays and pointers are so deeply connected, the reverse also works ─ we can use the familiar [] syntax on a pointer.
#include <iostream>
using namespace std;
int main()
{
int marks[5] = {90, 85, 70, 60, 95};
int *ptr = marks;
// we can use [] on the pointer just like an array!
cout << ptr[0] << endl;
cout << ptr[3] << endl;
return 0;
}
The output will be ─
90 60
So a pointer can pretend to be an array, and an array name can pretend to be a pointer. They really are two views of the same idea.
Looping through an array using a pointer
Let’s put it all together with a real loop. This is a very common pattern we’ll see in C and older C++ code.
#include <iostream>
using namespace std;
int main()
{
int marks[5] = {90, 85, 70, 60, 95};
int *ptr = marks;
// loop using the pointer
for (int i = 0; i < 5; i++)
{
cout << "element " << i << " : " << *(ptr + i) << endl;
}
return 0;
}
The output will be ─
element 0 : 90 element 1 : 85 element 2 : 70 element 3 : 60 element 4 : 95
We could even move the pointer itself forward each step (ptr++) instead of using i, but adding i to a fixed pointer keeps things easy to read.
One important difference ─ they are NOT identical twins ⚠️
By now it might feel like arrays and pointers are literally the same thing. They are very close, but not exactly equal. There is one big difference that trips up many beginners.
An array name knows the size of the whole array. A pointer does not ─ a pointer only knows about one address.
Let’s see this clearly.
#include <iostream>
using namespace std;
int main()
{
int marks[5] = {90, 85, 70, 60, 95};
int *ptr = marks;
cout << "sizeof(marks) : " << sizeof(marks) << endl;
cout << "sizeof(ptr) : " << sizeof(ptr) << endl;
return 0;
}
The output will be something like ─
sizeof(marks) : 20 sizeof(ptr) : 8
Look at that 👀
sizeof(marks) gave 20, because the array holds 5 integers and each int is 4 bytes (5 × 4 = 20). The array name remembers the entire block.
But sizeof(ptr) gave 8, which is just the size of one pointer variable on a 64-bit system. The pointer has no idea how many elements are sitting behind it ─ it only knows a single address.
This is why a classic trick to count array elements ─
int count = sizeof(marks) / sizeof(marks[0]); // 20 / 4 = 5
works on a real array, but breaks if we try it on a pointer. So even though arrays decay into pointers, remember ─ an array is not just a pointer. It’s a pointer that also remembers how big it is.
Why this matters ─ passing arrays to functions
Here’s a real-world consequence of everything above, and it connects straight back to our last post.
When we pass an array to a function, the array decays into a pointer. The function does not receive a copy of the whole array ─ it only receives the address of the first element.
#include <iostream>
using namespace std;
// even though it looks like an array, arr is actually a pointer here
void printMarks(int arr[], int size)
{
for (int i = 0; i < size; i++)
{
cout << arr[i] << " ";
}
cout << endl;
}
int main()
{
int marks[5] = {90, 85, 70, 60, 95};
// we must pass the size separately, because the function can't figure it out
printMarks(marks, 5);
return 0;
}
The output will be ─
90 85 70 60 95
Two big lessons hide in this small example 🥳
First ─ because the array decays to a pointer, the function works directly on the original array, not a copy. This is basically pass by reference happening automatically. If printMarks changed a value, the change would stick in main.
Second ─ notice we had to pass 5 as a separate size argument. Why?
Because the moment marks entered the function, it became a plain pointer and forgot its size. Inside the function, sizeof(arr) would give 8, not 20. That’s exactly the difference we just learned. This is why almost every C-style array function asks us to pass the length along with the array.
Wait ─ we can write that parameter as a pointer too
Here’s something that follows directly from what we just learned. Since the array decays into a pointer the moment it enters the function, the parameter was never really an array in the first place ─ it was always a pointer. So C++ lets us be honest about it and write the parameter as an actual pointer.
These two function headers are 100% identical to the compiler ─
// the array-looking way void printMarks(int arr[], int size) // the honest pointer way ─ exactly the same thing void printMarks(int *arr, int size)
Let’s use the pointer version in a full example.
#include <iostream>
using namespace std;
// arr is now written as a pointer ─ but it behaves exactly like before
void printMarks(int *arr, int size)
{
for (int i = 0; i < size; i++)
{
cout << arr[i] << " "; // we can still use [] on the pointer!
}
cout << endl;
}
int main()
{
int marks[5] = {90, 85, 70, 60, 95};
printMarks(marks, 5);
return 0;
}
The output will be ─
90 85 70 60 95
Same result, same behavior 🥳
Notice that even though arr is declared as int *arr, we still happily wrote arr[i] inside the loop. That’s allowed because ─ as we learned earlier ─ a pointer can use the [] syntax just like an array. And arr[i] is still just *(arr + i) under the hood.
So which style should we use? 🤔
It’s mostly personal taste, but here’s a good rule of thumb ─ writing int *arr makes it honest and clear that the function only receives an address, not a real copyable array. Many experienced C++ programmers prefer the pointer form for exactly that reason. The int arr[] form looks friendlier to beginners, but it can fool us into thinking a whole array was passed, when really it’s just a pointer.
What about multi-dimensional arrays? 🤔
Everything so far used a simple one-dimensional array ─ a single row of values. But real life isn’t always one row. Sometimes we need to store marks for multiple students across multiple subjects, like a table. Sometimes we need a grid for a game board, or a matrix for some math. For all of these, we reach for 2D arrays.
So let’s see how pointers connect with those ─ because the connection is just as deep, with one extra twist.
A normal 2D array first
Let’s start with something familiar.
#include <iostream>
using namespace std;
int main()
{
// 2 students, 3 subjects each
int marks[2][3] = {
{90, 85, 70},
{60, 95, 88}
};
cout << marks[0][2] << endl;
cout << marks[1][0] << endl;
return 0;
}
The output will be ─
70 60
So marks[1][0] means “row 1, column 0” ─ the first subject of the second student. The first index picks the row, the second index picks the column. Easy enough so far.
The secret ─ a 2D array is actually flat
Here’s the key idea, and it’s the same kind of “behind the scenes” truth we keep discovering in this series ─ a 2D array is not really a grid floating in space. In memory, it is stored as one long flat line, one row after another.
This storage style has a name ─ row-major order. It simply means “store the whole first row, then the whole second row, then the next, all in a straight line.”
So our marks[2][3] array really sits in memory like this ─
| Cell | marks[0][0] | marks[0][1] | marks[0][2] | marks[1][0] | marks[1][1] | marks[1][2] |
|---|---|---|---|---|---|---|
| Value | 90 | 85 | 70 | 60 | 95 | 88 |
| Flat index | 0 | 1 | 2 | 3 | 4 | 5 |
Look carefully ─ the entire first student’s row (90, 85, 70) comes first, then the entire second student’s row (60, 95, 88), all in one continuous strip. The neat [2][3] grid is just a comfortable view that C++ lays on top of this flat block of 6 integers. The grid is in our heads ─ the memory is a straight line.
Walking a 2D array with a single pointer
Because it’s secretly flat, we can stroll through an entire 2D array with one pointer, exactly like we did with a 1D array 🤯
#include <iostream>
using namespace std;
int main()
{
int marks[2][3] = {
{90, 85, 70},
{60, 95, 88}
};
// point at the very first element
int *ptr = &marks[0][0];
// walk through all 2 * 3 = 6 elements in a straight line
for (int i = 0; i < 6; i++)
{
cout << *(ptr + i) << " ";
}
cout << endl;
return 0;
}
The output will be ─
90 85 70 60 95 88
🥳 One pointer, one loop, and we visited every single cell ─ because in memory there were never really two dimensions, just six integers sitting in a row. The ptr + i simply slides along that flat strip, one int at a time.
The twist ─ the array name decays to a “pointer to a row” ⚠️
Now here’s the one part that surprises almost everyone, so let’s read this slowly.
Did we notice that we wrote int *ptr = &marks[0][0]; and not int *ptr = marks;?
With a 1D array, the plain name was a simple int*, so int *ptr = marks; worked perfectly. But with a 2D array, the plain name marks does not decay into a simple int*. Instead, it decays into a pointer to a whole row ─ a pointer that points to an entire group of 3 integers at once.
That type is written like this ─
int (*rowPtr)[3] = marks; // rowPtr points to a "row of 3 ints"
The reason is exactly the pointer-arithmetic rule from earlier in this post. When we add 1 to a pointer, it jumps forward by the size of one whole thing it points to. For our 2D array ─
markspoints to a row of 3 ints, somarks + 1jumps forward by an entire row (3 integers), landing on the second student.&marks[0][0]points to a single int, soptr + 1jumps forward by just one integer.
Let’s actually see the difference ─ this makes it click 🥳
#include <iostream>
using namespace std;
int main()
{
int marks[2][3] = {
{90, 85, 70},
{60, 95, 88}
};
int (*rowPtr)[3] = marks; // pointer to a row of 3
int *cellPtr = &marks[0][0]; // pointer to a single int
// adding 1 to rowPtr jumps a whole row forward
cout << "rowPtr + 1 first value : " << **(rowPtr + 1) << endl;
// adding 1 to cellPtr jumps just one int forward
cout << "cellPtr + 1 value : " << *(cellPtr + 1) << endl;
return 0;
}
The output will be ─
rowPtr + 1 first value : 60 cellPtr + 1 value : 85
See the difference? 👀 rowPtr + 1 skipped the whole first row and landed on 60 (the start of row 1). But cellPtr + 1 only stepped one integer ahead to 85. Same + 1, completely different jump ─ because the two pointers point to things of different sizes.
So the safe takeaway ─ when we want a plain int* to the start of a 2D array, don’t use the bare name marks. Point at the first element directly with &marks[0][0], and then the whole array behaves like one flat 1D array again.
Mapping [row][col] onto the flat line ourselves
Once we have a flat int *ptr, we might wonder ─ how do we jump to a specific [row][col] without the comfy [][] syntax?
There’s a neat formula. If the array has COLS columns ─
value at [row][col] == *(ptr + row * COLS + col)
The idea is simple ─ to reach a row, skip over all the full rows before it (row * COLS elements), then step col more to reach the exact column.
For our array, COLS is 3. Let’s find marks[1][0] ─
marks[1][0] => *(ptr + 1 * 3 + 0) => *(ptr + 3) => 60
Look back at the memory table ─ flat index 3 holds 60. It matches perfectly 🥳 We just did by hand exactly what the compiler does silently every time we write marks[1][0].
Passing a 2D array to a function
This “pointer to a row” idea has a very practical consequence ─ it changes how we pass 2D arrays to functions.
With a 1D array, the function just needed an int*. But with a 2D array, the function needs to know how many columns each row has ─ otherwise it can’t figure out where one row ends and the next begins. So we may leave the first dimension empty, but we must tell it the column count.
#include <iostream>
using namespace std;
// the row count [] can be empty, but the column count [3] is REQUIRED
void printGrid(int arr[][3], int rows)
{
for (int i = 0; i < rows; i++)
{
for (int j = 0; j < 3; j++)
{
cout << arr[i][j] << " ";
}
cout << endl;
}
}
int main()
{
int marks[2][3] = {
{90, 85, 70},
{60, 95, 88}
};
printGrid(marks, 2);
return 0;
}
The output will be ─
90 85 70 60 95 88
🥳 Why is the column count 3 mandatory? Because, as we just learned, the array name decays into a pointer to a row ─ and to know how big “a row” is, the function must know the number of columns. Without [3], C++ literally cannot calculate where row 1 starts. The number of rows, on the other hand, is not needed for the addressing math, which is why we pass it separately as rows ─ just like we passed size for 1D arrays.
The honest pointer way ─ int (*arr)[3]
Just like a 1D array parameter int arr[] could be written honestly as int *arr, a 2D array parameter has its own honest pointer form. Since the array name decays into a pointer to a row of 3 ints, we can write that type directly in the parameter list.
These two function headers are exactly the same thing to the compiler ─
// the array-looking way void printGrid(int arr[][3], int rows) // the honest pointer way ─ identical to the compiler void printGrid(int (*arr)[3], int rows)
That int (*arr)[3] reads as “arr is a pointer to an array of 3 ints” ─ in other words, a pointer to one row. Let’s use it in a full example.
#include <iostream>
using namespace std;
// arr is a pointer to a row of 3 ints
void printGrid(int (*arr)[3], int rows)
{
for (int i = 0; i < rows; i++)
{
for (int j = 0; j < 3; j++)
{
cout << arr[i][j] << " ";
}
cout << endl;
}
}
int main()
{
int marks[2][3] = {
{90, 85, 70},
{60, 95, 88}
};
printGrid(marks, 2);
return 0;
}
The output will be ─
90 85 70 60 95 88
Same result 🥳 And notice we still wrote arr[i][j] happily inside the function. That’s because arr[i] jumps to row i (remember, adding 1 to a row-pointer skips a whole row), and then [j] picks the column. The column count 3 is still baked into the type, which is exactly why we can never drop it here.
But what if we don’t want to hard-code the column size? ─ int** arr
Here’s a fair question ─ in both forms above, we were forced to write the column count 3 into the parameter. That means the function only works for arrays with exactly 3 columns. What if we want a function that handles any number of columns, passing both dimensions as plain numbers like this?
void printGrid(int **arr, int rows, int cols)
This looks perfect ─ no fixed sizes anywhere. But there is a very important catch that trips up almost every beginner ⚠️
A normal 2D array like int marks[2][3] cannot be passed to an int **arr parameter. They are different types. Remember ─ a real 2D array is one flat block of memory, and its name decays to a pointer to a row (int (*)[3]), not to a pointer to a pointer (int**). So this will not compile ─
int marks[2][3] = { {90, 85, 70}, {60, 95, 88} };
printGrid(marks, 2, 3); // WRONG ─ int(*)[3] is not int**
So when does int** arr actually work? It works when we build the 2D structure ourselves out of pointers ─ an array of int*, where each pointer holds its own row. This is a genuinely different layout ─ instead of one flat block, we have a list of separate rows that the pointers tie together.
#include <iostream>
using namespace std;
// now arr is a pointer to pointers ─ both sizes passed as plain numbers
void printGrid(int **arr, int rows, int cols)
{
for (int i = 0; i < rows; i++)
{
for (int j = 0; j < cols; j++)
{
cout << arr[i][j] << " ";
}
cout << endl;
}
}
int main()
{
// build each row separately
int row0[3] = {90, 85, 70};
int row1[3] = {60, 95, 88};
// an array of pointers, each pointing to a row
int *grid[2] = { row0, row1 };
// grid is an array of int*, so it decays to int** here
printGrid(grid, 2, 3);
return 0;
}
The output will be ─
90 85 70 60 95 88
🥳 Now both rows and cols are flexible plain numbers, exactly as we wanted.
So which approach should we use? Here’s the simple way to remember it ─
- If we have a real, fixed 2D array (
int marks[2][3]), it lives as one flat block, and we pass it withint arr[][3]orint (*arr)[3]. The column count is required. - If we want fully flexible dimensions with
int **arr, we must build the rows ourselves as an array of pointers (or allocate them dynamically). It is a different memory layout, not just a different way of writing the same thing.
This difference between “one flat block” and “an array of pointers” is one of the most important ideas about 2D data in C++. Mixing them up is behind a huge number of beginner compile errors, so it’s well worth pausing on 😄
A common mistake ⚠️
A very frequent beginner error is trying to grab a 2D array with a plain int* using the bare name ─
int marks[2][3] = { {90, 85, 70}, {60, 95, 88} };
int *ptr = marks; // WRONG ─ won't compile!
This fails because marks is a pointer to a row of 3 ints, not a pointer to one int. The types don’t match, and the compiler rightly complains. The fix is the trick we already know ─
int *ptr = &marks[0][0]; // correct ─ now ptr is a plain int*
One small character, big difference. Whenever a 2D array refuses to cooperate with a plain pointer, this mismatch is almost always the reason 😄
So what should we remember? 🤔
Let’s wrap up the key takeaways ─
- The array name, used by itself, is the address of the first element.
- An array decays into a pointer automatically ─ that’s why
int *ptr = marks;needs no&. marks[i]is just a friendly way of writing*(marks + i).- Pointer arithmetic respects the type size, so
ptr + 1jumps to the next element, not the next byte. - Arrays and pointers are close cousins, but not identical ─ an array remembers its full size, a pointer does not.
- When passed to a function, an array becomes a pointer and loses its size, so we must pass the length separately.
- A function array parameter
int arr[]andint *arrare exactly the same thing ─ both are really just pointers. - A 2D array is stored flat in memory (row-major), so it can also be walked with a single pointer. Its name decays into a “pointer to a row”, not a plain
int*─ so use&arr[0][0]for a flat pointer. - A real 2D array is one flat block ─ pass it with
int arr[][3]or the honest pointer formint (*arr)[3], and the column count is always required. - To pass both dimensions as flexible numbers with
int **arr, the data must be an array of pointers (rows built separately), not a normal 2D array ─ the two layouts are different and not interchangeable.
Once these click, we’ll start reading C and C++ code with completely new eyes 👀✨
Congratulations 🥳🥳🥳
We now understand the deep and beautiful relationship between pointers and arrays.
In the next post we will talk about pointers and strings ─ because in C++, the classic C-style string is really just an array of characters, which means it’s secretly a pointer too 😉
Happy Coding 💻 🎵