Understanding Legacy C Arrays
Understand built-in array syntax and its limitations compared to std::array.
What Are C-Style Arrays?
A C-style array is a run of N objects of a single type, laid out one after another in memory, with the count N baked into the array's type. It is not a class and not a container: it is part of the core language, inherited from C, which is why it needs no header and why it comes with none of the conveniences a class could provide.
You have already met two array types that are classes. std::vector manages a resizable block on the heap, and std::array wraps a fixed-size block in a struct. Both are built on top of the thing this lesson is about. Everything a C-style array can and cannot do follows from one fact: the array object is its elements and nothing else. There is no length field, no capacity, no pointer, no bookkeeping of any kind.
Side by Side With std::array
You know std::array well enough that the fastest way to learn C-style arrays is to see where the two differ.
| Question | std::array<T, N> |
T name[N] |
|---|---|---|
| Header needed | <array> |
none, it is built into the language |
| Where the length lives | a template argument | part of the declared type, written in the brackets |
| Length deduced from initializers | yes, through CTAD | yes, leave the brackets empty |
| Element type deduced | yes, through CTAD | never, you must name the type |
| Index type accepted | std::size_t only |
any integral type, signed or unsigned, and unscoped enumerators |
| Bounds checking | none from operator[], available from at() |
none at all |
| Asking for the length | .size() |
std::size(), or sizeof arithmetic |
| Whole-object assignment | supported | not supported |
| Bytes in the object | the elements, and nothing the standard requires beyond them | exactly the elements |
The rest of this lesson works down that right-hand column.
Defining One
The brackets go after the variable name, and the number inside them says how many elements to make. Empty braces value-initialize every one of them:
#include <iostream>
int main()
{
int stopDwell[6] {};
stopDwell[0] = 45;
stopDwell[5] = 30;
std::cout << "first stop waits " << stopDwell[0] << " seconds\n";
std::cout << "last stop waits " << stopDwell[5] << " seconds\n";
std::cout << "stop 2 was never set: " << stopDwell[2] << " seconds\n";
return 0;
}
first stop waits 45 seconds
last stop waits 30 seconds
stop 2 was never set: 0 seconds
Two details are worth pinning down straight away.
The square brackets in int stopDwell[6] are declaration syntax. They look like a use of operator[], but at that point there is no object to subscript yet; the brackets are how you say "array of 6" the way * is how you say "pointer to".
The empty braces are not decoration. Drop them and you get default initialization, which for int elements means the array holds whatever those bytes happened to contain. Reading one of those elements before writing to it is undefined behavior, so write int stopDwell[6] {}; unless you are about to fill every element yourself.
The length must be known at compile time. It must be a constant expression, and it must be at least 1, so a variable, a negative number, or a fractional value are all rejected.
Writing
int stopDwell[stopCount] {}; where stopCount is an ordinary variable is a variable-length array, a C99 feature that is not part of C++. GCC accepts it as a silent extension, so the platform compiler here will build it without complaint, and asking for strict conformance with -pedantic-errors turns it into error: ISO C++ forbids variable length array 'stopDwell'. Do not write them. When the length is only known at runtime, that is what std::vector is for.
Indexing Runs From 0 to N-1, and No One Is Checking
An array of length N has valid indices 0 through N-1. There is no index N: the six-element array above ends at stopDwell[5].
What the index may be is more permissive than anything you have seen from the standard library containers. std::vector and std::array subscript with std::size_t, so a signed loop counter has to be converted first. A C-style array accepts any integral type, signed or unsigned, and unscoped enumerators too:
#include <iostream>
enum Stop
{
depot,
market,
museum,
pier,
};
int main()
{
constexpr int travelMinutes[] { 0, 4, 9, 14 };
int leg{ 2 };
unsigned int returnLeg{ 2 };
std::cout << "signed index: " << travelMinutes[leg] << '\n';
std::cout << "unsigned index: " << travelMinutes[returnLeg] << '\n';
std::cout << "enumerator: " << travelMinutes[pier] << '\n';
return 0;
}
signed index: 9
unsigned index: 9
enumerator: 14
All three subscripts are fine as written. The sign-conversion friction that surrounds indexing a std::vector with an int simply does not arise here, which is one of the few places where the built-in array is the more comfortable tool.
The subscript operator performs no bounds checking whatsoever, and there is no checked alternative like
at(). An index outside 0 to N-1 is undefined behavior: the program may print a plausible-looking number, corrupt a neighbouring variable, or crash, and which of those happens can change between builds. Never treat a value read from a bad index as data.
Filling One In With an Initializer List
C-style arrays are aggregates, so a braced list initializes the elements in order from element 0. Both int carriageSeats[5] { 32, 32, 28 } and the copy-list form int carriageSeats[5] = { 32, 32, 28 } do the same thing, and the first is preferred. Supply fewer initializers than the array holds and the leftovers are value-initialized, which zeroes them for int:
#include <iostream>
int main()
{
int carriageSeats[5] { 32, 32, 28 };
for (int seats : carriageSeats)
{
std::cout << seats << ' ';
}
std::cout << '\n';
return 0;
}
32 32 28 0 0
Going the other way is an error rather than a truncation, and the element type is never deduced. This program is broken twice over and compiles neither line:
#include <iostream>
int main()
{
int carriageSeats[3] { 32, 32, 28, 24 };
auto axleLoad[4] { 5.5, 6.25, 6.25, 5.5 };
std::cout << carriageSeats[0] << axleLoad[0] << '\n';
return 0;
}
GCC answers the first line with error: too many initializers for 'int [3]' and the second with error: direct-list-initialization of 'auto' requires exactly one element. CTAD cannot help either, because a C-style array is not a class template. The element type is something you always write out by hand.
The length, on the other hand, is something you can often leave out. Empty brackets ask the compiler to count the initializers for you:
#include <iostream>
#include <iterator>
namespace Network
{
constexpr int fareZone[] { 1, 1, 2, 3, 3 };
}
int main()
{
std::cout << "zones on file: " << std::size(Network::fareZone) << '\n';
for (int zone : Network::fareZone)
{
std::cout << zone << ' ';
}
std::cout << '\n';
return 0;
}
zones on file: 5
1 1 2 3 3
fareZone has type const int[5] even though nobody typed a 5. Writing the length as well is legal but creates a number that has to be kept in step by hand: add a sixth zone to the list, forget to change the 5, and you get a compile error at best or a silently trailing zero at worst. Empty brackets need all the elements listed, so int carriageSeats[] {} is rejected: the compiler would deduce a zero-length array, which is not allowed.
When you are initializing every element explicitly, omit the length and let the compiler count. Write the length only when you want elements the initializer list does not supply.
That example also shows a constexpr array. Like std::array, a C-style array can be const or constexpr, and like every other const object it must be initialized where it is defined and can never be assigned to afterwards.
Asking an Array How Big It Is
sizeof applied to an array gives the size of the whole thing in bytes, because the array object really is nothing but its elements:
#include <iostream>
#include <iterator>
int main()
{
constexpr double axleLoad[] { 5.5, 6.25, 6.25, 5.5 };
std::cout << "whole array: " << sizeof(axleLoad) << " bytes\n";
std::cout << "one element: " << sizeof(axleLoad[0]) << " bytes\n";
std::cout << "std::size: " << std::size(axleLoad) << '\n';
std::cout << "std::ssize: " << std::ssize(axleLoad) << '\n';
return 0;
}
whole array: 32 bytes
one element: 8 bytes
std::size: 4
std::ssize: 4
Four double elements at 8 bytes each account for all 32 bytes, with nothing left over for a length field or any other overhead. That absence is exactly why the length has to be recovered from the type rather than read out of the object.
std::size() returns the count as an unsigned std::size_t and has been available since C++17. std::ssize() returns it as a signed type and arrived in C++20, which is handy when the count is about to be compared against a signed value. Both live canonically in <iterator>, though <array> and <vector> happen to supply them as well, so a program that includes neither of those needs <iterator> explicitly.
Older code computes the count arithmetically instead, dividing the size of the array by the size of one element. The sum is correct, and it is also a trap. The following program is broken, and prints two different answers for the same array:
#include <iostream>
void reportRiders(int riders[])
{
std::cout << "counted " << sizeof(riders) / sizeof(riders[0]) << " stops\n";
}
int main()
{
int riders[6] { 12, 31, 8, 44, 19, 5 };
std::cout << "counted " << sizeof(riders) / sizeof(riders[0]) << " stops\n";
reportRiders(riders);
return 0;
}
counted 6 stops
counted 2 stops
Inside main() the arithmetic is right. Inside reportRiders() it is not, because a parameter written as int riders[] is really a pointer, so sizeof(riders) measured a pointer rather than an array and the division returned 8 divided by 4. GCC spots this particular case and warns, 'sizeof' on array function parameter 'riders' will return size of 'int*', but the arithmetic is only ever as trustworthy as your certainty that you are holding an array and not a pointer to one. The next lesson, on array decay, explains when that conversion happens and why it is so easy to miss.
Prefer
std::size() and std::ssize() to the sizeof ratio. Hand them a pointer and they fail to compile, which is a diagnostic; the ratio quietly produces a wrong number, which is a bug.
Copying and Assigning
An array can be initialized from a list, and individual elements can be assigned, but the array as a whole cannot be. This program is wrong on two lines:
#include <iostream>
int main()
{
int routeMinutes[] { 6, 11, 9 };
const int fareZone[] { 1, 2, 3 };
routeMinutes[0] = 8;
routeMinutes = { 7, 13, 10 };
fareZone[0] = 2;
std::cout << routeMinutes[0] << fareZone[0] << '\n';
return 0;
}
GCC reports error: assigning to an array from an initializer list for the whole-array assignment, and error: assignment of read-only location 'fareZone[0]' for the attempt to modify a const element. The first restriction is the surprising one, and the reason is grammatical rather than technical: assignment requires the left operand to be a modifiable lvalue, and an array is not one.
Element-by-element assignment works, and so does std::copy, which walks a source range and writes into a destination:
#include <algorithm>
#include <iostream>
int main()
{
int routeMinutes[] { 6, 11, 9 };
const int reroute[] { 7, 13, 10 };
routeMinutes[0] = 8;
std::copy(std::begin(reroute), std::end(reroute), std::begin(routeMinutes));
for (int minutes : routeMinutes)
{
std::cout << minutes << ' ';
}
std::cout << '\n';
return 0;
}
7 13 10
Note that std::copy writes into whatever the destination points at without knowing how long the destination is, so a source longer than the destination overruns it. If you find yourself reaching for whole-array replacement often, that is a sign the data wants a std::vector, which supports assignment, or a std::array, which can be copied with a plain =.
Summary
Built into the language: a C-style array needs no header because it is core language syntax rather than a library class. std::array and std::vector are typically implemented on top of one.
The object is only its elements: no length field, no capacity, no overhead. sizeof on an array therefore gives the total bytes of all elements, and the length has to come from the type.
Declaration syntax: type name[length], with the length a constant expression of at least 1. Variable-length arrays are a C feature that some compilers accept as an extension; they are not valid C++.
Indexing: valid indices run from 0 to length-1. Unlike the standard library containers, the index may be any integral type, signed or unsigned, or an unscoped enumerator. Nothing checks the bounds, and an out-of-range index is undefined behavior.
Aggregate initialization: a braced list initializes elements in order. Too many initializers is a compile error; too few value-initializes the rest, which zeroes fundamental types. Empty braces value-initialize everything.
No type deduction: neither CTAD nor auto can deduce the element type. Spell it out.
Omitted length: leave the brackets empty when you list every element, and the compiler counts them, so adding or removing an initializer can never disagree with a hand-written length.
const and constexpr: both are available, and both mean the elements must be initialized at the definition and can never be assigned afterwards.
Getting the length: std::size() since C++17 and std::ssize() since C++20, both from <iterator>. The sizeof(arr) / sizeof(arr[0]) idiom silently returns a wrong answer once the array has decayed to a pointer, which the next lesson covers.
No assignment: an array is not a modifiable lvalue, so it cannot appear on the left of =. Assign element by element, use std::copy, or reach for std::vector or std::array when whole-container assignment is what you actually want.
How did you find this lesson?
Your rating helps us improve the content.
Create an account to track your progress and access interactive exercises. Already have one? Sign in.
Understanding Legacy C Arrays - Quiz
Test your understanding of the lesson.
Practice Exercises
Working with C-Style Arrays
Practice declaring, initializing, and accessing C-style arrays. Learn about aggregate initialization, omitting length, and getting array size with std::size().
Lesson Discussion
Share your thoughts and questions
No comments yet. Be the first to share your thoughts!