Skip to content
C++

std::array and std::span

Raw C-style arrays — int data[10]; — have been part of C++ since its earliest days, but they come with a long list of problems: they decay to pointers when passed to functions (losing their size), they cannot be compared or assigned, and they give no indication when you read or write out of bounds. std::array solves all of these at zero runtime cost, while std::span (added in C++20) provides a safe way to pass any contiguous sequence — raw array, std::array, or std::vector — to a function without copying it and without losing size information.

The problems with raw C-style arrays

Before looking at the solutions, it helps to understand exactly what goes wrong with a plain array. The problems are not obvious until you try to pass the array to a function or compare two arrays, at which point things quietly stop working the way you expect.

// Raw array problems:

// 1. Size is lost when passed to a function — "decay to pointer"
void process(int* arr) {   // arr is just a pointer now; size is gone
    // sizeof(arr) == sizeof(pointer), NOT the array's size
}
int data[10] {};
process(data);   // silently becomes process(&data[0])

// 2. Cannot compare two arrays with == — compares pointer addresses, not values
int a[] {1, 2, 3};
int b[] {1, 2, 3};
if (a == b) { }   // compares addresses — always false!

// 3. Cannot assign one array to another
int c[3];
c = a;   // ERROR: arrays are not assignable

// 4. No bounds checking — out-of-bounds access is undefined behavior
data[15] = 42;   // silently corrupts memory

These are not edge cases — every one of them is a common mistake that compilers historically accepted without a warning. The standard library alternatives below eliminate all of them.

std::array<T, N> — the safe fixed-size array

std::array<T, N> is a class template in the <array> header that wraps a fixed-size array of N elements of type T. N must be a compile-time constant — the size is baked in at compile time, just like a raw array — but the resulting object behaves like a proper value type: it knows its size, it can be compared, it can be assigned, and it can be stored inside other containers like std::vector. Because the data lives inside the object itself (on the stack, not the heap), there is no dynamic allocation and no runtime overhead compared to a raw array.

#include <array>

// Explicit type and size:
std::array<double, 4> values {1.0, 2.0, 3.0, 4.0};

// Zero-initialize all elements with empty braces:
std::array<int, 100> data {};    // all 100 ints are 0

// C++17 CTAD (template argument deduction) — type and size inferred:
std::array inferred {1, 2, 3, 4, 5};   // deduced: std::array<int, 5>
// Note: all values in the initializer list must be the same type for CTAD to work

// Unlike raw arrays, std::array without an initializer contains garbage:
std::array<int, 5> uninitialized;   // elements are indeterminate — add {} to be safe
std::array<int, 5> safe {};         // all zeros

The most important thing to remember about initialization: unlike std::vector and most other standard containers, which always initialize their elements to zero, std::array is deliberately designed to mirror raw array behavior as closely as possible. An std::array created without any initializer contains garbage values, just like a raw array. Adding an empty {} is the idiomatic way to guarantee zero-initialization.

Accessing elements and iterating

Element access works the same way as a raw array — square-bracket indexing starting from zero — but std::array also offers a safer alternative, .at(i), that throws a std::out_of_rangeexception if the index is invalid rather than silently accessing memory it shouldn't. The range-based for loop works naturally because std::array provides begin() and end() iterators.

std::array<double, 4> v {0.5, 1.0, 1.5, 2.0};

// Index access — no bounds checking (UB if out of range):
v[0] = 10.0;
double x = v[2];          // 1.5

// .at() — throws std::out_of_range if index is bad:
double y = v.at(2);       // 1.5 — same result, safe
// v.at(99);              // would throw at runtime

// Convenience accessors:
double first = v.front(); // v[0]
double last  = v.back();  // v[v.size()-1]

// .size() always knows the count:
std::size_t n = v.size(); // 4

// Range-based for loop:
double total {};
for (auto elem : v)
    total += elem;        // sum: 5.0

// Modify elements in-place with a reference:
for (auto& elem : v)
    elem *= 2.0;          // double every element

// Set all elements to a single value:
v.fill(0.0);              // all four elements become 0.0

std::array as a proper value type

One of the most useful properties of std::array over raw arrays is that it behaves like a proper value. You can copy it, assign it, compare it, return it from a function, and store it inside other containers — none of which work correctly with raw arrays. Comparison is done element-by-element, which is almost always what you want.

std::array<int, 4> a {1, 2, 3, 4};
std::array<int, 4> b {1, 2, 3, 4};
std::array<int, 4> c {1, 2, 0, 4};

// Element-by-element comparison — works correctly:
if (a == b) std::println("equal");     // prints: equal
if (a != c) std::println("different"); // prints: different
if (a >  c) std::println("greater");  // prints: greater (3 > 0 at index 2)

// Assignment — makes a copy:
b = c;   // b is now {1, 2, 0, 4}

// Return from a function (returns by value, makes a copy):
auto make_scores() -> std::array<int, 3> {
    return {90, 85, 92};
}

// Store inside another container:
std::vector<std::array<int, 3>> records;
records.push_back({90, 85, 92});
records.push_back({78, 88, 95});

When you need to hand the underlying raw pointer to a legacy C function that expects an int*, the .data() member returns a pointer to the first element: legacy_c_function(a.data(), a.size()). This gives you interoperability without sacrificing the benefits of std::array everywhere else.

std::span<T> — a non-owning view (C++20)

std::span<T> (in <span>, C++20) is a lightweight, non-owning view over a contiguous sequence of elements. It does not allocate memory or own any data — it just holds a pointer to the beginning of the sequence and a count of how many elements follow. Think of it the same way you think of std::string_view: just as string_view is a read-only window into a string without copying it, std::span is a (optionally mutable) window into a contiguous array without copying it.

The primary motivation is solving the function-parameter problem. If you want to write a function that processes a sequence of integers, you have several bad options without span: take a raw pointer (loses size), take const std::vector<int>&(won't accept a raw array or std::array), or write multiple overloads. With std::span, one function accepts all of them:

#include <span>

// One function that accepts any contiguous sequence:
void print_all(std::span<const int> s) {
    std::println("size: {}", s.size());
    for (int v : s)
        std::print("{} ", v);
    std::println("");
}

int main() {
    int raw[]              {1, 2, 3, 4, 5};
    std::array<int, 5> arr {1, 2, 3, 4, 5};
    std::vector<int>   vec {1, 2, 3, 4, 5};

    print_all(raw);   // raw array — automatically promoted to span
    print_all(arr);   // std::array — works
    print_all(vec);   // std::vector — works
    // All three print:  size: 5  →  1 2 3 4 5
}

When a raw array is passed to a function taking a std::span, the compiler automatically promotes it — capturing both the pointer and the size — so the function can safely iterate over it without being told the count separately. This is the single biggest quality-of-life improvement std::span offers over raw pointers.

Useful span operations

std::span provides most of the same accessors as std::array: indexed access, front(), back(), data(), size(), and range-based for. It also adds sub-span operations for taking a slice of the sequence without copying anything:

std::array<int, 6> data {10, 20, 30, 40, 50, 60};
std::span<int> s {data};   // view over all 6 elements

// Basic access:
int first = s.front();     // 10
int last  = s.back();      // 60
int third = s[2];          // 30 — no bounds checking
std::size_t n = s.size();  // 6

// Sub-spans — views into part of the sequence (no copying):
std::span<int> head = s.first(3);         // {10, 20, 30}
std::span<int> tail = s.last(2);          // {50, 60}
std::span<int> mid  = s.subspan(1, 4);    // {20, 30, 40, 50}

// Modify through span (works because span<int> is not const):
for (int& v : s)
    v *= 10;   // data now holds {100, 200, 300, 400, 500, 600}

// Use span<const int> when the function should not modify the data:
void read_only(std::span<const int> view);

Like std::string_view, std::span performs no bounds checking on operator[] — accessing an out-of-range index is undefined behavior. If you need runtime safety, check bounds yourself before indexing, or iterate with a range-based loop instead.

The second template parameter is the extent: std::span<int, 5> is a fixed-extent span that always covers exactly 5 elements (size is known at compile time), while std::span<int> is a dynamic-extent span where the size is stored at runtime. For most purposes you want the dynamic version (written with just one template argument), since it accepts sequences of any length.

When to use which

The choice between raw arrays, std::array, std::span, and std::vector comes down to two questions: do you know the size at compile time, and does this object need to own its data?

Use std::array<T, N> when…

  • → The size is fixed and known at compile time (RGB channels, matrix dimensions, game board tiles)
  • → You want stack allocation and zero runtime overhead vs a raw array
  • → You need value semantics — copy, assign, compare, return from functions

Use std::span<T> when…

  • → Writing a function parameter that should accept any contiguous sequence (raw array, std::array, std::vector)
  • → You need a view into part of a sequence (like a slice) without copying
  • → You want to avoid the overhead of passing a whole vector by reference just to read its elements

Use std::vector<T> when…

  • → The size is not known at compile time or can change at runtime
  • → You need to add and remove elements
  • → The fixed-size constraint of std::array does not fit the problem

As a rule of thumb: prefer std::array over raw arrays whenever the size is fixed, use std::vector when the size can change, and reach for std::span whenever you are writing a function that only reads or modifies a sequence without needing to own it. Never use a raw C-style array in new code unless you have a specific reason to do so.

Key rules to remember

Prefer std::array over raw arrays — same performance, far safer

std::array has zero overhead compared to a raw array but solves every major problem: it knows its size, supports comparison and assignment, and works correctly with algorithms.

Always add {} to zero-initialize a std::array

Unlike vector and most other containers, std::array does NOT default-initialize its elements to zero. Use std::array<int, N> data {}; to guarantee all zeros.

Use .at(i) when you want runtime bounds checking

operator[] does no checking and causes undefined behavior if the index is out of range. .at(i) throws std::out_of_range instead, which is catchable and diagnosable.

Use std::span<const T> for read-only function parameters

A function that does not need to modify the sequence should take span<const T>, not span<T>. This makes the intent clear and allows the caller to pass const data.

std::span does not own data — it must not outlive its source

A span is a view into data owned elsewhere. If the underlying array or vector is destroyed while the span still exists, using the span is undefined behavior.

std::span requires C++20; std::array has been available since C++11

If your project targets C++17 or earlier, std::array is available but std::span is not. You can use gsl::span from the Guidelines Support Library as a backport.

Sign in to track progress