Pointers & Memory · beginner · ~10 min

Pointers and arrays

- Explain what **array-to-pointer decay** is and name the exact contexts where it happens. - Prove to yourself why `a[i]` and `*(a + i)` produce the identical value and address. - Identify the two places decay does **not** happen — `sizeof` and `&a` — and predict what each yields. - Pass arrays into functions correctly and understand why the length must travel as a separate argument. - Recognise and fix the classic `sizeof(arr)/sizeof(arr[0])`-inside-a-function bug. - Read and write array elements safely by walking a pointer instead of indexing.

Overview

An array is a fixed-length block of elements laid out back-to-back in memory. A pointer is a variable that holds an address. These two ideas feel separate, but in C they are wired together so tightly that beginners often confuse them. This lesson makes the connection precise: when you use an array's name in an expression, C almost always converts it into a pointer to its first element. That conversion is called array-to-pointer decay.

This builds directly on Pointer arithmetic. There you learned that adding i to a pointer moves it forward by i elements (not i bytes), because the compiler scales by the element size. That single rule is the engine behind the whole a[i] == *(a + i) equivalence you will see here. If pointer arithmetic still feels shaky, revisit it — everything below leans on it.

In plain language: writing a[i] is just a friendlier way of saying "start at the address of the array, step forward i elements, and look at what is there." Once you internalise that, indexing stops being magic and becomes ordinary arithmetic. The terminology to carry forward is: decay (array name becoming a pointer), element size (what pointer math scales by), and bounds (the valid range 0 .. n-1).

Why it matters

Almost every non-trivial C program stores data in arrays and hands them to functions. The moment you pass an array to a function, it decays to a pointer — and the length is gone. Misunderstanding this is the single most common source of two serious bugs: computing the wrong element count with sizeof inside a function, and reading or writing past the end of the array. The second one is not just a wrong answer; it is undefined behaviour and a classic security hole (buffer overflow).

Understanding decay also unlocks idiomatic C. String functions, qsort, memcpy, and virtually every standard-library routine that touches a buffer takes a pointer plus a length precisely because arrays decay. Once you see arrays and pointers as two views of the same memory, this API style stops looking arbitrary and starts looking obvious.

Core concepts

1. Array-to-pointer decay

Definition. In most expressions, an array's name is automatically converted to a pointer to its element 0. The type int[5] becomes int*, and the value is &a[0].

Plain-language explanation. The array name, when you use it (assign it, pass it, do arithmetic on it), evaluates to an address — the address of the first element. It does not copy the whole array; it just yields where the array starts.

How it works internally. An array like int a[5] is 5 contiguous int slots. The compiler knows the base address and the element size. When a decays, you get that base address typed as int*. From there, ordinary pointer arithmetic navigates the rest.

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

 address:  1000   1004   1008   1012   1016
          +------+------+------+------+------+
   value: |  10  |  20  |  30  |  40  |  50  |
          +------+------+------+------+------+
 index:      0      1      2      3      4

 a          decays to ->  1000   (an int*, pointing at element 0)
 a + 2      ==            1008   (base + 2 * sizeof(int))
 *(a + 2)   ==            30      == a[2]

When to rely on it / when NOT to. Rely on it every time you pass an array to a function or walk it with a pointer. Do NOT rely on it to remember the length: after decay, the count is not carried anywhere.

Common pitfall. Assuming a decayed pointer "knows" how many elements it points to. It does not. The pointer is just an address; the length lives only in your head (and in a separate variable you must pass along).

Knowledge check: In the expression int total = a[0] + a[1];, does the array a decay to a pointer? (Answer: yes — a[0] is *(a+0), and a is used as a value, so it decays.)

2. Why a[i] equals *(a + i)

Definition. The subscript operator is defined by the C standard as a[i] == *(a + i). It is not "similar" — it is the same operation.

Plain-language explanation. a decays to an address. a + i moves forward i elements. The * reads (or writes) whatever sits there. The [] is pure syntactic sugar over that.

How it works internally. Because addition is commutative, a + i equals i + a, so *(a + i) equals *(i + a), which means i[a] is legal C and equals a[i]. (Never write i[a] in real code — but it proves the point.)

Expression Meaning Yields
a[i] subscript the element's value / lvalue
*(a + i) deref of pointer math identical to a[i]
a + i pointer arithmetic address of element i
&a[i] address-of element also a + i

Knowledge check (predict the output): Given int a[3] = {7, 8, 9};, what does *(a + 2) print with %d? (Answer: 9.)

3. Two exceptions where arrays do NOT decay

Decay is the rule, but two operators see the whole array, not a pointer.

sizeof on a declared array gives the total bytes of the array. On a decayed pointer, it gives the pointer's size (typically 8 on 64-bit systems).

&a (address-of the array) has type "pointer to array of N," e.g. int(*)[5]. It points to the same byte as a/&a[0], but the type differs, so &a + 1 jumps forward by the whole array, not one element.

 int a[5];

 sizeof(a)      -> 20   (5 * sizeof(int)), array NOT decayed
 sizeof(&a[0])  -> 8    a pointer's size

 a      : type int*        , a + 1 moves 4 bytes
 &a     : type int(*)[5]   , &a + 1 moves 20 bytes
 &a[0]  : type int*        , same address as a

When to use &a. When you genuinely need a pointer to the whole array (rare for beginners, e.g. passing to a function declared void f(int (*p)[5])). Otherwise avoid it.

Common pitfall. Calling sizeof(arr)/sizeof(arr[0]) inside a function to get the length. Inside the function arr is already a pointer, so this computes 8 / 4 == 2 regardless of the real size.

Knowledge check (find the bug): A function receives int scores[] and does int n = sizeof(scores)/sizeof(scores[0]);. Why is n wrong? (Answer: scores decayed to int*; sizeof(scores) is the pointer size, not the array size, so n is meaningless — usually 2 on a 64-bit machine.)

4. Passing arrays to functions

Because of decay, these three parameter declarations are exactly equivalent:

 void f(int a[10]);   // the 10 is ignored by the compiler
 void f(int a[]);     // same
 void f(int *a);      // same — this is the truth of it

The caller passes an address; the callee receives a pointer. The length is not transmitted, so you pass it as a separate parameter (int n). This is why nearly every array-taking C function has a (pointer, length) signature.

Knowledge check (explain in your own words): Why must you pass the length separately when you pass an array to a function? (Because the array decays to a bare pointer, which carries an address but no size information.)

Syntax notes

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

int x = a[2];        // subscript: same as *(a + 2)  -> 30
int y = *(a + 2);    // explicit pointer math         -> 30
int *p = a;          // decay: p now holds &a[0]
int z = p[2];        // pointers subscript too         -> 30

size_t bytes = sizeof(a);          // 20: whole array, NO decay
size_t count = sizeof(a)/sizeof(a[0]);  // 5: valid ONLY where a is declared

int (*whole)[5] = &a;  // &a does NOT decay: pointer-to-array-of-5

Key points: subscripting works on any pointer, not just arrays. The sizeof-based count trick is valid only in the scope where the array is declared, never after it has been passed to a function.

Lesson

Arrays decay to pointers

In most contexts, the name of an array decays to a pointer to its first element. "Decay" here just means the array name is automatically treated as the address of element 0.

Because of this, a[i] is exactly the same as *(a + i).

  • a + i is the address of element i.
  • *(a + i) reads the value stored there.

This equivalence is also why a function that takes an array can declare the parameter either way:

  • int a[]
  • int *a

Both compile to the same thing.

Two important exceptions

Decay does not happen in every case. Watch out for these two:

  1. sizeof on a declared array gives the total size of the array in bytes. But if the array was passed into a function (where it has decayed to a pointer), sizeof returns the size of the pointer instead.
  2. &a (the address of the array) has a different type from a (a pointer to the first element). They point to the same place, but the compiler treats them as different types.

Code examples

#include <stdio.h> #include <stddef.h>

/* Sum n elements by walking a pointer from a to a + n. The array has already decayed to int* here, so we MUST receive the length separately. */ long sum_via_ptr(const int *a, size_t n) { long total = 0; for (const int *p = a; p < a + n; ++p) { total += p; / read the element p points at */ } return total; }

int main(void) { int scores[] = {10, 20, 30, 40, 50};

/* sizeof works here because 'scores' is DECLARED in this scope. */
size_t n = sizeof(scores) / sizeof(scores[0]);

printf("element count: %zu\n", n);
printf("sizeof(scores) = %zu bytes\n", sizeof(scores));

/* Prove a[i] == *(a + i) for every element. */
for (size_t i = 0; i < n; ++i) {
    printf("scores[%zu]=%d  *(scores+%zu)=%d  same=%s\n",
           i, scores[i], i, *(scores + i),
           scores[i] == *(scores + i) ? "yes" : "no");
}

/* Decay in action: passing the array yields a pointer. */
long s = sum_via_ptr(scores, n);
printf("sum = %ld\n", s);

return 0;

}

/* Expected output: element count: 5 sizeof(scores) = 20 bytes scores[0]=10 *(scores+0)=10 same=yes scores[1]=20 *(scores+1)=20 same=yes scores[2]=30 *(scores+2)=30 same=yes scores[3]=40 *(scores+3)=40 same=yes scores[4]=50 *(scores+4)=50 same=yes sum = 150

Edge cases: passing n = 0 makes the loop run zero times and returns 0, which is correct. Passing an n larger than the real array is undefined behaviour (out-of-bounds read). */

Line by line

  1. sum_via_ptr(const int *a, size_t n) — the parameter is written int *a, not int a[], to make the decay explicit: what actually arrives is a pointer. const promises we only read. n carries the length because the pointer cannot.
  2. for (const int *p = a; p < a + n; ++p)p starts at the first element. The bound a + n is the one-past-the-end address (legal to compute, not to dereference). ++p advances by one int (pointer arithmetic scales by element size).
  3. total += *p;*p reads the current element. Over the loop, p visits a, a+1, ... a+n-1.
  4. In main, int scores[] = {10,...,50}; declares an array of 5 ints — 20 bytes contiguous.
  5. sizeof(scores) / sizeof(scores[0]) — here scores is a declared array, so sizeof sees all 20 bytes; dividing by 4 gives 5. This line would break inside any function that received scores as a parameter.
  6. The proof loop prints scores[i] next to *(scores + i) and compares them — they match for every i, demonstrating the equivalence.
  7. sum_via_ptr(scores, n) — passing scores decays it to &scores[0]; the function receives that address plus the count.

Trace of sum_via_ptr for {10,20,30,40,50}, n = 5:

step p points at *p total after
1 element 0 10 10
2 element 1 20 30
3 element 2 30 60
4 element 3 40 100
5 element 4 50 150
stop p == a + 5 returns 150

Common mistakes

1. Counting elements with sizeof inside a function.

/* WRONG */
long sum(int a[], int unused) {
    int n = sizeof(a) / sizeof(a[0]);  /* a is int*, so this is 8/4 = 2 */
    long t = 0;
    for (int i = 0; i < n; i++) t += a[i];  /* only sums 2 elements! */
    return t;
}

Why it is wrong: a decayed to a pointer, so sizeof(a) is the pointer size, not the array size. Fix: pass the length in.

/* CORRECT */
long sum(const int *a, size_t n) {
    long t = 0;
    for (size_t i = 0; i < n; i++) t += a[i];
    return t;
}

How to catch it: if you compute a count from sizeof anywhere except the scope where the array is declared, treat it as a bug.

2. Confusing &a with a.

int a[5];
int *p  = a;   /* fine: points at a[0] */
int *q  = &a;  /* WRONG type: &a is int(*)[5], not int* */

Why it is wrong: &a and a point to the same byte but have different types; &a + 1 skips the whole array. Fix: use a (or &a[0]) when you want int*. Recognise it by the compiler warning "initialization from incompatible pointer type."

3. Treating a 2D array parameter as int **.

void f(int **m);        /* WRONG for a real 2D array */
void f(int m[][4]);     /* CORRECT: rows of 4 */

Why it is wrong: int grid[3][4] is 12 contiguous ints, not an array of pointers. int** assumes a pointer table that does not exist, so indexing reads garbage addresses. Fix: declare the column count so the compiler can compute row offsets.

Debugging tips

Compiler errors / warnings.

  • initialization from incompatible pointer type when assigning &a to an int* — you meant a or &a[0].
  • passing argument ... from incompatible pointer type when passing int(*)[N] where int* is expected. Turn on -Wall -Wextra; the compiler often flags decay mistakes for you.

Runtime errors.

  • A crash or garbage from reading past the end usually means your length argument is wrong or you looped with <= instead of <. Run under a sanitizer: gcc -fsanitize=address,undefined -g file.c reports out-of-bounds access with the exact line.

Logic errors.

  • Getting a count of 2 (on 64-bit) or 1 almost always means a sizeof/sizeof-inside-a-function bug: 8/4 = 2, 8/8 = 1.
  • A sum that is too small by a few elements: your loop bound (n) is off, or you decayed the array and lost the real length.

Questions to ask when it doesn't work.

  1. Is arr a declared array here, or a parameter that already decayed?
  2. Did I pass the length as a separate argument?
  3. Is my loop bound < n (correct) or <= n (off-by-one overflow)?
  4. Did I write &a where I meant a?

Memory safety

Arrays-as-pointers is where beginners first meet undefined behaviour, so treat these as hard rules.

  • Bounds. Valid indices are 0 .. n-1. a + n is a legal address to compute (one past the end) but illegal to dereference. Reading or writing a[n] (or beyond) is out-of-bounds — undefined behaviour, and the root of buffer-overflow vulnerabilities.
  • The length must travel with the pointer. After decay, the size is gone. Always pass n, and validate it: never trust a caller-supplied length that could exceed the real allocation.
  • Initialisation. Reading an uninitialised array element is undefined. Initialise (int a[5] = {0};) or write before you read.
  • const correctness. If a function only reads, declare the parameter const int *. This lets the compiler stop accidental writes and documents intent.
  • Overflow of the index type. For very large arrays use size_t for indices and counts (it is unsigned and sized for memory), not int, which can overflow.
  • Lifetimes. Never return a pointer to a local array from a function — the array dies when the function returns, and the pointer dangles. Return by value, or have the caller own the storage.

Defensive habit: compile with -Wall -Wextra -fsanitize=address,undefined during development. It catches most decay-related memory bugs at the exact moment they happen.

Real-world uses

Concrete use case. Every C standard-library function that processes a buffer relies on decay and the (pointer, length) convention: memcpy(dst, src, n), qsort(base, count, size, cmp), and fread(buf, size, count, fp) all take a decayed pointer plus counts. When you call qsort(scores, n, sizeof scores[0], cmp), the array scores decays to a pointer exactly as in this lesson. Operating-system kernels, network stacks (parsing packet byte buffers), and embedded firmware (reading sensor sample arrays) all pass fixed buffers around this way because it avoids copying.

Professional best-practice habits.

Beginner:

  • Always pair an array pointer with an explicit length parameter.
  • Compute element counts with sizeof(arr)/sizeof(arr[0]) only in the scope where the array is declared.
  • Use < (not <=) as the loop bound.
  • Name variables for what they hold (count, len, scores), not x/y.

Advanced:

  • Use const on read-only pointer parameters and size_t for sizes and indices.
  • Prefer passing the count alongside the pointer as adjacent parameters, in a consistent order across your codebase.
  • Enable and heed -Wall -Wextra; run tests under AddressSanitizer/UBSan in CI.
  • For fixed-size buffers, consider int (*p)[N] parameters so the compiler can enforce the length at the type level.

Practice tasks

Beginner 1 — Prove the equivalence. Declare int a[6] with any values. Loop over the indices and print, for each i, both a[i] and *(a + i), plus whether they are equal. Requirement: use size_t for the index. Expected: every line reports equal. Concepts: decay, subscript vs. pointer math.

Beginner 2 — Length in the right place. Write a main that declares an array, computes its element count with sizeof(arr)/sizeof(arr[0]), and prints it. Then pass the array to a function void show(const int *a, size_t n) that prints each element. Requirement: the count is computed in main, never inside show. Concepts: decay across a function boundary, passing length separately.

Intermediate 1 — Sum by walking a pointer. Implement long sum_via_ptr(const int *a, size_t n) using a moving pointer p (no [] indexing at all). Input: {3, 1, 4, 1, 5}. Output: sum = 14. Constraint: loop condition must be p < a + n. Hint: initialise const int *p = a; and ++p. Concepts: pointer arithmetic, one-past-the-end bound.

Intermediate 2 — Find the max and its index. Write int index_of_max(const int *a, size_t n) returning the index of the largest element (first one wins on ties). Input: {2, 9, 4, 9, 1}. Output: 2 (the first 9 is at index 1 — verify your tie rule). Constraint: return a sensible value for n == 0 and document it. Hint: track both the best value and its index. Concepts: bounds, indexing, edge cases.

Challenge — 2D sum without int**. Write long grid_sum(int rows, int cols, int m[][/* cols */]) (choose a fixed column count, e.g. int m[][4]) that sums all elements of a 2D array. Requirement: do NOT use int**; explain in a comment why m[i][j] works via row-offset arithmetic (*(*(m + i) + j)). Input: a 3x4 grid of your choice. Output: the total. Hint: the compiler needs the column count to compute where row i starts. Concepts: 2D arrays, why the column dimension cannot decay away.

Summary

  • An array name decays to a pointer to its first element in almost every expression, so passing an array really passes an address.
  • a[i] is defined as *(a + i) — same address, same value; [] is sugar over pointer arithmetic you already know.
  • The two exceptions are sizeof (gives the whole array's bytes) and &a (type pointer-to-array, not pointer-to-element).
  • Because decay discards the length, always pass the count as a separate argument; sizeof(arr)/sizeof(arr[0]) only works where the array is declared.
  • The dangerous mistakes: counting elements with sizeof inside a function, confusing &a with a, treating a 2D array as int**, and reading past the end (undefined behaviour / buffer overflow).
  • Remember: valid indices are 0 .. n-1, use < for the loop bound, prefer const and size_t, and compile with -Wall -Wextra -fsanitize=address,undefined to catch bounds bugs early.

Practice with these exercises