Lesson 9 of 25

Arrays

What an Array Is in Memory

An array is a run of elements of the same type stored back to back in memory, with nothing in between. That single sentence explains everything else about arrays, including the parts that seem arbitrary.

Because the elements are the same size and are contiguous, the address of element i is simply the address of the first element plus i times the element size. The processor computes it with one multiply and one add, no searching involved. That is why array access is O(1) — constant time regardless of how large the array is — and it is also why indices start at 0 rather than 1: the first element sits at an offset of zero from the start.

The contiguity has a second benefit that matters more than most students expect. Processors read memory in blocks called cache lines, so when you touch one element the neighbouring ones come along for free. Walking an array in order is therefore dramatically faster than jumping around it randomly, and much faster than walking a linked list of the same length, where each node could be anywhere. This is one of the main reasons std::vector outperforms std::list in practice even for operations where the theory says the list should win.

The price of that layout is rigidity. The block is allocated once at a fixed size, and there is nowhere to put a sixth element in a five-element array — the memory just after it belongs to something else. Every array type in C++ is some answer to the question of how to handle that limitation.

Example
#include <iostream>

int main() {
    int marks[5] = {72, 65, 91, 48, 88};

    std::cout << marks[0] << '\n';   // 72 — offset 0 from the start
    std::cout << marks[4] << '\n';   // 88 — the last valid index
    marks[2] = 95;

    // The addresses are 4 bytes apart on a typical machine
    std::cout << &marks[0] << '\n';
    std::cout << &marks[1] << '\n';

    // Partial initialiser: the rest are set to 0
    int counts[5] = {1, 2};          // {1, 2, 0, 0, 0}

    // Empty braces: everything zeroed
    int zeros[5] = {};               // {0, 0, 0, 0, 0}

    // NO initialiser inside a function: contents are GARBAGE
    int junk[5];
    // std::cout << junk[0];         // undefined behaviour

    std::cout << counts[4] << ' ' << zeros[0] << '\n';
}
Notes
  • int junk[5]; written inside a function leaves all five elements holding whatever was previously at that memory. Only arrays declared at global or static scope are zero-initialised for you. Write int junk[5] = {}; and the question never arises.

Three Problems with C-Style Arrays

C-style arrays are inherited from C, and every one of their rough edges is a consequence of C's design. You need to recognise them because they appear in older code, in interview questions, and in every C library you will ever call. You should rarely choose to write one yourself.

Problem one: the size must be known at compile time. int marks[5]; is fine because 5 is a constant. int marks[n]; where n was read from input is not standard C++, even though GCC and Clang accept it as an extension. Code that relies on it will not compile with MSVC, and it silently forfeits the standard's guarantees. When the size is only known at run time, the answer is std::vector.

Problem two: no bounds checking, ever. marks[10] on a five-element array is undefined behaviour. It does not throw, it does not warn at run time, and it frequently does not crash — it reads or writes whatever is next in memory, which may be another one of your variables. A program that quietly corrupts a nearby variable is far harder to debug than one that crashes, which is why building with -fsanitize=address while you learn pays for itself.

Problem three, and the worst: arrays decay to pointers. When you pass an array to a function, what actually arrives is a pointer to the first element. The size is not passed and cannot be recovered. This is why the sizeof(arr) / sizeof(arr[0]) trick works where the array was declared and gives you a meaningless answer inside a function — there, sizeof(arr) is the size of a pointer, typically 8. Every C function that takes an array therefore also takes a length parameter, and every mismatch between those two arguments is a bug waiting to happen.

Example
#include <iostream>

// This LOOKS like it takes an array. It takes a pointer.
void printWrong(int arr[]) {
    int n = sizeof(arr) / sizeof(arr[0]);   // 8 / 4 == 2, not 5
    for (int i = 0; i < n; ++i) std::cout << arr[i] << ' ';
    std::cout << "  <- only printed 2\n";
}

// The C way: always pass the length alongside
void printRight(const int arr[], int n) {
    for (int i = 0; i < n; ++i) std::cout << arr[i] << ' ';
    std::cout << '\n';
}

int main() {
    int marks[5] = {72, 65, 91, 48, 88};

    int n = sizeof(marks) / sizeof(marks[0]);   // 5 — works only HERE
    printRight(marks, n);
    printWrong(marks);

    // std::cout << marks[10];   // UB: reads memory that is not yours

    // int size; std::cin >> size;
    // int dynamic[size];        // not standard C++ — use std::vector
}
Notes
  • C++20 adds std::span, a lightweight object that carries a pointer and a length together, so a function can accept "some contiguous elements" without losing the size. If your compiler supports C++20, it is the modern replacement for the pointer-plus-length pair.

std::array and std::vector: Pick One

std::array<T, N> is a fixed-size array that behaves like a proper C++ object. The size is part of its type, so it never decays to a pointer; it knows its own size(); it can be copied, assigned and returned from a function; it offers at() for bounds-checked access; and it works with every standard algorithm because it provides begin() and end(). All of this costs nothing at run time — the memory layout is identical to a C array, so there is genuinely no reason to prefer the C form.

std::vector<T> is the answer when the number of elements is not known until the program runs, or changes as it runs. It allocates its storage on the heap, grows when you push_back past its capacity, and frees everything in its destructor. For most day-to-day C++ this is the container you will reach for by default.

So the choice is straightforward. If the count is a compile-time constant that will never change — the twelve months of a year, a board of 64 squares, the RGB channels of a pixel — use std::array: it avoids a heap allocation entirely. If the count comes from input, a file, or the growth of your data, use std::vector. If you find yourself writing a C-style array, ask which of the two you actually meant.

Both offer the same pair of access styles as std::string. [] is unchecked and fast; at() checks the index and throws std::out_of_range if it is bad. Use at() when the index came from user input or a file, and [] inside a loop whose bounds you already control.

Example
#include <array>
#include <iostream>
#include <vector>

// std::array does NOT decay — the function still knows the size
double mean(const std::array<int, 5>& a) {
    int sum = 0;
    for (int x : a) sum += x;
    return static_cast<double>(sum) / a.size();
}

int main() {
    std::array<int, 5> marks = {72, 65, 91, 48, 88};
    std::cout << marks.size() << '\n';       // 5
    std::cout << marks.at(0) << '\n';        // 72, bounds-checked
    std::cout << marks.front() << ' ' << marks.back() << '\n';   // 72 88
    std::cout << mean(marks) << '\n';        // 72.8

    std::array<int, 5> copy = marks;         // a real copy, unlike a C array
    copy.fill(0);

    // Size only known at run time -> vector
    int n = 0;
    std::cin >> n;
    std::vector<int> scores(n, 0);           // n elements, all zero
    scores.push_back(100);                   // now n + 1 elements
    std::cout << scores.size() << '\n';

    try {
        std::cout << marks.at(10) << '\n';   // throws
    } catch (const std::out_of_range& e) {
        std::cout << "bad index: " << e.what() << '\n';
    }
}
Notes
  • std::vector<int> v(5); creates five elements each equal to 0, while std::vector<int> v{5}; creates one element equal to 5. Round brackets mean "this many"; braces mean "these values". It is one of the sharper edges in the language, and it catches experienced people too.

Two Dimensions: Grids, Matrices and Row-Major Order

A 2D array is written int matrix[3][4] and is best understood as three groups of four, laid out one after another in a single flat run of memory. This is called row-major order: the entire first row comes first, then the entire second row, and so on. matrix[i][j] is really the element at flat offset i * columns + j.

That layout has a performance consequence you can measure. Iterating with the row index on the outside and the column index on the inside walks memory in order and is cache-friendly. Swapping the two loops walks with a stride equal to the row length, touching a new cache line almost every step. On a large matrix the same arithmetic can run several times slower purely because of the loop order, and this is a favourite interview follow-up question.

For a grid whose dimensions are known only at run time — a game board sized by the user, a matrix read from a file — the usual choice is std::vector<std::vector<int>>. It is convenient and reads well, and it is worth knowing that its rows are separate allocations scattered across the heap, so it loses the cache advantage described above. The alternative used in performance-sensitive code is a single flat std::vector of rows * cols elements, indexed by hand as v[i * cols + j]. Start with the readable version; reach for the flat one when profiling says you need it.

As with 1D arrays, a C-style 2D array passed to a function loses all but its first dimension, which is why C code full of matrices is full of explicit row and column parameters. Using std::vector or std::array avoids the whole problem, because both carry their sizes with them.

Example
#include <iostream>
#include <vector>

int main() {
    int matrix[3][3] = {
        {1, 2, 3},
        {4, 5, 6},
        {7, 8, 9}
    };
    std::cout << matrix[1][2] << '\n';       // 6

    // Cache-friendly: row outside, column inside
    for (int i = 0; i < 3; ++i) {
        for (int j = 0; j < 3; ++j) {
            std::cout << matrix[i][j] << ' ';
        }
        std::cout << '\n';
    }

    // Run-time sized grid, readable version
    int rows = 3, cols = 4;
    std::vector<std::vector<int>> grid(rows, std::vector<int>(cols, 0));
    grid[1][2] = 7;
    std::cout << grid[1][2] << ' ' << grid.size() << 'x' << grid[0].size() << '\n';

    // Flat version: one allocation, best locality
    std::vector<int> flat(rows * cols, 0);
    flat[1 * cols + 2] = 7;                  // same element as grid[1][2]
    std::cout << flat[1 * cols + 2] << '\n';
}
Notes
  • std::vector<std::vector<int>> grid(rows); gives you rows empty rows, not a rectangle. Writing grid[0][0] at that point is out of bounds. Always give the inner vector its size too, as in the example above.
Ask AI