Arrays & Strings · beginner · ~10 min

Arrays

- Declare a fixed-size array and initialise it in several ways (explicit values, all-zero, size inferred). - Access and modify individual elements by index, understanding that valid indices run from `0` to `N-1`. - Loop over every element forward and backward using a counter and the array length. - Compute the element count of a *real* array with `sizeof a / sizeof a[0]`, and explain why that trick fails on a pointer. - Explain array-to-pointer decay and why functions must receive the length as a separate argument. - Recognise and avoid out-of-bounds access, the most common memory bug in C array code.

Overview

An array is a contiguous block of memory that holds N values of the same type, laid out one after another with no gaps. If int a[5] lives at some address, then a[0], a[1], ... a[4] sit at increasing addresses, each sizeof(int) bytes apart. "Contiguous" is the whole point: because the elements are evenly spaced, the machine can find a[i] with one multiply-and-add, which is why arrays are so fast.

Arrays are the simplest way C represents a sequence of things: a list of scores, the characters of a word, the pixels of an image, the samples of a sound. In the previous lesson, for / while / do-while, you learned to repeat work a known number of times. Arrays are the natural partner of loops: the loop supplies the index i, and the array supplies the value a[i]. Almost every loop you write from now on will walk over an array.

Three ideas define how arrays behave in C, and the rest of this lesson unpacks them:

  1. Zero-indexed. The first element is a[0], the last is a[N-1]. The index is really an offset from the start.
  2. Fixed, known size. A plain array's length is decided when you declare it and never changes. The compiler knows it; you must too.
  3. No self-knowledge at runtime. An array does not carry its own length around. C will happily let you read a[100] of a 5-element array, with no error and no warning. Keeping the index inside the valid range is your job.

Why it matters

Arrays are the foundation under almost everything else in C and in computing generally:

  • Strings are just arrays of char ending in a '\0' byte.
  • Buffers for files, network packets, audio, and video are arrays of bytes.
  • Images are arrays (often 2-D) of pixels.
  • Lookup tables, hash tables, dynamic vectors, ring buffers, stacks, heaps are all built on top of arrays.

They also matter for correctness and security. The single most exploited class of bug in C's history — the buffer overflow — is an array access that runs past its end. Reading or writing out of bounds corrupts neighbouring memory or leaks data, and historically this has caused crashes, data corruption, and serious security holes. Learning arrays well means learning to count carefully, and that habit prevents a large fraction of real-world C bugs.

Core concepts

Concept 1: Indexing and zero-based offsets

Definition. An index selects one element of an array. a[i] means "the element i positions after the start of a."

How it works internally. The compiler turns a[i] into *(a + i): take the address of the first element, step forward i elements (each sizeof(element) bytes), and read what is there. Because the first element is at offset 0, indices run 0 to N-1.

int a[5] = {10, 20, 30, 40, 50};

 index:     0     1     2     3     4      (5 is PAST the end)
          +----+ +----+ +----+ +----+ +----+
  value:  | 10 | | 20 | | 30 | | 40 | | 50 |
          +----+ +----+ +----+ +----+ +----+
  addr:   1000   1004   1008   1012   1016     <- 4 bytes apart (int)
            ^                            ^
          a[0]                         a[4]

When to use / not. Use indices whenever you need direct, random access to any position. If you only ever walk straight through, a pointer walk is an alternative, but indices are clearer for beginners.

Pitfall. Treating the size as a valid index. For int a[5], the last index is 4, not 5. Writing a[5] is one slot past the end.

Knowledge check (concept). For char s[8], what is the highest index you may legally use, and how many elements does the array have?

Concept 2: Out-of-bounds access and undefined behaviour

Definition. Accessing an index outside [0, N-1] (for example a[N] or a[-1]) is undefined behaviour (UB) — the C standard places no constraints on what happens.

Plain language. C does not check bounds at runtime. There is no exception, no IndexError. The program might crash, might silently read or overwrite unrelated memory, or might appear to work today and fail tomorrow on another machine or compiler.

Valid array region              one past the end
+----+----+----+----+----+ | ?? whatever lives here ??
| a0 | a1 | a2 | a3 | a4 | | (another variable, a saved
+----+----+----+----+----+ |  return address, padding...)
  0    1    2    3    4   |  5  <- writing here corrupts it

When to use / not. Never deliberately. Always guard with i < N before a[i].

Pitfall. A loop written as for (i = 0; i <= N; i++) touches a[N]. The condition should be i < N.

Knowledge check (find-the-bug). for (size_t i = 0; i <= n; i++) sum += a[i]; — which element does this read that it should not, and how do you fix the loop?

Concept 3: Array-to-pointer decay

Definition. When an array name is used in most expressions, it decays into a pointer to its first element. A pointer is simply a value that holds a memory address.

How it works. a by itself usually evaluates to &a[0], of type int *. This is why passing an array to a function passes only the address — the element count is lost. Decay happens everywhere except as the operand of sizeof and &.

Inside main:                 Inside f(int *a, ...):
   a  ---> [10][20][30]         a  ---> (same 10/20/30 elements)
   sizeof a == 12 (3 ints)      sizeof a == 8  (size of a POINTER!)
   length known                 length unknown -> must be passed in

This is why these two parameter declarations are identical to the compiler:

void f(int a[]);   // looks like an array
void f(int *a);    // is actually a pointer

When to use / not. Decay is what makes passing arrays cheap (no copying). But never rely on sizeof to recover length inside a function — it gives the pointer size, not the array size.

Pitfall. Computing sizeof a / sizeof a[0] on a function parameter. It silently returns the wrong number (typically 8/4 == 2).

Knowledge check (predict-the-output). A function takes int *a. Inside it, what is sizeof a on a typical 64-bit system, and why is it not the array length?

Concept 4: Fixed size and counting elements

Definition. A plain array's size is fixed at compile time and cannot grow or shrink.

Counting elements. For a real array, sizeof a / sizeof a[0] gives the element count: total bytes divided by bytes-per-element. Use size_t (an unsigned type sized for memory) to hold lengths and indices.

When to use / not. Use a fixed array when the maximum size is known and modest. If the size is decided at runtime, allocate with malloc and treat the returned pointer like an array (covered in later lessons).

Pitfall. Hard-coding the length (for (i = 0; i < 5; i++)) and then changing the array size but forgetting the loop. Derive the count once with the sizeof trick instead.

Knowledge check (explain-in-your-own-words). Why does sizeof a / sizeof a[0] work on a real array but not on a pointer?

Syntax notes

int a[5] = {1, 2, 3, 4, 5};        // declare and fully initialise
int b[5] = {1, 2};                  // b[0]=1, b[1]=2, rest are 0
int z[5] = {0};                     // every element set to 0
int c[]  = {10, 20, 30};            // size inferred: c has 3 elements

size_t n = sizeof a / sizeof a[0];  // number of elements (real array only)

a[0] = 99;                          // write the first element
int x = a[4];                       // read the last element (index N-1)

Key points:

  • Partial initialisers zero-fill the rest, so {0} is the idiom for an all-zero array.
  • int c[] = {...} lets the compiler count the initialisers for you.
  • The sizeof element-count trick only works where a is a real array in scope — not on a parameter, where the array has decayed to a pointer.

Lesson

An array is a contiguous block of elements of the same type.

  • Declare it with int a[N];
  • Access an element with a[i], where i ranges from 0 to N-1.

C does not check bounds at runtime. Accessing a[N] is undefined behaviour — the program may crash, return garbage, or appear to work by accident.

Arrays also do not carry their size with them. When you pass an array to a function, it decays to a pointer to the first element. The size is lost, so you must pass it as a separate argument.

Code examples

#include <stdio.h>
#include <stddef.h>   /* size_t */

/* Length must be passed in: inside a function the array is just a pointer. */
static void print_array(const int *a, size_t n) {
    for (size_t i = 0; i < n; i++) {
        printf("%d", a[i]);
        if (i + 1 < n) printf(" ");   /* space between, none after last */
    }
    printf("\n");
}

static long array_sum(const int *a, size_t n) {
    long total = 0;               /* long so big sums don't overflow int */
    for (size_t i = 0; i < n; i++)
        total += a[i];
    return total;
}

static int array_max(const int *a, size_t n) {
    int best = a[0];              /* seed from a[0], not 0, for all-negative data */
    for (size_t i = 1; i < n; i++)
        if (a[i] > best) best = a[i];
    return best;
}

int main(void) {
    int a[] = {10, 20, 30, 40, 50};
    size_t n = sizeof a / sizeof a[0];   /* works: a is a real array here */

    print_array(a, n);
    printf("sum = %ld\n", array_sum(a, n));
    printf("max = %d\n", array_max(a, n));

    /* Reverse in place using two indices walking toward each other. */
    for (size_t i = 0, j = n - 1; i < j; i++, j--) {
        int tmp = a[i];
        a[i] = a[j];
        a[j] = tmp;
    }
    print_array(a, n);
    return 0;
}

What it does. It builds a 5-element array, derives its length safely, prints it, computes the sum and maximum, reverses it in place, and prints again. There is no dynamic memory, so there is nothing to free; the array lives on the stack and is reclaimed automatically.

Expected output:

10 20 30 40 50
sum = 150
max = 50
10 20 30 40 50

Wait — the last line is the reversed array, so it is actually:

50 40 30 20 10

Edge cases. array_max assumes n >= 1 (it reads a[0]); call it only on non-empty arrays. The reverse loop's condition i < j correctly does nothing for n <= 1 and stops at the middle for even and odd lengths alike.

Line by line

Walking through main and the helpers:

  1. int a[] = {10, 20, 30, 40, 50}; — the compiler counts 5 initialisers, so a is int[5]. Memory now holds 10 20 30 40 50 in five adjacent int slots.

  2. size_t n = sizeof a / sizeof a[0];sizeof a is 20 bytes (5 × 4), sizeof a[0] is 4, so n == 5. This works because a is a real array in this scope.

  3. print_array(a, n)a decays to &a[0]; the function receives the address and the count 5. Inside, the loop prints each a[i] with a separating space, giving 10 20 30 40 50.

  4. array_sum(a, n)total starts at 0 and accumulates each element. Using long avoids overflow if the values were large.

    i a[i] total
    0 10 10
    1 20 30
    2 30 60
    3 40 100
    4 50 150
  5. array_max(a, n)best is seeded from a[0] (10), then the loop from i = 1 updates best whenever a larger value appears: 20, 30, 40, 50. Final best == 50.

  6. The reverse loop uses two indices: i starts at 0, j at n-1 (4). Each step swaps a[i] with a[j], then i++ and j--. It stops when i < j fails (they meet in the middle).

    i j after swap
    0 4 50 20 30 40 10
    1 3 50 40 30 20 10
    2 2 loop ends (i < j false)
  7. The final print_array shows 50 40 30 20 10.

Common mistakes

Mistake 1 — Off-by-one past the end (<= instead of <).

/* WRONG: touches a[n], which does not exist */
for (size_t i = 0; i <= n; i++) sum += a[i];

Why it is wrong: valid indices stop at n-1. Reading a[n] is undefined behaviour. Corrected:

for (size_t i = 0; i < n; i++) sum += a[i];

Prevent it: the loop bound for a forward walk is almost always i < n. Run with AddressSanitizer to catch the rare slip.

Mistake 2 — Using sizeof to find length inside a function.

/* WRONG: inside f, a is a pointer, not an array */
void f(int a[]) {
    size_t n = sizeof a / sizeof a[0];  /* gives 8/4 == 2, not the real length */
    for (size_t i = 0; i < n; i++) ...
}

Why: the array decayed to a pointer, so sizeof a is the pointer size. Corrected — pass the length:

void f(const int *a, size_t n) { for (size_t i = 0; i < n; i++) ... }

Recognise it: a loop that always processes exactly 2 (or 1) elements regardless of the array size.

Mistake 3 — Returning a pointer to a local array.

/* WRONG: buf disappears when the function returns */
int *make(void) { int buf[4] = {1,2,3,4}; return buf; }

Why: buf lives on the stack and is destroyed at return; the caller gets a dangling pointer. Fix by writing into a caller-provided buffer, or by allocating with malloc (and the caller frees it).

Mistake 4 — Seeding a maximum with 0.

int best = 0;  /* WRONG for all-negative arrays: returns 0, never an element */

Fix: int best = a[0]; and start the loop at i = 1.

Debugging tips

Compiler messages

  • warning: 'sizeof' on array function parameter ... will return size of 'int *' — you tried the sizeof trick on a parameter. Pass the length explicitly.
  • array subscript N is above array bounds (with -O2 -Wall) — the compiler proved an out-of-bounds index. Fix the loop condition.
  • Always compile with -Wall -Wextra; many array mistakes surface as warnings.

Runtime errors

  • A crash ("segmentation fault") often means you read or wrote far outside the array. Rebuild with -fsanitize=address -g and rerun; AddressSanitizer prints the exact line and the bad index.
  • "Stack smashing detected" means you wrote past a local array and clobbered the stack guard — a classic off-by-one write.

Logic errors

  • If results look shifted by one, suspect the loop bounds (< vs <=, start at 0 vs 1).
  • Print every element in a loop to see the whole array, not just the suspicious one.

Questions to ask when it does not work

  • What is the array's real length, and does every index stay in [0, n-1]?
  • Did I pass the length, or am I relying on sizeof after decay?
  • Is my accumulator/seed initialised correctly for negative or empty input?

Memory safety

Arrays are where most beginner memory bugs in C originate, because the language never checks bounds for you.

  • Out-of-bounds read (a[n], a[-1]): undefined behaviour. May return garbage or leak nearby data.
  • Out-of-bounds write: silently corrupts whatever sits after (or before) the array — another variable, padding, or a saved return address. The crash often appears far from the real bug, making it hard to trace.
  • Uninitialised elements: int a[5]; without an initialiser leaves the values indeterminate. Reading them before assigning is undefined. Use int a[5] = {0}; when you need a clean start.
  • Signed/unsigned and overflow in index math: indices and lengths should be size_t. Computing an index as n - 1 when n is 0 underflows to a huge value — guard empty arrays first.

Protective habits:

  • Confirm i < n before every a[i].
  • Pass the length alongside every array; never recover it with sizeof after decay.
  • Prefer memcpy/memset over hand-rolled byte loops when copying or clearing; their bounds are explicit and easy to audit.
  • Compile with -Wall -Wextra and test with -fsanitize=address,undefined during development to catch bounds and UB problems early.

Real-world uses

Concrete uses. Audio software stores raw samples in a large float/int16_t array and processes them in a tight loop. Image editors hold pixels in arrays (one byte per channel). Network code reads incoming bytes into a fixed buffer (unsigned char buf[1500]) and parses them. Embedded firmware keeps sensor history in a small ring buffer backed by an array. Almost every fast numerical inner loop is, at heart, an array traversal.

Professional best practices

Beginner rules:

  • Name arrays for their contents (scores, samples), not arr.
  • Track and pass the length explicitly; never assume the caller knows it.
  • Keep every index inside [0, n-1]; write i < n, not i <= n.
  • Initialise arrays (= {0}) when you cannot fill them immediately.

Advanced rules:

  • Use size_t for sizes and indices, and validate any length that comes from input before allocating or indexing.
  • For fixed-capacity buffers, store both the capacity and the current count, and check the count against the capacity before appending.
  • Prefer standard library helpers (memcpy, memmove, memset) with carefully computed sizes over manual loops.
  • Build with sanitizers in CI so out-of-bounds bugs are caught automatically rather than in production.

Practice tasks

Beginner 1 — Print an array. Write void print_array(const int *a, size_t n) that prints the n elements separated by single spaces, with a newline at the end and no trailing space. Test it from main on {3, 1, 4, 1, 5}. Concepts: decay, passing length, i < n loop. Hint: print the space before every element except the first.

Beginner 2 — Sum and average. Read n then n integers into an array (assume n <= 100). Print their sum (as long) and their average (as double). Example: input 4 then 10 20 30 40sum = 100, avg = 25.00. Constraint: handle n == 0 by printing sum = 0, avg = 0.00. Hint: cast the sum to double before dividing.

Intermediate 1 — Max and its index. Write a function returning both the maximum value and the index where it first occurs. Input 5 then 7 2 9 9 1max = 9 at index 2. Constraint: n >= 1. Hint: seed from a[0] and index 0; update both together; use > (not >=) so the first maximum wins.

Intermediate 2 — Reverse in place. Write void reverse(int *a, size_t n) that reverses the array without using a second array. Verify it on even and odd lengths and on n == 0 and n == 1. Hint: two indices walking toward each other while i < j; swap with a temporary.

Challenge — Remove a value in place. Write size_t remove_value(int *a, size_t n, int target) that deletes every element equal to target, shifting the survivors forward, and returns the new length. Example: a = {3, 5, 3, 7}, target = 3a becomes {5, 7, ...}, returns 2. Constraints: in place, single pass, no extra array. Hint: keep a write index w; for each element, if it is not the target, copy it to a[w] and increment w. Concepts: two-index technique, careful bounds, returning a new length.

Summary

  • An array is a contiguous, fixed-size block of same-typed elements; a[i] is the element at offset i, and valid indices run 0 to N-1.
  • C performs no bounds checking: accessing outside [0, N-1] is undefined behaviour and the source of buffer overflows. Always guard with i < n.
  • Arrays do not remember their length. When passed to a function an array decays to a pointer, so you must pass the length separately.
  • sizeof a / sizeof a[0] counts elements only on a real array in scope, never on a decayed pointer.
  • Key syntax: int a[N] = {0};, a[i], for (size_t i = 0; i < n; i++), and the element-count sizeof trick.
  • Most common mistakes: off-by-one (<=), sizeof on a parameter, returning a pointer to a local array, and seeding a max with 0.
  • Remember: count carefully, pass the length, stay in bounds, and lean on -Wall -Wextra and AddressSanitizer to catch slips.

Practice with these exercises