C++ std::array vs C Arrays: Fixed-Size Container Guide
Table of Contents
Why std::array Exists
C-style arrays have been part of C++ since day one, but they carry dangerous legacy behavior. They decay to pointers when passed to functions, losing their size information. They don’t support bounds checking. They can’t be assigned, compared, or returned from functions directly. std::array, introduced in C++11, wraps a fixed-size array in a proper class with zero overhead — same memory layout, same stack allocation, same performance — but with type safety and STL compatibility.
The <array> header provides std::array<T, N> where T is the element type and N is the compile-time size. Because it’s a class template, it knows its own size, supports .begin()/.end() for iterators, and works seamlessly with every STL algorithm.
Creating and Initializing
#include <array>
#include <iostream>
int main() {
// Aggregate initialization
std::array<int, 5> a = {1, 2, 3, 4, 5};
// Partial initialization — remaining elements are zero-initialized
std::array<int, 5> b = {1, 2}; // {1, 2, 0, 0, 0}
// Default initialization — values are indeterminate for built-in types!
std::array<int, 5> c; // contains garbage in release mode
// Value initialization — all zeros
std::array<int, 5> d{}; // {0, 0, 0, 0, 0}
// Fill with a value
std::array<int, 5> e;
e.fill(42); // {42, 42, 42, 42, 42}
// C++17 CTAD (Class Template Argument Deduction)
std::array f = {1, 2, 3, 4, 5}; // deduces std::array<int, 5>
// C++20 std::to_array — create from C array or braced init
auto g = std::to_array({1, 2, 3}); // std::array<int, 3>
int raw[] = {10, 20, 30};
auto h = std::to_array(raw); // copies into std::array
// Nested arrays
std::array<std::array<int, 3>, 2> matrix = {{{1,2,3}, {4,5,6}}};
}
Accessing Elements
#include <array>
#include <iostream>
int main() {
std::array<int, 5> arr = {10, 20, 30, 40, 50};
// operator[] — no bounds checking (like C arrays)
std::cout << arr[0] << "\n"; // 10
std::cout << arr[4] << "\n"; // 50
// arr[10] = 99; // undefined behavior — no error!
// .at() — with bounds checking (throws std::out_of_range)
std::cout << arr.at(2) << "\n"; // 30
try {
arr.at(10) = 99; // throws!
} catch (const std::out_of_range& e) {
std::cout << "Out of range: " << e.what() << "\n";
}
// front() and back()
std::cout << arr.front() << "\n"; // 10
std::cout << arr.back() << "\n"; // 50
// data() — raw pointer to underlying array
int* ptr = arr.data();
std::cout << ptr[0] << "\n"; // 10
// Structured bindings (C++17) — for small arrays
auto [x, y, z] = std::array{1, 2, 3};
std::cout << x << " " << y << " " << z << "\n"; // 1 2 3
}
Use .at() during development and debugging for safety. Switch to [] in performance-critical inner loops where you’ve proven the index is always valid. The .data() method is essential for interoperability with C APIs that expect a raw pointer.
Size and Emptiness
std::array<int, 5> arr = {1, 2, 3, 4, 5};
// .size() — always returns N (compile-time constant)
std::cout << arr.size() << "\n"; // 5
// .max_size() — same as size() for std::array
std::cout << arr.max_size() << "\n"; // 5
// .empty() — true only if N == 0
std::cout << arr.empty() << "\n"; // false
// Zero-size array is allowed
std::array<int, 0> empty_arr;
std::cout << empty_arr.empty() << "\n"; // true
std::cout << empty_arr.size() << "\n"; // 0
Unlike C arrays, std::array::size() is a constexpr member function. You can use it in static_assert, template parameters, and other compile-time contexts. This eliminates the error-prone sizeof(arr)/sizeof(arr[0]) pattern entirely.
Iteration
std::array<std::string, 4> names = {"Alice", "Bob", "Charlie", "Diana"};
// Range-based for (preferred)
for (const auto& name : names) {
std::cout << name << " ";
}
// Iterator-based
for (auto it = names.begin(); it != names.end(); ++it) {
std::cout << *it << " ";
}
// Reverse iteration
for (auto rit = names.rbegin(); rit != names.rend(); ++rit) {
std::cout << *rit << " "; // Diana Charlie Bob Alice
}
// Index-based (when you need the index)
for (std::size_t i = 0; i < names.size(); ++i) {
std::cout << i << ": " << names[i] << "\n";
}
Passing to Functions
This is where std::array shines over C arrays. It doesn’t decay to a pointer:
// C array: decays to pointer, loses size info
void print_c(int arr[], int size) {
for (int i = 0; i < size; ++i)
std::cout << arr[i] << " ";
}
// std::array: size is part of the type
void print_arr(const std::array<int, 5>& arr) {
for (const auto& val : arr) // knows its size!
std::cout << val << " ";
}
// Generic: accept any size
template<std::size_t N>
void print_any(const std::array<int, N>& arr) {
std::cout << "Size: " << N << " — ";
for (const auto& val : arr)
std::cout << val << " ";
std::cout << "\n";
}
// Works with any element type AND any size
template<typename T, std::size_t N>
void print_generic(const std::array<T, N>& arr) {
for (const auto& val : arr)
std::cout << val << " ";
}
// Can return std::array (impossible with C arrays)
std::array<int, 3> make_triple(int a, int b, int c) {
return {a, b, c};
}
int main() {
auto triple = make_triple(1, 2, 3); // works perfectly
print_any(std::array{10, 20, 30, 40});
print_any(std::array{1, 2, 3, 4, 5, 6, 7});
}
std::array vs C Arrays Comparison
Here’s a direct comparison of behaviors:
#include <array>
#include <algorithm>
int main() {
int c_arr[5] = {3, 1, 4, 1, 5};
std::array<int, 5> std_arr = {3, 1, 4, 1, 5};
// Size: C array loses it, std::array keeps it
// sizeof(c_arr) / sizeof(c_arr[0]) // works here but not after decay
std_arr.size(); // always correct: 5
// Assignment: C array can't, std::array can
// int c_arr2[5] = c_arr; // ERROR
std::array<int, 5> std_arr2 = std_arr; // OK — copies all elements
// Comparison: C array compares pointers, std::array compares values
// if (c_arr == c_arr2) {} // compares addresses, always false
if (std_arr == std_arr2) {} // compares element-by-element
// Sorting: both work but std::array is cleaner
std::sort(c_arr, c_arr + 5);
std::sort(std_arr.begin(), std_arr.end());
// Swap: both elements, not pointers
std::array<int, 5> a = {1,2,3,4,5};
std::array<int, 5> b = {6,7,8,9,10};
std::swap(a, b); // swaps all elements
}
constexpr and Compile-Time Arrays
std::array is fully constexpr-compatible, meaning you can create and manipulate arrays entirely at compile time:
constexpr std::array<int, 5> primes = {2, 3, 5, 7, 11};
// Compile-time access
static_assert(primes[0] == 2);
static_assert(primes.size() == 5);
// Compile-time computation
constexpr auto make_squares() {
std::array<int, 10> result{};
for (int i = 0; i < 10; ++i) {
result[i] = (i + 1) * (i + 1);
}
return result;
}
constexpr auto squares = make_squares();
static_assert(squares[0] == 1);
static_assert(squares[4] == 25);
static_assert(squares[9] == 100);
// Lookup table computed at compile time
constexpr auto make_fibonacci() {
std::array<long long, 20> fib{};
fib[0] = 0; fib[1] = 1;
for (int i = 2; i < 20; ++i)
fib[i] = fib[i-1] + fib[i-2];
return fib;
}
constexpr auto fib = make_fibonacci();
// fib[19] == 4181, computed at compile time — zero runtime cost
std::array with STL Algorithms
#include <array>
#include <algorithm>
#include <numeric>
std::array<int, 8> data = {5, 2, 8, 1, 9, 3, 7, 4};
// Sort
std::sort(data.begin(), data.end());
// Binary search (requires sorted)
bool found = std::binary_search(data.begin(), data.end(), 5);
// Accumulate
int sum = std::accumulate(data.begin(), data.end(), 0);
// Min/Max
auto [min_it, max_it] = std::minmax_element(data.begin(), data.end());
// Transform in-place
std::transform(data.begin(), data.end(), data.begin(),
[](int x) { return x * 2; });
// Count
int above5 = std::count_if(data.begin(), data.end(),
[](int x) { return x > 5; });
// Reverse
std::reverse(data.begin(), data.end());
// Fill with iota
std::array<int, 10> sequential;
std::iota(sequential.begin(), sequential.end(), 1); // {1,2,...,10}
Multidimensional Arrays
// 2D array: 3 rows x 4 columns
std::array<std::array<int, 4>, 3> matrix = {{
{1, 2, 3, 4},
{5, 6, 7, 8},
{9, 10, 11, 12}
}};
// Access: matrix[row][col]
std::cout << matrix[1][2] << "\n"; // 7
// Iterate
for (const auto& row : matrix) {
for (const auto& val : row) {
std::cout << val << " ";
}
std::cout << "\n";
}
// 3D array
std::array<std::array<std::array<int, 2>, 3>, 4> cube{};
// Type alias makes it readable
template<typename T, std::size_t Rows, std::size_t Cols>
using Matrix = std::array<std::array<T, Cols>, Rows>;
Matrix<double, 3, 3> identity = {{
{1.0, 0.0, 0.0},
{0.0, 1.0, 0.0},
{0.0, 0.0, 1.0}
}};
When to Use What
Use std::array when the size is known at compile time and you want stack allocation, type safety, and STL compatibility. It replaces C-style arrays in virtually every modern C++ scenario.
Use std::vector when the size is determined at runtime or changes frequently. Vector allocates on the heap and can grow/shrink.
Use C-style arrays only when interfacing with C APIs that require them, or in extremely constrained embedded environments. Even then, prefer std::array with .data() for the C API interop.
Common Mistakes
Forgetting braces for nested arrays. std::array<std::array<int, 3>, 2> m = {{1,2,3}, {4,5,6}}; may give warnings. Use double braces: {{ {1,2,3}, {4,5,6} }}.
Large arrays on the stack. std::array<int, 1000000> allocates 4MB on the stack, which may cause a stack overflow. For large fixed-size arrays, use std::vector with .reserve(), or allocate the std::array on the heap with std::make_unique.
Default initialization vs value initialization. std::array<int, 5> a; leaves elements uninitialized (garbage values). std::array<int, 5> a{}; zero-initializes them. Always use {} unless you have a specific reason not to.
Summary
std::array is the modern C++ replacement for C-style fixed-size arrays. It provides the same zero-overhead stack allocation while adding type safety, bounds checking via .at(), full STL compatibility, constexpr support, and the ability to be passed, returned, and compared as a value. Use it whenever the size is known at compile time. In the next lesson, you’ll learn about std::list and std::deque — the non-contiguous sequence containers for specialized use cases.