C++ / REFERENCES, POINTERS, AND NULL
C-style arrays and decay to pointers
Explain why a C-style array's length lives only in its type, predict where it decays to a pointer, and pass arrays around without losing their size.
What you will learn
- Read T[N] as one object: sizeof covers all N elements, and N exists only in the type.
- Spot array-to-pointer decay and name the resulting type: int[5] becomes int*.
- Name the contexts that do not decay: sizeof, unary &, and binding a reference to array.
- Pass a pointer plus a count, or a const T (&)[N] parameter, to keep the length.
Understanding C-style arrays and decay to pointers
A declaration like int scores[5] creates one object large enough for five ints laid out back to back; its type is int[5], and the 5 is part of that type, fixed at compile time. Nothing in memory records the count: there is no header and no hidden length field, so sizeof(scores) is 20 on a machine with 4-byte ints purely because the compiler knows the type. Once the type information is gone, the length is gone with it, and no amount of inspecting the bytes can bring it back.
In nearly every expression that needs a value, an array of type T[N] is implicitly converted to a T* pointing at its first element. That conversion is array-to-pointer decay, and it explains several otherwise odd rules: int* p = scores; compiles with no cast, one array cannot be copied to another with =, and a parameter written int arr[5] is silently rewritten by the compiler to int* with the 5 thrown away. C chose this so a call passed one address instead of copying the whole array, and C++ inherited the behaviour unchanged.
Decay is not universal. Three contexts keep the array as an array: the operand of sizeof, the operand of unary &, and binding to a reference declared T (&)[N]. That is why sizeof(scores)/sizeof(scores[0]) gives 5 in the scope where scores was declared, why &scores has type int (*)[5] rather than int**, and why a template parameter const int (&arr)[N] can deduce N. The mental model to carry: an array is a length-carrying type that collapses into a bare address the moment you hand it to something, so either keep the count beside the pointer or pick a parameter form that refuses to decay.
<iostream>
void takesArray(int arr[5]) { // the 5 is ignored: arr is really int*
std::cout << "inside: sizeof(arr) = " << sizeof(arr) << " bytes\n";
}
int main() {
int scores[5] = {10, 20, 30, 40, 50};
std::cout << "outside: sizeof(scores) = " << sizeof(scores) << " bytes\n";
std::cout << "elements = " << sizeof(scores) / sizeof(scores[0]) << '\n';
int* p = scores; // array-to-pointer decay, no cast needed
std::cout << "scores[2] = " << scores[2] << ", p[2] = " << p[2] << '\n';
std::cout << std::boolalpha << "p == &scores[0]? " << (p == &scores[0]) << '\n';
takesArray(scores);
return 0;
}
An array's element count lives in its type, and array-to-pointer decay discards that type, leaving only the address of the first element.
Worked examples
Passing the length along
Two ways to give a callee the element count: an explicit size argument, or a reference-to-array parameter that deduces it.
<iostream>
<cstddef>
int sum(const int* data, std::size_t n) {
int total = 0;
for (std::size_t i = 0; i < n; ++i) total += data[i];
return total;
}
template <std::size_t N>
int sumArray(const int (&arr)[N]) { // binds to int[N], no decay
return sum(arr, N); // decay happens here, N already captured
}
int main() {
int a[4] = {1, 2, 3, 4};
std::cout << sum(a, 4) << '\n';
std::cout << sumArray(a) << '\n';
std::cout << sum(a + 1, 2) << '\n';
return 0;
}
Example explained
Line 1sum takes const int* and a count because a decayed pointer carries no length, so the caller must supply 4 itself.
Line 2sumArray's parameter const int (&arr)[N] binds to the array object, so no decay occurs and N is deduced as 4.
Line 3Inside sumArray the array decays only when it is handed to sum, and by then the bound has been recorded in N.
Line 4sum(a + 1, 2) shows the flexibility of the pointer form: a + 1 is a decayed pointer to a[1], so the call adds 2 and 3.
int* versus int (*)[3]
Decay yields a pointer to the first element, while unary & on the array yields a pointer to the whole array.
<iostream>
int main() {
int a[3] = {1, 2, 3};
int* p = a; // decay: address of a[0]
int (*pa)[3] = &a; // no decay: address of the whole array
std::cout << "p step: "
<< reinterpret_cast<char*>(p + 1) - reinterpret_cast<char*>(p)
<< " bytes\n";
std::cout << "pa step: "
<< reinterpret_cast<char*>(pa + 1) - reinterpret_cast<char*>(pa)
<< " bytes\n";
std::cout << "(*pa)[1] = " << (*pa)[1] << ", pa[0][2] = " << pa[0][2] << '\n';
return 0;
}
Example explained
Line 1int* p = a; is the decay conversion: p holds the address of a[0], so p + 1 moves one int, 4 bytes.
Line 2int (*pa)[3] = &a; suppresses decay because & is one of the non-decaying contexts, so pa + 1 moves a whole int[3], 12 bytes.
Line 3(*pa)[1] dereferences to the array and then indexes it, giving 2; pa[0][2] reaches the same object through index notation.
Line 4Both pointers hold the same numeric address; only their types differ, and the type is what pointer arithmetic scales by.
Two-dimensional arrays decay to a row pointer
int[2][3] decays to int (*)[3], never to int**, because the rows are arrays rather than stored pointers.
<iostream>
void printRow(const int (*rows)[3], int rowCount) {
for (int r = 0; r < rowCount; ++r) {
for (int c = 0; c < 3; ++c) {
if (c) std::cout << ' ';
std::cout << rows[r][c];
}
std::cout << '\n';
}
}
int main() {
int grid[2][3] = {{1, 2, 3}, {4, 5, 6}};
printRow(grid, 2); // grid decays to int (*)[3]
// int** bad = grid; // error: no such conversion exists
std::cout << "sizeof(grid) = " << sizeof(grid)
<< ", sizeof(grid[0]) = " << sizeof(grid[0]) << '\n';
return 0;
}
Example explained
Line 1grid is a single 24-byte block of six ints, and grid[0] is an int[3] of 12 bytes, so no row pointers exist anywhere in memory.
Line 2printRow's parameter const int (*rows)[3] matches exactly what grid decays to: a pointer to its first row.
Line 3rows[r] advances 12 bytes per row and produces an array, which then decays again for the [c] subscript.
Line 4Assigning grid to an int** is rejected by the compiler; forcing it with a cast would make the code read the first ints as if they were addresses.
Important notes
The byte counts here assume 4-byte int and 8-byte pointers; a 32-bit build prints 4 for sizeof(int*), which is exactly why hard-coding sizes is unsafe.
GCC and Clang warn about sizeof applied to an array-shaped parameter; that warning is the compiler telling you decay already happened. Range-for and std::size work on the array but not on the decayed pointer.
Common mistakes
Writing sizeof(arr)/sizeof(arr[0]) inside a function whose parameter is int arr[]: the parameter is an int*, so on a 64-bit build the result is 2 and the loop silently visits two elements.
Trying to compare with a == b or copy with a = b: the names decay to addresses, so the comparison tests two unrelated pointers and is always false, while the assignment does not compile at all.
Returning a decayed pointer to a local array: the array's storage ends with the function, so the caller reads through a dangling pointer.
Try it yourself
Change, predict, then run
Declare int nums[6] with values of your choosing, write int maxOf(const int* data, std::size_t n) that prints sizeof(data) before scanning, then add a template<std::size_t N> int maxOf(const int (&arr)[N]) that forwards to it. Print maxOf(nums) alongside sizeof(nums) and confirm the two sizes differ.
Open the C++ workspaceCheck your understanding
A function is declared void f(double values[10]) and called with a real 10-element array. Inside f, sizeof(values) is 8 on a 64-bit build. What does that tell you about what the call actually passed?
- The bound 10 was discarded and the parameter's real type is double*, holding the address of values[0], so all ten elements are still reachable through it
- Only the first element was copied into the parameter, so values[1] through values[9] are no longer accessible
- The whole array was copied, but sizeof on any parameter reports the size of an address rather than the object
- The compiler stored the array's length in a hidden field and sizeof is reporting the size of that field
Show answer
An array parameter's bound is discarded and the parameter becomes a pointer to the element type, so sizeof measures a pointer while the ten doubles stay in the caller's storage and values[9] still works. Option 1 is tempting because the reported size shrank, but nothing was copied at all: decay produced an address, not a truncated array.