C++ / FUNCTIONS
Passing C-style arrays to functions without losing size
Pass C-style arrays to functions without losing their length, using pointer plus count, array references, size-deducing templates, or std::span.
What you will learn
- Spot array-to-pointer decay: a parameter written int a[10] is exactly int* a.
- Write int (&a)[10] or a template <std::size_t N> reference so the length survives.
- Compute counts with std::size(arr) at the call site, never inside the callee.
- Take std::span<const int> to accept arrays and vectors with .size() attached.
Understanding Passing C-style arrays to functions without losing size
An array name used as a value yields a pointer to its first element, and the length lives only in the array's type, int[10]. When you declare a parameter of array type, the compiler applies a rewrite rule: the parameter is adjusted to a pointer to the element type. So void f(int arr[10]), void f(int arr[]) and void f(int* arr) all declare the same function, and the 10 is a comment the compiler ignores. That is why sizeof(arr) inside the function measures a pointer, and the familiar sizeof(arr)/sizeof(arr[0]) idiom breaks the moment the array crosses a parameter boundary.
To keep the length you either stop the adjustment or carry the number yourself. A reference parameter stops it: void f(int (&arr)[10]) binds directly to an int[10] with no conversion, so sizeof(arr) is 40 and an int[9] argument is a compile error. Making the bound a template parameter, template <std::size_t N> void f(const int (&arr)[N]), gives the same safety for every length, because deduction reads N off the argument's type and instantiates one function per size. The remaining option, a pointer plus an explicit count, is what C APIs use, but it moves the length from something the compiler checks to something you promise.
The mental model is that length is type information, and each conversion to a pointer discards it permanently; no callee can recover it from the address alone. This is why for (int v : arr) and std::size(arr) compile in the scope that declared the array but not inside a function that took int arr[] — there is no array in there, only a pointer. In C++20, std::span<const int> bundles the address and the count into one object that converts from raw arrays, std::vector and std::array, so a single non-template signature keeps .size() available.
<cstddef>
<iostream>
// Written as int[10], but the parameter is adjusted to int* -- the 10 is ignored.
void decayed(int arr[10]) {
std::cout << "decayed: sizeof = " << sizeof(arr)
<< ", computed count = " << sizeof(arr) / sizeof(arr[0]) << '\n';
}
// A reference to an array of exactly 10 ints: no adjustment, no decay.
void byRef(int (&arr)[10]) {
std::cout << "byRef: sizeof = " << sizeof(arr)
<< ", computed count = " << sizeof(arr) / sizeof(arr[0]) << '\n';
}
// The bound becomes a template argument deduced from the call.
template <std::size_t N>
long long sumAll(const int (&arr)[N]) {
long long total = 0;
for (std::size_t i = 0; i < N; ++i) {
total += arr[i];
}
return total;
}
int main() {
int data[10] = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10};
int small[3] = {100, 200, 300};
std::cout << "main: sizeof = " << sizeof(data)
<< ", computed count = " << sizeof(data) / sizeof(data[0]) << '\n';
decayed(data);
byRef(data);
std::cout << "sum(data) = " << sumAll(data) << '\n';
std::cout << "sum(small) = " << sumAll(small) << '\n';
}
A parameter of array type is silently rewritten as a pointer, so the length must travel separately: as a count, as part of a reference type, or inside a span.
Worked examples
Pointer plus count, computed where the type still exists
Shows that std::size must be called in the array's own scope and that the count parameter is unchecked.
<cstddef>
<iostream>
<iterator>
double average(const int* first, std::size_t count) {
long long total = 0;
for (std::size_t i = 0; i < count; ++i) {
total += first[i];
}
return count == 0 ? 0.0 : static_cast<double>(total) / count;
}
int main() {
int scores[] = {80, 90, 100, 74};
// std::size compiles here because scores still has type int[4].
std::cout << "count = " << std::size(scores) << '\n';
std::cout << "average all = " << average(scores, std::size(scores)) << '\n';
// Nothing verifies the count; the callee has no way to check it.
std::cout << "average 2 = " << average(scores, 2) << '\n';
}
Example explained
Line 1average takes a count because the number of elements cannot be recovered from the pointer first.
Line 2std::size(scores) deduces 4 from the type int[4]; the identical call inside average would not compile.
Line 3The last call passes 2 and gets the average of the first two elements only, proving the count is a promise rather than a check.
Line 4static_cast<double> is required because total / count would be integer division between long long and std::size_t.
std::span carries the length for you
One non-template signature that accepts a raw array, a vector, and a sub-range while keeping size() available (compile with -std=c++20).
<iostream>
<span>
<vector>
// One parameter carries both the address and the length.
void report(std::span<const int> values) {
int best = values[0];
for (int v : values) {
if (v > best) {
best = v;
}
}
std::cout << "size " << values.size() << ", largest " << best << '\n';
}
int main() {
int raw[5] = {3, 17, 4, 9, 11};
std::vector<int> vec{42, 8};
report(raw);
report(vec);
report(std::span<const int>(raw).first(3));
}
Example explained
Line 1std::span<const int> stores a pointer and a length, so values.size() is usable inside the function.
Line 2report(raw) converts int[5] to a span at the call site, where the bound 5 is still part of the type.
Line 3report(vec) compiles because span accepts any contiguous sized range, so arrays and vectors share one signature.
Line 4.first(3) returns a shorter span over the same memory; no elements are copied.
Important notes
The parentheses in int (&arr)[10] are mandatory, since int &arr[10] would mean an array of references and is ill-formed; call such a function as f(data), not f(&data), whose type is int (*)[10].
The 8 and 2 printed by decayed assume 64-bit pointers and 4-byte int; keep -Wall on, because gcc and clang warn when sizeof is applied to an array-typed parameter.
Common mistakes
Computing sizeof(arr)/sizeof(arr[0]) inside a function whose parameter is int arr[]: on a typical 64-bit build that is 8/4 = 2, so the loop touches two elements and silently skips the rest.
Believing void f(int arr[5]) enforces five elements: it accepts an int[2] or any int*, and arr[4] then reads past the end with no diagnostic and undefined behaviour.
Passing sizeof(data) instead of std::size(data) as the count: the callee iterates 40 times over 10 ints and walks off the end of the array.
Try it yourself
Change, predict, then run
Write template <std::size_t N> void rotateLeft(int (&a)[N]) that moves every element one slot left and the first element to the end, call it twice on an int[6], and print the array after each call. Then add a second version taking int a[] and try to write the same loop inside it to see exactly which information is missing.
Open the C++ workspaceCheck your understanding
A function declared void f(int arr[100]) is called with an int data[100], and inside f the expression sizeof(arr)/sizeof(arr[0]) prints 2 on a 64-bit build. What explains this?
- The array is copied into the function, but only the first two elements fit in the parameter slot.
- The bound 100 is enforced only in debug builds and is dropped when optimizations are enabled.
- The parameter was adjusted to int*, so sizeof yields the pointer size (8) divided by sizeof(int) (4).
- sizeof cannot be applied to a parameter name, so the compiler substitutes a default value of 2.
Show answer
The adjustment happens at declaration: int arr[100], int arr[] and int* arr declare the identical function, so nothing about 100 survives and sizeof measures a pointer. The copying option is tempting because 2 looks like a real element count, but no elements are copied at all; only the address is passed, and all 100 elements remain reachable, so arr[57] is still valid.