Arrays & Strings · beginner · ~10 min
- 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.
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:
a[0], the last is a[N-1]. The index is really an offset from the start.a[100] of a 5-element array, with no error and no warning. Keeping the index inside the valid range is your job.Arrays are the foundation under almost everything else in C and in computing generally:
char ending in a '\0' byte.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.
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?
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?
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 issizeof aon a typical 64-bit system, and why is it not the array length?
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?
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:
{0} is the idiom for an all-zero array.int c[] = {...} lets the compiler count the initialisers for you.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.An array is a contiguous block of elements of the same type.
int a[N];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.
#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.
Walking through main and the helpers:
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.
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.
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.
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 |
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.
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) |
The final print_array shows 50 40 30 20 10.
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.
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.-Wall -Wextra; many array mistakes surface as warnings.Runtime errors
-fsanitize=address -g and rerun; AddressSanitizer prints the exact line and the bad index.Logic errors
< vs <=, start at 0 vs 1).Questions to ask when it does not work
[0, n-1]?sizeof after decay?Arrays are where most beginner memory bugs in C originate, because the language never checks bounds for you.
a[n], a[-1]): undefined behaviour. May return garbage or leak nearby data.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.size_t. Computing an index as n - 1 when n is 0 underflows to a huge value — guard empty arrays first.Protective habits:
i < n before every a[i].sizeof after decay.memcpy/memset over hand-rolled byte loops when copying or clearing; their bounds are explicit and easy to audit.-Wall -Wextra and test with -fsanitize=address,undefined during development to catch bounds and UB problems early.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:
scores, samples), not arr.[0, n-1]; write i < n, not i <= n.= {0}) when you cannot fill them immediately.Advanced rules:
size_t for sizes and indices, and validate any length that comes from input before allocating or indexing.memcpy, memmove, memset) with carefully computed sizes over manual loops.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 40 → sum = 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 1 → max = 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 = 3 → a 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.
a[i] is the element at offset i, and valid indices run 0 to N-1.[0, N-1] is undefined behaviour and the source of buffer overflows. Always guard with i < n.sizeof a / sizeof a[0] counts elements only on a real array in scope, never on a decayed pointer.int a[N] = {0};, a[i], for (size_t i = 0; i < n; i++), and the element-count sizeof trick.<=), sizeof on a parameter, returning a pointer to a local array, and seeding a max with 0.-Wall -Wextra and AddressSanitizer to catch slips.