A C-style array is a fixed number of same-type objects laid out contiguously in memory. Its length is part of the array’s type.
An array converts to a pointer to its first element in most expressions and function parameters. The pointer carries no length, and indexing has no automatic bounds check.
Prefer std::array for fixed-size value semantics, std::span for passing a contiguous sequence, and std::vector when the length changes at runtime.
What it is and why it exists
A C-style array is a built-in compound type in C++. The declaration int readings[4] creates four int subobjects indexed from 0 through 3, stored contiguously in memory. The 4 in that declaration is an array extent and part of the type: int[4] and int[5] are different types.
A built-in array provides the most direct layout for a fixed set of objects. It has no stored length field or separate control block. You meet it in structure members, string literals, C interfaces, and low-level library APIs. Its rules also explain problems that look like pointer problems, such as why a function does not receive an array’s length and why a two-dimensional array cannot be passed as int**.
The type is part of the core language, but it has few operations. Arrays cannot be assigned as a whole, passed by value, or returned by value, and subscripting does not check bounds. Modern C++ code usually uses standard-library types to express ownership and interfaces, reserving built-in arrays for layout, interoperability, or small local fixed-size storage.
Three standard-library types cover the usual intentions. std::array<T, N> owns a fixed number of elements and has value semantics, std::span<T> is a non-owning view of contiguous elements, and std::vector<T> owns a runtime-sized sequence. They do not change the built-in rules, but they make length and ownership visible in an interface.
| Need | Usual type | Where the length lives |
|---|---|---|
| Own a fixed number of elements | std::array<T, N> | N in the type |
| Observe or modify contiguous elements | std::span<T> | The span object or fixed-extent type |
| Grow and shrink at runtime | std::vector<T> | The container object |
| Interoperate with an interface requiring built-in layout | T[N] or T* plus a count | The array type or a separate contract |
A character array is still just an array whose element type is char. char name[] = "Ada" has extent 4 because initialization also copies the terminating null character '\0'. Only a character array that follows the termination convention can be passed to an API expecting a C string; an ordinary character array does not automatically become a string.
How it works
The declaration T values[N] creates N elements of type T. Standard C++ requires N to be greater than zero and to be a compile-time constant expression. Zero-length arrays and variable-length arrays accepted by GCC are extensions, not portable C++23. Use std::vector or another dynamic-storage type for a runtime length.
An array object is large enough to hold all its elements. For an index i, values[i] is defined in terms of *(values + i): an array-to-pointer conversion happens first, followed by pointer arithmetic and dereferencing. A valid element index must be less than the extent.
An element pointer can move from the first element through the one-past-the-end position. The one-past pointer can delimit a half-open range, but it cannot be dereferenced. Pointer arithmetic is guaranteed only within one array object and its one-past position. Moving farther outside that range is not valid array traversal, even if the pointer is never dereferenced.
The initialization form determines the initial values. When an initializer list has fewer entries than the extent, the remaining elements are value-initialized, so int counts[4]{2, 1} produces 2, 1, 0, 0. An automatic-storage array of fundamental type with no initializer contains indeterminate values; reading an element before assigning it has no usable semantics.
An array name is not a pointer variable. sizeof values operates on the whole array, &values has pointer-to-whole-array type, and values converts to T* only in most value-taking expressions. Consequently, sizeof values / sizeof values[0] works only in a scope where the array type has not been lost.
Array syntax in a function parameter is a particularly misleading exception. void consume(int values[10]) is adjusted to void consume(int* values); the 10 creates neither a runtime check nor part of the parameter type. If the function needs a length, pass it separately, accept an array reference, or, more commonly, accept std::span.
| Source spelling | Type information retained there |
|---|---|
Local variable int values[4] | int[4] |
auto pointer = values | int* |
auto& alias = values | int (&)[4] |
Parameter void read(int values[4]) | Adjusted to int* |
Parameter void read(int (&values)[4]) | Reference to int[4] |
std::span<T> stores a pointer and an extent, but it does not own the array or extend its lifetime. A dynamic-extent span normally stores the length at runtime; a fixed-extent std::span<T, N> puts N in the type. In C++23, span::operator[] still requires a valid index. It is not an automatically throwing safe subscript.
A multidimensional declaration is made from nested arrays. int grid[2][3] is an array of two elements, each of which is an int[3]. Its row subarrays are stored in sequence, giving row-major order ; grid[row][column] selects a row first and then an element within that row.
An array object’s lifetime follows its storage duration. An ordinary local array’s lifetime ends when its scope exits, a static array lasts until program termination, and an array created by new T[n] lasts until its matching delete[]. A pointer or span obtained from a local array does not keep the elements alive after the function returns.
Examples
Preserve the extent while iterating
The first example uses a list initializer and lets std::size obtain the extent through an array reference. A range for also retains the array range, so the loop does not need a handwritten end index.
#include <iostream>
#include <iterator>
int main() {
int readings[]{18, 21, 19, 24};
std::cout << "count: " << std::size(readings) << '\n';
for (int& reading : readings) {
reading += 1;
}
std::cout << "adjusted:";
for (int reading : readings) {
std::cout << ' ' << reading;
}
std::cout << '\n';
}count: 4
adjusted: 19 22 20 25The int& lets the first loop modify the array elements directly. The second loop reads each int by value. If you write auto pointer = readings, type deduction first triggers the array-to-pointer conversion; auto& same_array = readings preserves the int[4] type.
Pass a pointer and length with span
The next example accepts a span. The caller can pass a built-in array, while the function receives both the first element and the element count instead of relying on a raw pointer parameter with a separate, easily desynchronized length.
#include <iostream>
#include <span>
int total(std::span<const int> values) {
int result = 0;
for (int value : values) {
result += value;
}
return result;
}
void replace_negative(std::span<int> values) {
for (int& value : values) {
if (value < 0) {
value = 0;
}
}
}
int main() {
int balances[]{12, -3, 8, -1};
replace_negative(balances);
std::cout << "total: " << total(balances) << '\n';
std::cout << "middle: " << total(std::span{balances}.subspan(1, 2)) << '\n';
}total: 20
middle: 8std::span<int> lets replace_negative modify elements, whereas std::span<const int> provides read-only access. subspan(1, 2) describes two elements beginning at index 1; the requested range still has to be valid when the subview is created.
Deduce the extent through an array reference
A template can retain an extent through an array reference and deduce it as N. This interface works for a narrow utility that intentionally accepts only built-in arrays. A span is usually simpler when the function should also accept std::array, vectors, or subranges.
#include <cstddef>
#include <iostream>
template<std::size_t N>
int last(const int (&values)[N]) {
static_assert(N > 0);
return values[N - 1];
}
template<std::size_t N>
void describe(const int (&values)[N]) {
std::cout << "extent=" << N
<< ", first=" << values[0]
<< ", last=" << last(values) << '\n';
}
int main() {
int ports[]{443, 8443, 9443};
int retries[]{1, 2, 3, 5, 8};
describe(ports);
describe(retries);
}extent=3, first=443, last=9443
extent=5, first=1, last=8The two calls instantiate different values of N, so last can access the final element without a second length parameter. That guarantee comes from the parameter type; it was not recovered from a pointer.
Pass a two-dimensional array
The column extent of a two-dimensional array must be retained so the compiler can locate the next row. An array reference puts both row and column counts in the type and rejects an array with a different shape.
#include <cstddef>
#include <iostream>
template<std::size_t Rows, std::size_t Cols>
void print_grid(const int (&grid)[Rows][Cols]) {
std::cout << "shape: " << Rows << 'x' << Cols << '\n';
for (std::size_t row = 0; row < Rows; ++row) {
std::cout << "row " << row << ':';
for (std::size_t column = 0; column < Cols; ++column) {
std::cout << ' ' << grid[row][column];
}
std::cout << '\n';
}
}
int main() {
int seats[2][3]{
{1, 0, 1},
{0, 1, 1},
};
print_grid(seats);
}shape: 2x3
row 0: 1 0 1
row 1: 0 1 1Here grid[row] is a complete int[Cols] row array before it converts to a pointer to that row’s first element. If the function instead used the traditional parameter const int grid[][3], it would adjust to a pointer to int[3] and still need the row count separately. It is never int**.
Pitfalls
Out-of-bounds access and one-past pointers
Use a half-open range: write index < count, or use a range for, a standard algorithm, or a span. Check index < size explicitly before subscripting with an externally supplied index. Test builds can enable AddressSanitizer and UndefinedBehaviorSanitizer, but a detector is not a substitute for carrying the bound in the interface.
Deriving a length with sizeof inside a function
Change the parameter to std::span<const int>, or pass a pointer and element count together at a low-level boundary. Use const int (&values)[8] only when the function must reject every other extent. In that spelling, the bracketed 8 really is part of the referred-to type.
Assuming an array is copied as a value
Use std::array when you need an independent value, or copy elements into another existing range. If a built-in array is a class member, the class’s implicit copy operation copies the member element by element. That is a rule for copying the enclosing class, not an array assignment operator.
Returning a view of a local array
Make the owner outlive the view, or return an owning std::array or std::vector by value. When reviewing a helper, do not stop at the return type. Trace the storage duration of the array from which its span or pointer originated.
Forgetting a character array’s terminator
Reserve an element for the terminator, for example by letting char code[] = "ABC" deduce an extent of 4. If the data permits embedded zero bytes or has no terminator, pass an explicit length or a suitable span instead of pretending that it is a C string.
Array types, extents, and conversions
An array’s extent is part of its type, but a variable name rarely carries that fact very far. decltype(values) yields the declared array type, sizeof values measures the complete array object, and std::size(values) returns the element count through an array-reference overload. By contrast, ordinary by-value deduction in auto value = values produces a pointer to the first element.
Unary &values does not decay the array either. If values is int[4], then &values is int (*)[4], and adding 1 crosses the entire four-element array. &values[0] is an int*, so adding 1 advances by one element. The two pointers can represent the same starting address while retaining different types and arithmetic units.
Reference binding also preserves the array type. The template parameter T (&array)[N] can deduce both the element type and extent; const T (&array)[N] provides read-only access. This is useful when the extent itself determines behavior, but each distinct N produces a separate template instantiation. A span is often a better public interface.
The array-to-pointer conversion produces only the first element’s address, not an end address. The resulting T* cannot distinguish one element, ten elements, or an address with no accessible element. Every raw-pointer interface therefore needs another contract defining a count, sentinel, or termination condition.
std::span<T, N> makes the representation choice explicit. The fixed extent N is part of the span type, whereas a dynamic-extent span exposes size() from the object. Both are views. Copying a span copies its observed location and length, not the underlying elements.
A built-in array cannot be a function’s direct return type and has no array assignment. Putting an array in a structure changes how it can be used: the structure can be returned and assigned by value, and its generated operations process each array member. std::array packages value-style container operations as a standard-library type; which operations are available still depends on the element type.
An array of unknown bound is useful only in restricted declarations. For example, extern int records[] can declare an array defined elsewhere. Code cannot apply an operation requiring the complete size until it sees the full definition. This is not a runtime variable-length array, and the object does not hide a length field.
With new T[n], n can be known at runtime, but the expression still returns T* with no queryable extent. Exceptions, early returns, and ownership transfer all make the matching delete[] harder to maintain. Unless you are implementing an owning container or adapting an interface that requires this shape, std::vector<T> or an array smart pointer expresses the lifetime more clearly.
Initialization, copying, and element lifetimes
Array initialization initializes each element in order; there is no separate construction step for an “array value.” A program is ill-formed if the initializer list has more elements than the extent. If it has fewer, every remaining element is initialized as if from an empty initializer list. That produces zero for int and runs the corresponding default construction for a class type.
| Declaration and location | Initial state of fundamental elements |
|---|---|
Local int data[4]; | Indeterminate; do not read before writing |
Local int data[4]{}; | All zero |
Static-storage int data[4]; | Zero-initialized during static initialization |
int data[4]{7, 8}; | 7, 8, 0, 0 |
Array elements are complete objects and follow the element type’s construction and destruction rules. Class-type elements are constructed in increasing subscript order and destroyed in reverse order when the array lifetime ends. If construction of one element throws, earlier fully constructed elements are destroyed and later elements never begin their lifetimes.
Top-level const applies to the elements. The elements of const int limits[3]{1, 2, 3} cannot be changed through that array, and conversion produces const int*. Casting away const and writing does not make this valid. If the original object is actually const, the write has undefined behavior.
Read an array declarator outward from the variable name. int* pointers[3] is an array of three int* elements, while int (*pointer)[3] is a pointer to an array of three integers. Parentheses change the binding. For a complicated declaration, a type alias or standard-library container is usually clearer than another declarator layer.
A built-in array itself has no copy constructor or assignment operator. You cannot use an array name as the whole initializer for another array, and an array cannot appear on the left side of assignment. An element-wise algorithm, an enclosing class, or std::array supplies an explicit copying operation.
When an array is a class member, its lifetime is managed by the enclosing object. The outer object’s default copy and move operations process the built-in array element by element, and destruction destroys every element. If an element is not copyable, the corresponding default copy operation of the outer class can be defined as deleted.
typedef int Row[4] or using Row = int[4] gives an array type a name. Row table[3] then means three rows of four integers each. An alias can make function references and row pointers easier to read, but it adds neither bounds checking nor value semantics.
Storage duration and ownership must be considered separately. A local array is normally owned by its scope, a static array by the program lifetime, and a member array by its enclosing object. Pointers, references, and spans only observe those elements. Their usability depends entirely on whether the owner still exists.
Arrays fit sets of objects whose extent is known at compile time. If the extent comes from input, putting it into a compiler-extension variable-length array on the stack introduces both portability and stack-space risks. A std::vector allocates dynamically and lets allocation failure follow the container’s exception and RAII rules, which is usually easier to review.
Expressing arrays at API boundaries
Array bugs often arise at a function boundary rather than at the storage declaration. A T* says only that T may be accessed through an address. It does not say whether the address may be null, how many elements exist, whether the call may modify them, or how long the pointer can be retained. A dependable interface states those constraints instead of making callers infer them from parameter names.
For an internal modern C++ interface, std::span<const T> commonly means a borrowed read-only sequence of contiguous elements, while std::span<T> means a borrowed modifiable sequence. Const restricts writes through that view; it does not disable other aliases. The caller still has to keep the backing array alive and avoid replacing storage in a way that invalidates addresses while the span is used.
A fixed-extent std::span<T, N> works when an algorithm requires exactly N elements at compile time. It still owns nothing, but an array with the wrong extent cannot implicitly construct that span type. If the algorithm accepts any length, a dynamic-extent std::span<T> reduces template instantiations and makes the interface broader.
A C-style interface usually uses a pointer plus count, such as const unsigned char* data, std::size_t size. Review the count’s unit: an element count, a byte count, and a final valid index are different values. Using a byte count as the loop bound for T* crosses the real element range whenever sizeof(T) > 1.
Empty ranges also need a contract. A function must not dereference the data pointer when the length is zero; whether the interface permits a null pointer depends on its preconditions. A span provides one representation for an empty range and lets loops rely on begin() and end(), but constructing a span from an invalid pointer does not repair the pointer’s origin.
Standard algorithms and range algorithms can accept an array while its extent is intact. std::ranges::sort(values) obtains the beginning and end from the array without a manual count. Compared with passing values and a repeated numeric constant, a range interface reduces opportunities for the pointer and length to disagree.
Use std::to_array or construct std::array explicitly when an existing built-in array should become an owning value. std::to_array("ABC") retains the string literal’s null terminator, so the result has four elements. If the intent is text rather than raw character capacity, a std::string or std::string_view interface says so more directly.
ABI or C interoperability may require a raw-pointer shape. Do not pretend in the wrapper that the type now guarantees the bound. Validate the length and nullability, then form a span as early as possible for internal code. The wrapper should also state that ownership stays with the caller and whether a callback or asynchronous operation may retain the view beyond the call.
Generic code can inspect the number of array dimensions and each extent with std::rank_v<T> and std::extent_v<T, I> from <type_traits>. These traits operate on types, so use them before decay, for example with decltype(values). Once only an int* remains, a type trait cannot recover the original extent.
Interface selection has two axes: ownership and bounds. Use a container for ownership and copying, a span for borrowing with a count, and expose a raw pointer only where another contract already defines the count. This decision prevents more real errors than a stylistic preference between array and pointer syntax.
Multidimensional and character-array bounds
Every layer of a multidimensional array has its own extent. For int image[3][4], the outer array has three elements whose type is int[4]; std::size(image) is 3, and std::size(image[0]) is 4. Both the row and column index need their own bounds proof.
When image appears in most expressions, it converts to int (*)[4], not int*. The compiler needs the four-element row type so that image + 1 can advance to the next row. The parameter int image[][4] adjusts to the same pointer type, which is why only the outermost extent can be omitted.
int** is a pointer to int*. A common layout behind it is an array of row pointers whose rows may live in separate allocations. A two-dimensional array has no such row-pointer objects; it directly contains row arrays. A cast cannot create the missing pointers, and dereferencing the array as int** misinterprets element bytes.
Nested arrays store rows in outer-element order and columns in each row’s element order, naturally producing row-major layout. Row-by-row and column-by-column access stays within the nested object bounds. Treating &image[0][0] as a one-dimensional pointer on which arbitrary arithmetic crosses all rows instead ignores the subarray boundaries. If flat storage is required, declare a one-dimensional array and compute row * columns + column explicitly.
Partial list initialization fills with zero at every layer. int image[2][3]{{1}, {2, 3}} produces rows 1, 0, 0 and 2, 3, 0. Inner braces can be omitted for some simple scalar arrays, but keeping the layers visible better communicates the shape and gives the compiler more opportunity to warn about suspicious initialization.
A string literal is itself a const char[N] array, where N includes the terminating null character. Initializing char text[] from it creates a modifiable array copy. Making const char* text point to the literal creates only a pointer, and modifying the literal still has undefined behavior. sizeof also differs: the former gives the array capacity, while the latter gives the pointer size.
Character-array capacity, current text length, and binary-data length are separate concepts. sizeof buffer gives the capacity only while buffer retains array type. std::strlen(buffer) requires a reachable \0 and counts only the characters before it. Data that may contain zero bytes needs a separately carried length; a span is more suitable than the string-termination convention.
A string literal can contain an embedded \0, so its array extent need not equal std::strlen plus one. char sample[] = "A\0B" has extent four, while C string functions see a text length of one. For protocol fields or file content, do not replace an explicit data length with null-terminated string rules.
Before ordering or subtracting two element pointers, confirm that they belong to the same array object or its corresponding one-past position. Numerically adjacent addresses do not create that relationship. Ordering or subtracting pointers from different arrays cannot establish one larger contiguous range.
An array member does not turn into a pointer transfer merely because its enclosing object is moved. A default move of the outer object applies move initialization or move assignment to each array element. For a large fixed collection, the mere fact that a move occurred does not guarantee constant cost.
A bounds review ultimately answers three questions together: what the complete type is, whether the current expression has already decayed, and how long the backing object remains alive. Checking only a subscript’s numeric value is insufficient. A correct number paired with the wrong row type or a dangling span still produces undefined behavior.
Further reading
4 questions · 1 predict-the-output · 1 spot-the-bug