The C++ Textbook

Part 2 · Memory and objects

Arrays, and why they decay

The oldest container in the language, and the surprising rule that makes it lose its size.

By the end of this chapter you can

  • Explain array-to-pointer decay and when it happens
  • Choose std::array or std::vector over a raw array
  • Pass an array to a function without losing its length

A built-in array is a fixed number of objects laid out contiguously. It is the most efficient container in the language and the most dangerous, because of one rule inherited from C: in almost every context, an array silently becomes a pointer to its first element, and its length is lost.

That rule is called decay, and understanding exactly when it fires is what makes raw arrays predictable.

An array knows its size — briefly

sizeof knows; a function does not
#include <iostream>

void takes_an_array(int values[5]) {
    // Despite appearances, `values` here is an int*, not an array of 5.
    std::cout << "  inside the function, sizeof = " << sizeof(values) << '\n';
}

int main() {
    int values[5] = {1, 2, 3, 4, 5};

    std::cout << "in main, sizeof(values) = " << sizeof(values) << " bytes\n";
    std::cout << "element count = " << sizeof(values) / sizeof(values[0]) << '\n';

    takes_an_array(values);
}

In main, sizeof(values) is 20: the array’s real size. Inside the function it is 8 — the size of a pointer. The [5] in the parameter list is not a constraint the compiler checks; it is decoration. void f(int v[5]), void f(int v[]), and void f(int* v) declare the same function, and you may pass an array of any length to any of them.

When decay happens

An array converts to a pointer to its first element whenever it is used as a value: passed to a function, assigned to a pointer, returned, or used in arithmetic.

It does not decay in four places:

The four places an array stays an array
#include <iostream>
#include <typeinfo>

int main() {
    int values[5] = {1, 2, 3, 4, 5};

    // 1. sizeof
    std::cout << "sizeof:   " << sizeof(values) << '\n';

    // 2. Taking its address: &values is int(*)[5], not int**
    int (*array_ptr)[5] = &values;
    std::cout << "via &:    " << (*array_ptr)[2] << '\n';

    // 3. Binding to a reference to array
    int (&ref)[5] = values;
    std::cout << "via ref:  " << sizeof(ref) << " (still 20)\n";

    // 4. In a range-based for, which is why this one knows when to stop
    for (int v : values) std::cout << v << ' ';
    std::cout << '\n';
}

Point 4 is the practical one: for (int v : values) works on a raw array and stops at the right place, because the array has not decayed. Change it to a pointer and it will not compile — which is the language protecting you.

Point 3 is the escape hatch. A reference to an array preserves the length, and combined with a template it gives you a function that knows how long its argument is:

A function that keeps the length
#include <cstddef>
#include <iostream>

// N is deduced from the argument's type.
template <std::size_t N>
int sum(const int (&values)[N]) {
    int total = 0;
    for (int v : values) total += v;
    return total;
}

int main() {
    int three[] = {1, 2, 3};
    int five[]  = {1, 2, 3, 4, 5};

    std::cout << sum(three) << ' ' << sum(five) << '\n';
    // sum(pointer) would not compile — there is no length to deduce.
}

That is essentially how std::size, std::begin, and std::end work on raw arrays. Use them instead of the sizeof idiom:

std::size, which cannot be fooled
#include <iostream>
#include <iterator>

int main() {
    int values[5] = {1, 2, 3, 4, 5};

    std::cout << "std::size:  " << std::size(values) << '\n';
    std::cout << "sizeof idiom: " << sizeof(values) / sizeof(values[0]) << '\n';

    int* decayed = values;
    // std::size(decayed) does not compile — the mistake is caught, not computed.
    std::cout << "sizeof idiom on a decayed pointer: "
              << sizeof(decayed) / sizeof(decayed[0]) << "  <- wrong, silently\n";
}

The last line prints 2. The sizeof idiom cannot tell you it has failed; std::size refuses to compile, which is the outcome you want.

No bounds checking, ever

Writing past the end
#include <iostream>

int main() {
    int values[3] = {1, 2, 3};
    int canary = 999;

    std::cout << "canary before: " << canary << '\n';
    values[3] = 42;                 // one past the end — undefined behaviour
    std::cout << "canary after:  " << canary << '\n';
}

Nothing in the language checks the index — not at compile time, not at run time. AddressSanitizer catches it here because this book compiles with it on, and it names the exact allocation. Without it, the write lands on whatever happens to be adjacent, and the program continues with corrupted state.

This is the single most common source of security vulnerabilities in C and C++ code, and it is the reason the rest of this book prefers containers.

What to use instead

std::array — a fixed size that behaves

std::array knows itself everywhere
#include <array>
#include <iostream>

void takes_array(const std::array<int, 5>& values) {
    // The size is part of the type, so it survives the call.
    std::cout << "  inside: size " << values.size() << ", first " << values.front() << '\n';
}

int main() {
    std::array<int, 5> values{1, 2, 3, 4, 5};

    std::cout << "size():   " << values.size() << '\n';
    takes_array(values);

    std::cout << "at(2):    " << values.at(2) << '\n';

    for (int v : values) std::cout << v << ' ';
    std::cout << '\n';
}

std::array is a raw array with the sharp edges removed: same layout, same zero overhead, no decay, and it can be copied, returned, and compared. There is no reason to prefer a raw array over it in new code.

at() is bounds-checked and throws std::out_of_range; operator[] is not checked, exactly like a raw array. Use at() when the index comes from somewhere you do not control.

at() turns a silent corruption into an exception
#include <array>
#include <iostream>
#include <stdexcept>

int main() {
    std::array<int, 3> values{1, 2, 3};

    try {
        std::cout << values.at(7) << '\n';
    } catch (const std::out_of_range& e) {
        std::cout << "caught: " << e.what() << '\n';
    }
}

std::vector — when the size is not known until run time

A size decided at run time
#include <iostream>
#include <vector>

int main() {
    int n = 4;                       // imagine this came from input
    std::vector<int> values(n, 7);   // n elements, each 7

    values.push_back(8);             // and it can grow

    std::cout << "size " << values.size() << ": ";
    for (int v : values) std::cout << v << ' ';
    std::cout << '\n';
}

The comparison

raw array std::array<T, N> std::vector<T>
Size fixed at compile time compile time run time
Knows its own size only before decay always always
Decays to a pointer yes no no
Copyable, returnable no yes yes
Bounds-checked option no at() at()
Storage wherever declared wherever declared heap
Overhead vs raw array none one allocation

Where raw arrays still appear

Two places, both worth recognising.

String literals. "hello" is a const char[6] — five characters and a terminating '\0' — which decays to const char* almost immediately.

A literal is an array
#include <iostream>

int main() {
    const char literal[] = "hello";

    std::cout << "sizeof literal: " << sizeof(literal) << " (5 chars + '\\0')\n";

    const char* decayed = literal;
    std::cout << "sizeof decayed: " << sizeof(decayed) << " (a pointer)\n";
    std::cout << "text: " << decayed << '\n';
}

C interfaces. Any C library you call takes pointer-and-length pairs, because that is all C has. std::vector::data() and std::array::data() hand you the pointer when you need to cross that boundary.

Check yourself

Practice