In this lesson:
- the identity at the heart of C:
arr[i]is*(arr + i) - why
p + 1advances bysizeof(*p)bytes, not one byte - array-to-pointer decay and the size information it destroys
- iterating with pointers instead of indices
- the one-past-the-end rule and where pointer arithmetic becomes undefined
Arrays and pointers are deeply related
An array name, used in an expression, decays to a pointer to its first element. That's why this works:
int arr[4] = {10, 20, 30, 40};
int *p = arr; // no &needed: 'arr' becomes &arr[0]
printf("%d\n", *p); // 10
printf("%d\n", arr[0]); // 10 β same thingThe indexing operator is defined in terms of pointer arithmetic. The compiler literally rewrites arr[i] as *(arr + i):
arr[2] // what you write
*(arr + 2) // what the compiler does
2[arr] // legal (!) but cursed β *(2 + arr) is the same expressionThat last line compiles and works, because addition commutes. Don't write it, but it proves that [] is pure pointer math underneath.
Pointer arithmetic scales by type
Here's the part that surprises beginners: p + 1 does not add 1 to the address. It adds sizeof(*p), one element, not one byte.
int arr[4] = {10, 20, 30, 40};
int *p = arr; // suppose p == 0x1000
p + 0 // 0x1000 β arr[0]
p + 1 // 0x1004 β arr[1] (int is 4 bytes, so +4 bytes)
p + 2 // 0x1008 β arr[2] p p+1 p+2 p+3
β β β β
βΌ βΌ βΌ βΌ
ββββββ¬βββββ¬βββββ¬βββββ
β 10 β 20 β 30 β 40 β each cell = 4 bytes (int)
ββββββ΄βββββ΄βββββ΄βββββ
0x1000 0x1008This is why the pointer's type matters: a char * advances by 1 byte, an int * by 4, a struct foo * by sizeof(struct foo). The compiler does the scaling for you, so you always think in elements.
Subtraction works too: &arr[3] - &arr[0] is 3 (the number of elements between them), not 12.
Iterating with pointers
Anything you can do with an index, you can do by walking a pointer. Two equivalent loops:
int arr[4] = {10, 20, 30, 40};
// index style
for (int i = 0; i < 4; i++) {
printf("%d ", arr[i]);
}
// pointer style β walk from the start to one-past-the-end
for (int *p = arr; p < arr + 4; p++) {
printf("%d ", *p);
}The pointer style uses arr + 4, a pointer to one past the last element, as the stop condition. C explicitly allows you to compute and compare that one-past pointer (it's a valid loop sentinel), but dereferencing it is undefined behavior. More on that below.
Decay: the size disappears at the function boundary
While arr is a real array in its defining scope, sizeof knows its full size. The instant you pass it to a function, it decays to a bare pointer and that knowledge is gone:
void f(int a[]) { // identical to: void f(int *a)
printf("%zu\n", sizeof(a)); // 8 β size of a POINTER, not the array
}
int main(void) {
int arr[4] = {1, 2, 3, 4};
printf("%zu\n", sizeof(arr)); // 16 β 4 ints Γ 4 bytes
f(arr); // decays to &arr[0]; size info lost
return 0;
}Even though the parameter is written int a[], the compiler treats it as int *a. The [] is cosmetic. This is the reason every function that takes an array must also take its length:
int sum(const int *arr, size_t n) { // length passed explicitly
int total = 0;
for (size_t i = 0; i < n; i++) total += arr[i];
return total;
}sizeof(arr) / sizeof(arr[0]) to get a length only works in the scope where arr is a true array, never inside a function that received it as a parameter.
Arrays are not pointers (a few real differences)
Decay makes them feel interchangeable, but they aren't the same type:
True array int arr[4] |
Pointer int *p |
|
|---|---|---|
sizeof |
16 (whole array) | 8 (the pointer) |
&arr type |
int (*)[4] (pointer to array) |
int ** |
| Assignable? | No, arr = ... is illegal |
Yes, p = ... fine |
| Storage | the elements themselves | just an address |
A useful mental model: an array is its elements; a pointer merely refers to elements somewhere else.
The one-past-the-end rule
C defines exactly one out-of-bounds pointer you're allowed to form: the address one element past the end of an array. You may compute it and compare against it (it's the canonical loop terminator), but you may not dereference it.
int arr[4];
int *end = arr + 4; // OK to form and compare
for (int *p = arr; p < end; p++) { ... } // fine
int *bad = arr + 5; // UB to even *form* this pointer
int x = *(arr + 4); // UB β dereferencing one-past-the-endGoing further than one-past, forming arr + 5 or arr - 1, is undefined behavior even if you never dereference it. Optimizers exploit this, so "it didn't crash" doesn't mean it's correct.
Gotchas
p + 1moves one element, not one byte. The scaling is bysizeof(*p). Casting a pointer to a different type changes the stride, a frequent bug when reinterpreting buffers.- Decay erases the size. Inside a function,
sizeof(param)is the pointer size. Always pass length alongside the array. - Don't dereference one-past-the-end. Computing
arr + nas a sentinel is legal; reading*(arr + n)is UB. Forming a pointer beyond one-past (or before the start) is UB even without dereferencing. char *vsint *strides differ. When you cast a byte buffer toint *and walk it, each++jumps 4 bytes. Mixing this up corrupts data or misaligns reads (and unaligned access faults on some MCUs).
TL;DR
arr[i]is defined as*(arr + i), indexing is pointer arithmetic in disguise.- Pointer arithmetic scales by element size:
p + 1advancessizeof(*p)bytes. The pointer's type sets the stride. - An array name decays to a pointer to its first element in most expressions, including when passed to a function, which loses the size. Always pass the length too.
- Arrays and pointers are different types:
sizeof,&, and assignability all differ. An array is its storage; a pointer refers to storage. - You may form and compare a one-past-the-end pointer as a loop sentinel, but dereferencing it, or forming any pointer further out of bounds, is undefined behavior.