What Is C-Style Array Decay?

Array decay is an implicit conversion: in most expressions, a C-style array turns into a pointer to its first element. An int[6] becomes an int*, a const char[11] becomes a const char*, and the number in the square brackets is gone.

That last part is the whole lesson. The conversion is cheap and silent, but the length lives in the array's type, and a pointer type has nowhere to store it. Once an array has decayed, nothing in the program knows how many elements are on the other end of that address.

Everything C-style arrays do well, and every way they hurt you, follows from that single trade.

Watching the Conversion Happen

Two claims are worth checking rather than taking on trust: that the converted value really is a pointer to the element type, and that it really points at index 0.

#include <iostream>
#include <type_traits>

int main()
{
    int stageTimes[6]{ 412, 388, 501, 476, 433, 459 };

    auto cursor{ stageTimes }; // stageTimes decays right here

    std::cout << "cursor is an int*:        "
              << (std::is_same_v<decltype(cursor), int*> ? "yes" : "no") << '\n';
    std::cout << "cursor == &stageTimes[0]: "
              << (cursor == &stageTimes[0] ? "yes" : "no") << '\n';

    return 0;
}

Output:

cursor is an int*:        yes
cursor == &stageTimes[0]: yes

auto deduced int*, not int[6], because the array decayed the moment it was evaluated. What comes out is an unremarkable int*, carrying no hidden bookkeeping about where it came from.

A const array decays the same way, into a pointer-to-const: const int[6] gives you a const int*.

Warning
Because arrays decay almost everywhere, it is tempting to conclude that arrays are pointers. They are not. An array object is a run of elements sitting in memory; a pointer object is a single address. Decay is a conversion between two different things, not an identity.

What the Pointer Keeps and What It Drops

Question Asked of the array (int stageTimes[6]) Asked of the decayed pointer (int* cursor)
What is the type? int[6] int*
Is the length part of the type? Yes, 6 No
What does sizeof report? The whole array, 24 bytes The pointer, 8 bytes
Does std::size() work? Yes, returns 6 No, fails to compile
Does [] work? Yes Yes
#include <iostream>
#include <iterator>

int main()
{
    int stageTimes[6]{ 412, 388, 501, 476, 433, 459 };
    int* cursor{ stageTimes };

    std::cout << "sizeof(stageTimes):    " << sizeof(stageTimes) << '\n';
    std::cout << "std::size(stageTimes): " << std::size(stageTimes) << '\n';
    std::cout << "sizeof(cursor):        " << sizeof(cursor) << '\n';

    return 0;
}

Output:

sizeof(stageTimes):    24
std::size(stageTimes): 6
sizeof(cursor):        8
Key Concept
A decayed array pointer does not know how many elements sit behind it. The name "decay" was chosen for that missing count, not for anything happening to the elements themselves.

Where Decay Does Not Happen

Four contexts leave the array intact:

Context Example Result
Operand of sizeof or typeid sizeof(stageTimes) Measures the array, not a pointer
Operand of operator& &stageTimes Type int (*)[6], a pointer to the whole array
Member of a class type being copied a struct holding an array The array member is copied element by element
Bound to a reference int (&stages)[6] The parameter keeps the length

The last two matter most in practice, and the reference case is the one you can act on: a reference parameter keeps the length in the type, so length-aware code still works inside the function.

#include <iostream>
#include <iterator>
#include <type_traits>

void reportStageCount(const int (&stages)[6]) // by reference, so no decay
{
    std::cout << "inside the function: " << std::size(stages) << " stages" << '\n';
}

int main()
{
    const int stageTimes[6]{ 412, 388, 501, 476, 433, 459 };

    std::cout << "&stageTimes is a pointer to an array: "
              << (std::is_same_v<decltype(&stageTimes), const int (*)[6]> ? "yes" : "no") << '\n';

    reportStageCount(stageTimes);

    return 0;
}

Output:

&stageTimes is a pointer to an array: yes
inside the function: 6 stages

Note the cost of that fix: reportStageCount now accepts arrays of exactly six elements and nothing else. Making it work for other lengths requires a function template.

Subscripting Is a Pointer Operation

Since evaluating an array decays it, stageTimes[2] is not an array operation at all. The array decays first, and operator[] is applied to the resulting pointer. Doing the decay yourself and subscripting the pointer gives an identical result.

#include <iostream>

int main()
{
    const int stageTimes[]{ 412, 388, 501, 476, 433, 459 };
    const int* cursor{ stageTimes };

    std::cout << stageTimes[2] << '\n'; // array decays, then the pointer is subscripted
    std::cout << cursor[2] << '\n';     // the pointer is subscripted directly

    return 0;
}

Output:

501
501

This is why subscripting keeps working inside a function that received a decayed array: subscripting never needed the length. The next lesson takes this apart in terms of pointer arithmetic.

Why C Chose This: Passing Arrays to Functions

Decay looks like a design accident until you see the problem it was invented for. C had two requirements that pulled against each other:

  1. Do not copy the array. Copying hundreds of elements on every call is expensive, and C has no references, so pass by reference was not available as an escape hatch.
  2. Accept any length. One function should handle an array of three elements and an array of six. C has no "any length" array syntax, no templates, and no conversion between int[3] and int[6].

Decay satisfies both at once. Passing an array passes an address, so nothing is copied, and every array of the same element type collapses to the same pointer type, so lengths stop being part of the argument type.

#include <iostream>

void showOpeningStage(const int* stages) // receives an address, not six ints
{
    std::cout << "Opening stage: " << stages[0] << " s" << '\n';
}

int main()
{
    const int alpineRally[]{ 412, 388, 501, 476, 433, 459 };
    const int coastRally[]{ 233, 259, 274 };

    showOpeningStage(alpineRally); // six ints, handed over as a single pointer
    showOpeningStage(coastRally);  // three ints, same parameter type

    return 0;
}

Output:

Opening stage: 412 s
Opening stage: 233 s

const int[6] and const int[3] are distinct, incompatible types. Their decayed forms are both const int*, which is why one function swallows both.

Key Concept
The call site reads like pass by value, but what crosses into the function is an address, so C-style arrays are passed by address. The function therefore holds a pointer into the caller's own array and can write through it. Mark the parameter const whenever the function only reads.

Array Syntax or Pointer Syntax for the Parameter

A parameter written const int* stages is honest about being a pointer but says nothing about whether it addresses one value or many. C++ offers a second spelling that reads better:

#include <iostream>

void showOpeningStage(const int stages[]) // a second spelling of const int* stages
{
    std::cout << "Opening stage: " << stages[0] << " s" << '\n';
}

int main()
{
    const int alpineRally[]{ 412, 388, 501, 476, 433, 459 };
    const int coastRally[]{ 233, 259, 274 };
    const int lonelyNumber{ 500 };

    showOpeningStage(alpineRally);
    showOpeningStage(coastRally);
    showOpeningStage(&lonelyNumber); // a single int, and the call still builds

    return 0;
}

Output:

Opening stage: 412 s
Opening stage: 233 s
Opening stage: 500 s

The two spellings are the same parameter. The compiler rewrites const int stages[] to const int*, so no length is needed inside the brackets, and any length you write there is discarded.

Best Practice
A parameter that expects a C-style array should use array syntax (int stages[]) rather than pointer syntax (int* stages). It tells the caller what is expected, even though the compiler treats both identically.

The third call above is the catch. &lonelyNumber is a const int* too, so a pointer to a single value satisfies the parameter perfectly. Reading stages[0] from it happens to be fine; reading stages[3] would run off into memory that was never part of any array. Array syntax also hides the fact that stages has decayed, so it takes discipline to remember that length-dependent code will not work inside the function.

Danger
A decayed array parameter cannot be bounds checked. Nothing in the type distinguishes a six-element array from a three-element array from a single int, so an out-of-range subscript compiles cleanly and produces undefined behavior at run time.

Failure Mode: sizeof Measures the Pointer

The following program is wrong on purpose. It applies sizeof on both sides of a function call and gets two different answers.

#include <iostream>

void reportRally(const int stages[])
{
    std::cout << "bytes:       " << sizeof(stages) << '\n';
    std::cout << "stage count: " << sizeof(stages) / sizeof(*stages) << '\n';
}

int main()
{
    const int alpineRally[]{ 412, 388, 501, 476, 433, 459 };

    std::cout << "bytes:       " << sizeof(alpineRally) << '\n';
    std::cout << "stage count: " << sizeof(alpineRally) / sizeof(*alpineRally) << '\n';

    reportRally(alpineRally);

    return 0;
}

The compiler is suspicious:

s.cpp: In function 'void reportRally(const int*)':
s.cpp:5:44: warning: 'sizeof' on array function parameter 'stages' will return size of 'const int*' [-Wsizeof-array-argument]
    5 |     std::cout << "bytes:       " << sizeof(stages) << '\n';
      |                                           ~^~~~~~~
s.cpp:3:28: note: declared here
    3 | void reportRally(const int stages[])
      |                  ~~~~~~~~~~^~~~~~~~

It compiles anyway, and prints:

bytes:       24
stage count: 6
bytes:       8
stage count: 2

In main() the array is intact, so sizeof measures 24 bytes and the old sizeof(a) / sizeof(*a) length trick reports 6. Inside reportRally() the parameter is a pointer, sizeof measures 8 bytes on a 64-bit build, and the same trick confidently reports 2 stages. A loop written against that number would stop four elements early.

Warning
The sizeof(a) / sizeof(*a) length idiom is only correct where the actual array object is in scope. Anywhere the array may have decayed, it silently computes the wrong length instead of failing.

Failure Mode: std::size Refuses to Compile

std::size() (C++17) and std::ssize() (C++20) fix that silence. They deduce the length from the array type, so a pointer gives them nothing to deduce from. This program does not build:

#include <iostream>
#include <iterator>

void reportStageCount(const int stages[])
{
    std::cout << std::size(stages) << " stages" << '\n'; // stages is a pointer
}

int main()
{
    const int alpineRally[]{ 412, 388, 501, 476, 433, 459 };

    std::cout << std::size(alpineRally) << " stages" << '\n';
    reportStageCount(alpineRally);

    return 0;
}

Trimmed diagnostic:

s.cpp: In function 'void reportStageCount(const int*)':
s.cpp:6:27: error: no matching function for call to 'size(const int*&)'
    6 |     std::cout << std::size(stages) << " stages" << '\n'; // stages is a pointer
      |                  ~~~~~~~~~^~~~~~~~
  • there are 2 candidates
    • candidate 2: 'template<class _Tp, long unsigned int _Nm> constexpr std::size_t std::size(const _Tp (&)[_Nm])'
      • template argument deduction/substitution failed:
        •   mismatched types 'const _Tp [_Nm]' and 'const int*'

A compile error is the good outcome here. The overload that handles arrays binds a reference to _Tp (&)[_Nm] and cannot deduce _Nm from a pointer, so the mistake is caught before the program ever runs.

This also explains a subtler cost of decay: refactoring gets risky. Lift a few lines out of main() into a helper function and the array they operate on becomes a pointer. If those lines used std::size(), the build breaks and you fix it. If they used sizeof, the build succeeds and the behavior quietly changes.

Restoring the Length by Hand

Two techniques predate the standard containers. Both work, and both leave the burden with the programmer.

Pass the count alongside the array. The function gets back the number it needs and can check the index before using it.

#include <iostream>
#include <iterator>

void showStage(const int stages[], int stageCount, int wanted)
{
    if (wanted < 0 || wanted >= stageCount)
    {
        std::cout << "No stage " << wanted << " on this rally" << '\n';
        return;
    }

    std::cout << "Stage " << wanted << ": " << stages[wanted] << " s" << '\n';
}

int main()
{
    constexpr int alpineRally[]{ 412, 388, 501, 476, 433, 459 };
    constexpr int coastRally[]{ 233, 259, 274 };

    showStage(alpineRally, static_cast<int>(std::size(alpineRally)), 3);
    showStage(coastRally, static_cast<int>(std::size(coastRally)), 3);

    return 0;
}

Output:

Stage 3: 476 s
No stage 3 on this rally

Terminate the data with an impossible value. A stage time is never negative, so -1 can mean "the data stops here", and any function can walk forward until it meets that value.

#include <iostream>

void listStages(const int stages[]) // the caller must end the array with -1
{
    for (int i{ 0 }; stages[i] != -1; ++i)
    {
        if (i > 0)
            std::cout << ", ";

        std::cout << stages[i];
    }

    std::cout << '\n';
}

int main()
{
    const int coastRally[]{ 233, 259, 274, -1 };

    listStages(coastRally);

    return 0;
}

Output:

233, 259, 274
Key Concept
C-style strings are C-style arrays that use exactly this technique. The null terminator '\0' marks the end, which is why a decayed const char* can still be printed or measured.

Where each one leaks:

Technique What can go wrong
Count parameter The caller can pass a count that does not match the array, and nothing detects it
Count parameter std::size() returns std::size_t, so mixing it with int invites sign conversion problems
Count parameter A run-time check only fires on paths your tests actually take, and function parameters cannot be constexpr, so a compile-time check is not available
Count parameter Implicit calls, such as an operator taking the array as an operand, have no place to put the extra argument
Terminator value If the terminator is missing, traversal walks off the end into undefined behavior
Terminator value Every function must handle the terminator specially, for example by not printing it
Terminator value The stored length and the meaningful length differ, so the terminator can get processed as if it were data
Terminator value It requires a value that can never be legitimate data, and often no such value exists

What to Reach for Instead

Between the unusual passing semantics and the lost length, C-style arrays have fallen out of favour for general use.

Best Practice
Reach for a standard library type before a C-style array:
  • std::string_view when text is only read, which covers string literal constants and string parameters
  • std::string when text is owned or modified
  • std::array for a constexpr array that is not a global
  • std::vector for any array that is not constexpr

std::array carries its length in the type and never decays, so the length is still there inside the function:

#include <array>
#include <cstddef>
#include <iostream>

template <std::size_t N>
void describeRally(const std::array<int, N>& stages)
{
    std::cout << stages.size() << " stages, opening at " << stages[0] << " s" << '\n';
}

int main()
{
    constexpr std::array alpineRally{ 412, 388, 501, 476, 433, 459 };
    constexpr std::array coastRally{ 233, 259, 274 };

    describeRally(alpineRally);
    describeRally(coastRally);

    return 0;
}

Output:

6 stages, opening at 412 s
3 stages, opening at 233 s

You could build the same protection on top of C-style arrays by taking them by reference and adding a template for the length, but at that point you have reinvented std::array with worse syntax.

Where C-Style Arrays Still Earn Their Place

Two situations still favour them:

  • Constexpr global or constexpr static local data. Such an array is reachable from anywhere, so it never has to be passed and never decays. The declaration syntax is lighter than std::array, and indexing it does not drag in the sign conversion issues that the standard containers bring.
  • Parameters that take non-constexpr C-style strings directly. Converting one to std::string_view means walking it to find its length. If the function is performance sensitive and is going to traverse the string anyway, or if it only turns around and calls other functions that want const char*, the conversion buys nothing.

Summary

Array decay: In most expressions a C-style array implicitly converts to a pointer to its first element, so int[6] becomes int* and the length disappears from the type.

The pointer is ordinary: It equals &array[0] and behaves like any other pointer. An array is a run of elements; a pointer is one address. They are not the same thing.

Four exceptions: No decay as the operand of sizeof or typeid, as the operand of operator&, as a member of a class type, or when bound to a reference.

Subscripting: stageTimes[2] decays the array first and applies operator[] to the pointer, which is why subscripting still works after decay.

Passed by address: A C-style array argument looks like pass by value but is pass by address, so nothing is copied and the function can modify the caller's elements. Use const when it should not.

Different lengths, same type: int[3] and int[6] are incompatible types that both decay to int*, which is how one function accepts either.

Parameter syntax: Prefer int stages[] over int* stages for readability. They compile to the same thing, and any length written in the brackets is ignored.

sizeof trap: sizeof gives the array size on an array and the pointer size on a decayed array, so the sizeof(a) / sizeof(*a) length idiom silently returns the wrong count after decay.

std::size protection: std::size() and std::ssize() refuse to compile when handed a pointer, turning a silent wrong answer into a build error.

Workarounds: Pass the count as a second argument, or terminate the data with an impossible value the way C-style strings use '\0'. Counts can be mismatched; terminators can be missing.

Modern default: Prefer std::string_view, std::string, std::array, and std::vector. Keep C-style arrays for constexpr global data and for interfaces that handle C-style strings directly.