Ready to practice?
Sign up to access interactive coding exercises and track your progress.
Array-to-Pointer Conversion
Understand how arrays convert to pointers when passed to functions.
C-style array decay
The C-style array passing challenge
The designers of the C language faced a problem. Consider this simple program:
#include <iostream>
void display(int value)
{
std::cout << value;
}
int main()
{
int num { 42 };
display(num);
return 0;
}
When display(num) is called, the value of argument num (42) is copied into parameter value. Within the function body, the value of value (42) is printed to the console. Because num is cheap to copy, there's no problem here.
Now consider a similar program using a 500-element C-style int array instead of a single int:
#include <iostream>
void displayFirst(int data[500])
{
std::cout << data[0]; // print the value of the first element
}
int main()
{
int measurements[500] { 100 }; // define an array with 500 elements, measurements[0] is initialized to 100
displayFirst(measurements);
return 0;
}
This program compiles and prints the expected value (100) to the console.
While this code looks similar to the previous example, it works differently than you might expect. This is due to the solution that C designers created to solve two major challenges.
First, copying a 500-element array every time a function is called is expensive (even more so if elements are expensive to copy), so we want to avoid that. But how? C doesn't have references, so using pass by reference to avoid making copies wasn't an option.
Second, we want to write a single function that can accept array arguments of different lengths. Ideally, our displayFirst() function should work with arrays of any length (since element 0 is guaranteed to exist). We don't want separate functions for every possible array length. But how? C has no syntax to specify "any length" arrays, doesn't support templates, and arrays of one length can't be converted to arrays of another length (presumably because doing so would involve making an expensive copy).
The C language designers devised a clever solution (inherited by C++ for compatibility) that solves both issues:
#include <iostream>
void displayFirst(int data[500]) // doesn't make a copy
{
std::cout << data[0]; // print the value of the first element
}
int main()
{
int measurements[12] { 100 }; // define an array with 12 elements
displayFirst(measurements); // somehow works!
return 0;
}
Somehow, this passes a 12-element array to a function expecting a 500-element array, without any copies being made. In this lesson, we'll explore how this works.
We'll also examine why the solution C designers picked is dangerous and not well suited for modern C++.
But first, we need to cover two subtopics.
Array to pointer conversions (array decay)
In most cases, when a C-style array is used in an expression, the array will be implicitly converted into a pointer to the element type, initialized with the address of the first element (index 0). This is called array decay (or just decay).
You can see this in action:
#include <iomanip> // for std::boolalpha
#include <iostream>
int main()
{
int readings[5]{ 10, 20, 30, 40, 50 }; // our array has elements of type int
// First, let's prove that readings decays into an int* pointer
auto pointer{ readings }; // evaluation causes readings to decay, type deduction should deduce type int*
std::cout << std::boolalpha << (typeid(pointer) == typeid(int*)) << '\n'; // Prints true if the type of pointer is int*
// Now let's prove that the pointer holds the address of the first element of the array
std::cout << std::boolalpha << (&readings[0] == pointer) << '\n';
return 0;
}
When tested, this printed:
true
true
There's nothing special about the pointer that an array decays into. It's a normal pointer holding the address of the first element.
Similarly, a const array (e.g. const int data[5]) decays into a pointer-to-const (const int*).
Tip: In C++, there are a few common cases where a C-style array doesn't decay:
- When used as an argument to
sizeof()ortypeid(). - When taking the address of the array using
operator&. - When passed as a member of a class type.
- When passed by reference.
Because C-style arrays decay into a pointer in most cases, it's a common misconception to believe arrays are pointers. This is not true. An array object is a sequence of elements, whereas a pointer object just holds an address.
The type information of an array and a decayed array is different. In the example above, the array readings has type int[5], whereas the decayed array has type int*. Notably, the array type int[5] contains length information, whereas the decayed array pointer type int* does not.
Core Understanding: A decayed array pointer does not know how long the array it is pointing to is. The term "decay" indicates this loss of length type information.
Subscripting a C-style array actually applies operator[] to the decayed pointer
Because a C-style array decays to a pointer when evaluated, when a C-style array is subscripted, the subscript is actually operating on the decayed array pointer:
#include <iostream>
int main()
{
const int readings[] { 10, 20, 30, 40, 50 };
std::cout << readings[3]; // subscript decayed array to get element 3, prints 40
return 0;
}
We can also use operator[] directly on a pointer. If that pointer holds the address of the first element, the result will be identical:
#include <iostream>
int main()
{
const int readings[] { 10, 20, 30, 40, 50 };
const int* pointer{ readings }; // readings decays into a pointer
std::cout << pointer[3]; // subscript pointer to get element 3, prints 40
return 0;
}
We'll see where this is convenient in a moment, and take a deeper look at how this actually works in the next lesson.
Array decay solves our C-style array passing issue
Array decay solves both challenges we encountered at the top of the lesson.
When passing a C-style array as an argument, the array decays into a pointer, and the pointer holding the address of the first element is what gets passed to the function. So although it looks like we're passing the C-style array by value, we're actually passing it by address! This is how making a copy of the C-style array argument is avoided.
Core Understanding: C-style arrays are passed by address, even when it looks like they are passed by value.
Now consider two different arrays of the same element type but different lengths (e.g. int[8] and int[12]). These are distinct types, incompatible with each other. However, they will both decay into the same pointer type (e.g. int*). Their decayed versions are interchangeable! Dropping the length information from the type allows us to pass arrays of different lengths without a type mismatch.
Core Understanding: Two C-style arrays with the same element type but different lengths will decay into the same pointer type.
In the following example, we'll illustrate two things:
- That we can pass arrays of different lengths to a single function (because both decay to the same pointer type).
- That our function parameter receiving the array can be a (const) pointer of the array's element type.
#include <iostream>
void displayFirst(const int* data) // pass by const address
{
std::cout << data[0];
}
int main()
{
const int fibonacci[] { 0, 1, 1, 2, 3, 5 };
const int powers[] { 1, 2, 4, 8, 16, 32, 64, 128, 256 };
displayFirst(fibonacci); // fibonacci decays to a const int* pointer
displayFirst(powers); // powers decays to a const int* pointer
return 0;
}
This example works fine and prints:
01
Within main(), when we call displayFirst(fibonacci), the fibonacci array decays from an array of type const int[6] to a pointer of type const int* holding the address of the first element of fibonacci. Similarly, when we call displayFirst(powers), powers decays from an array of type const int[9] to a pointer of type const int* holding the address of the first element of powers. These pointers of type const int* are what are actually passed to the function as an argument.
Since we're passing pointers of type const int*, our displayFirst() function needs a parameter of the same pointer type (const int*).
Within this function, we're subscripting the pointer to access the selected array element.
Because a C-style array is passed by address, the function has direct access to the array passed in (not a copy) and can modify its elements. For this reason, it's good practice to make your function parameter const if your function doesn't intend to modify the array elements.
C-style array function parameter syntax
One problem with declaring the function parameter as int* data is that it's not obvious that data is supposed to be a pointer to an array of values rather than a pointer to a single integer. For this reason, when passing a C-style array, it's preferable to use the alternate declaration form int data[]:
#include <iostream>
void displayFirst(const int data[]) // treated the same as const int*
{
std::cout << data[0];
}
int main()
{
const int fibonacci[] { 0, 1, 1, 2, 3, 5 };
const int powers[] { 1, 2, 4, 8, 16, 32, 64, 128, 256 };
displayFirst(fibonacci); // fibonacci decays to a pointer
displayFirst(powers); // powers decays to a pointer
return 0;
}
This program behaves identically to the prior one, as the compiler interprets function parameter const int data[] the same as const int*. However, this has the advantage of communicating to the caller that data is expected to be a decayed C-style array, not a pointer to a single value. Note that no length information is required between the square brackets (since it's not used anyway). If a length is provided, it will be ignored.
A function parameter expecting a C-style array type should use the array syntax (e.g. `int data[]`) rather than pointer syntax (e.g. `int *data`).
The downside of using this syntax is that it makes it less obvious that data has decayed (whereas it's quite clear with pointer syntax), so you'll need to take extra care not to do anything that doesn't work as expected with a decayed array.
The problems with array decay
Although array decay was a clever solution to ensure C-style arrays of different lengths could be passed to a function without making expensive copies, the loss of array length information makes it easy for several types of mistakes to be made.
First, sizeof() will return different values for arrays and decayed arrays:
#include <iostream>
void displaySize(int data[])
{
std::cout << sizeof(data) << '\n'; // prints 4 or 8 (depending on pointer size)
}
int main()
{
int measurements[]{ 100, 200, 300 };
std::cout << sizeof(measurements) << '\n'; // prints 12 (assuming 4 byte ints)
displaySize(measurements);
return 0;
}
This means using sizeof() on a C-style array is potentially dangerous, as you must ensure you're using it only when you can access the actual array object, not the decayed array or pointer.
In the prior lesson, we mentioned that sizeof(data)/sizeof(*data) was historically used as a hack to get the size of a C-style array. This hack is dangerous because if data has decayed, sizeof(data) will return the size of a pointer rather than the size of the array, producing the wrong array length as a result, likely causing the program to malfunction.
Fortunately, C++17's better replacement std::size() (and C++20's std::ssize()) will fail to compile if passed a pointer value:
#include <iostream>
#include <iterator>
int displayLength(int data[])
{
std::cout << std::size(data) << '\n'; // compile error: std::size() won't work on a pointer
}
int main()
{
int measurements[]{ 100, 200, 300 };
std::cout << std::size(measurements) << '\n'; // prints 3
displayLength(measurements);
return 0;
}
Second, and perhaps most importantly, array decay can make refactoring (breaking long functions into shorter, more modular functions) difficult. Code that works as expected with a non-decayed array may not compile (or worse, may silently malfunction) when the same code is using a decayed array.
Third, not having length information creates several programmatic challenges. Without length information, it's difficult to sanity check the length of the array. Users can easily pass in arrays that are shorter than expected (or even pointers to a single value), which will then cause undefined behavior when they are subscripted with an invalid index.
#include <iostream>
void displayThird(int data[])
{
// How do we ensure that data has at least four elements?
std::cout << data[3] << '\n';
}
int main()
{
int a[]{ 100, 200, 300, 400 };
displayThird(a); // ok
int b[]{ 500, 600 };
displayThird(b); // compiles but produces undefined behavior
int c{ 800 };
displayThird(&c); // compiles but produces undefined behavior
return 0;
}
Not having the array length also creates challenges when traversing the array - how do we know when we've reached the end?
There are solutions to these issues, but these solutions add both complexity and fragility to a program.
Working around array length issues
Historically, programmers have worked around the lack of array length information via one of two methods.
First, we can pass in both the array and the array length as separate arguments:
#include <cassert>
#include <iostream>
#include <iterator>
void displayThird(const int data[], int length)
{
assert(length > 3 && "displayThird: Array too short"); // can't static_assert on length
std::cout << data[3] << '\n';
}
int main()
{
constexpr int a[]{ 100, 200, 300, 400 };
displayThird(a, static_cast<int>(std::size(a))); // ok
constexpr int b[]{ 500, 600 };
displayThird(b, static_cast<int>(std::size(b))); // will trigger assert
return 0;
}
However, this still has several issues:
- The caller needs to ensure the array and the array length are paired - if the wrong value for length is passed in, the function will still malfunction.
- There are potential sign conversion issues if you're using
std::size()or a function that returns length asstd::size_t. - Runtime asserts only trigger when encountered at runtime. If our testing doesn't cover all calls to the function, there's a risk of shipping a program that will assert when customers do something we didn't explicitly test for. In modern C++, we'd want to use
static_assertfor compile-time validation of constexpr array length, but there's no easy way to do this (as function parameters can't be constexpr, even in constexpr or consteval functions!). - This method only works if we're making an explicit function call. If the function call is implicit (e.g. we're calling an operator with the array as an operand), then there's no opportunity to pass in the length.
Second, if there's an element value that's not semantically valid (e.g. a temperature reading of -999), we can mark the end of the array using an element of that value. That way, the length of the array can be calculated by counting elements between the start and this terminating element. The array can also be traversed by iterating from the start until hitting the terminating element. The nice thing about this method is that it works even with implicit function calls.
C-style strings (which are C-style arrays) use a null-terminator to mark the end of the string, so they can be traversed even if they have decayed.
But this method also has several issues:
- If the terminating element doesn't exist, traversal will walk right off the end of the array, causing undefined behavior.
- Functions that traverse the array need special handling for the terminating element (e.g. a C-style string print function needs to know not to print the terminating element).
- There's a mismatch between the actual array length and the number of semantically valid elements. If the wrong length is used, the semantically invalid terminating element may be "processed".
- This approach only works if a semantically invalid value exists, which is often not the case.
C-style arrays should be avoided in most cases
Because of the non-standard passing semantics (pass by address is used instead of pass by value) and risks associated with decayed arrays losing their length information, C-style arrays have generally fallen out of favor. We recommend avoiding them as much as possible.
Avoid C-style arrays whenever practical: - Prefer `std::string_view` for read-only strings (string literal symbolic constants and string parameters) - Prefer `std::string` for modifiable strings - Prefer `std::array` for non-global constexpr arrays - Prefer `std::vector` for non-constexpr arrays
It is okay to use C-style arrays for global constexpr arrays.
In C++, arrays can be passed by reference, in which case the array argument won't decay when passed to a function (but the reference to the array will still decay when evaluated). However, it's easy to forget to apply this consistently, and one missed reference will lead to decaying arguments. Also, array reference parameters must have a fixed length, meaning the function can only handle arrays of one particular length. If we want a function that can handle arrays of different lengths, we also have to use function templates. But if you're going to do both of those things to "fix" C-style arrays, then you might as well just use std::array!
So when are C-style arrays used in modern C++?
In modern C++, C-style arrays are typically used in two cases:
- To store constexpr global (or constexpr static local) program data. Because such arrays can be accessed directly from anywhere in the program, we don't need to pass the array, which avoids decay-related issues. The syntax for defining C-style arrays can be a little less wonky than
std::array. More importantly, indexing such arrays does not have sign conversion issues like the standard library container classes do. - As parameters to functions or classes that want to handle non-constexpr C-style string arguments directly (rather than requiring a conversion to
std::string_view). There are two possible reasons for this: First, converting a non-constexpr C-style string to astd::string_viewrequires traversing the C-style string to determine its length. If the function is in a performance critical section and the length isn't needed (because the function is going to traverse the string anyway) then avoiding the conversion may be useful. Second, if the function (or class) calls other functions that expect C-style strings, converting to astd::string_viewjust to convert back may be suboptimal (unless you have other reasons for wanting astd::string_view).
Summary
Array decay: When a C-style array is used in an expression, it implicitly converts to a pointer to the first element. This loses the array's length information from the type, solving C's challenge of passing arrays without expensive copies.
Pass by address, not value: Although it looks like C-style arrays are passed by value, they're actually passed by address after decaying to pointers. This avoids copying large arrays but loses length information.
Type incompatibility becomes compatibility: Arrays of different lengths (like int[8] and int[12]) are distinct types, but both decay to int*, making them interchangeable as function arguments.
Subscripting decayed arrays: When subscripting a C-style array, you're actually subscripting the decayed pointer. arr[3] is equivalent to *((arr) + (3)), which is pointer arithmetic with dereferencing.
sizeof() problems: sizeof returns the array size on the actual array but the pointer size on a decayed array. This makes the sizeof(arr)/sizeof(arr[0]) hack dangerous with decayed arrays.
Function parameter syntax: Use array syntax (int data[]) rather than pointer syntax (int* data) for parameters expecting decayed arrays. This communicates intent better, though both are treated identically by the compiler.
Length workarounds: Pass length as a separate parameter, or use a terminating value (like null-terminators in C-style strings). Both approaches have drawbacks: length parameters can mismatch, and terminators require valid sentinel values.
Why avoid C-style arrays: Non-standard passing semantics, loss of length information, difficult refactoring, and inability to validate array length make C-style arrays error-prone and dangerous.
Modern C++ usage: Use C-style arrays only for constexpr global/static data (avoiding decay issues) or performance-critical C-style string parameters. Otherwise, prefer std::array, std::vector, std::string, or std::string_view.
Array decay was clever for C's constraints but creates more problems than it solves in modern C++ where better alternatives exist.
Array-to-Pointer Conversion - Quiz
Test your understanding of the lesson.
Practice Exercises
Understanding Array Decay in Functions
Create a program that demonstrates how C-style arrays decay to pointers when passed to functions. The exercise shows why passing array length separately is necessary and uses proper array parameter syntax.
Lesson Discussion
Share your thoughts and questions
No comments yet. Be the first to share your thoughts!