Procedural Programming with C · Intermediate · Week 15

Pointer Arithmetic and Arrays

Week 13 established that values[i] means *(values + i). This week takes that identity seriously and develops the arithmetic behind it — including the rules that say which pointer operations are legal and which are quietly undefined.

By the end of this week you can
  • Explain why adding 1 to a pointer advances by the element size rather than one byte.
  • Subtract two pointers and say what ptrdiff_t is for.
  • Traverse an array or string with a moving pointer instead of an index.
  • Build and use arrays of pointers and pointers to pointers.
  • State the rule about the one-past-the-end pointer and why it exists.

1Arithmetic scales by the element

Adding an integer to a pointer does not add bytes. It advances by that many elements:

int values[5] = { 10, 20, 30, 40, 50 };
int *p = values;

p + 1      /* four bytes further on, pointing at values[1] */
p + 3      /* twelve bytes further on, pointing at values[3] */
102030 4050 pp+1p+2 p+3p+4 100010041008 10121016 p+5 one-past-the-end is a legal address to form, but not to read

Each step is sizeof(int) bytes. The pointer's type is what supplies the scale.

Because of this scaling, the two notations are interchangeable:

values[2]      /* 30 */
*(values + 2)  /* 30 — the same expression, by definition */
p[2]           /* 30 */
*(p + 2)       /* 30 */
2[values]      /* 30 — legal, since a+b == b+a. Never write this. */

That last line is a genuine curiosity of the language and occasionally appears in puzzles. It follows directly from indexing being defined as addition.

Increment and decrement

p++;      /* advance one element */
p--;      /* back one element     */
p += 3;   /* forward three        */

Only integers may be added to pointers. Adding two pointers is meaningless and does not compile.

2Subtraction and ptrdiff_t

Subtracting two pointers into the same array yields the number of elements between them:

int *start = &values[1];
int *end   = &values[4];
ptrdiff_t gap = end - start;     /* 3, not 12 */

The result has type ptrdiff_t, from <stddef.h> — a signed integer type wide enough to hold the difference. It is signed because the difference can be negative, which is exactly why it is not size_t. Print it with %td.

This is how my_strlen in week 14 worked: walk a pointer to the terminator, then subtract the starting position.

Comparison

Pointers into the same array may be compared with <, >, <=, >=, which gives the idiomatic loop:

for (int *q = values; q < values + n; q++) {
    printf("%d ", *q);
}

Any two pointers may be compared with == and !=, including against NULL.

3The rules you may not break

Pointer arithmetic is only defined within a single array, plus one position past its end. Outside that, the behavior is undefined — not merely unpredictable.

ExpressionLegal?
values + 5 for a 5-element arrayYes — one past the end may be formed
*(values + 5)No — it may not be dereferenced
values + 6No — undefined even without dereferencing
values - 1No — before the start is undefined
Comparing pointers into two different arraysUnspecified with <; fine with ==

The one-past-the-end allowance exists precisely so the loop above can terminate: q < values + n must be able to form values + n. The standard grants that address and nothing beyond it.

Why values - 1 matters in practice. A reverse loop written as for (int *q = values + n - 1; q >= values; q--) forms values - 1 on the final decrement, before the comparison fails. That is undefined behavior, and on a segmented or checked implementation it can genuinely misbehave. Write it as for (int *q = values + n; q-- > values; ) instead — the same shape as the size_t countdown from week 12.

4Index or pointer?

Both traversals below are correct. Which to write is a real question with a defensible answer.

/* index style */
for (size_t i = 0; i < n; i++) {
    total += values[i];
}

/* pointer style */
for (const int *q = values; q < values + n; q++) {
    total += *q;
}
Index stylePointer style
The position is available for messages and comparisonsNo index variable to keep in sync
Reads naturally to most peopleNatural for strings, where the end is a terminator, not a count
Works unchanged for multi-dimensional accessHistorically produced better code

That last row needs qualifying. On 1980s compilers, pointer walking really was faster because address computation was expensive. Modern compilers apply strength reduction and produce identical machine code for both. You can verify this yourself with the -S technique from week 2, and the worked example below does exactly that.

Prefer index style by default, and use pointer style where it genuinely reads better — walking a string to its terminator, or advancing through a buffer whose remaining length you track separately.

5Pointers to pointers, and arrays of pointers

A pointer is an object, so it has an address, so there can be a pointer to it:

int value = 42;
int *p = &value;
int **pp = &p;

**pp        /* 42          */
*pp         /* p, an int * */

Two places this appears constantly. An array of pointers, typically to strings:

const char *names[] = { "Ada", "Dennis", "Ken" };
size_t count = sizeof names / sizeof names[0];

for (size_t i = 0; i < count; i++) {
    printf("%s (%zu characters)\n", names[i], strlen(names[i]));
}

The array holds three pointers, each pointing at a literal somewhere in read-only memory. The strings need not be the same length, because the array stores addresses rather than the text. This is exactly the shape of argv in week 23.

And an output parameter that is itself a pointer, which week 19 needs for reallocation:

static void allocate(int **out, size_t n)
{
    *out = malloc(n * sizeof **out);   /* writes into the caller's pointer */
}

int *data = NULL;
allocate(&data, 10);

The rule is the same one as week 13: to modify a caller's variable you pass its address. If the variable is an int *, its address is an int **.

int *a[5] versus int (*a)[5]. The first is an array of five pointers; the second is a pointer to an array of five ints. Brackets bind tighter than the asterisk, so parentheses change the meaning entirely. Week 16 gives the right-left rule that makes such declarations readable rather than memorized.

6Worked example: the same loop, three ways

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

/* Index style. */
static long sum_indexed(const int *values, size_t n)
{
    long total = 0;
    for (size_t i = 0; i < n; i++) {
        total += values[i];
    }
    return total;
}

/* Pointer style: identical machine code on any modern compiler. */
static long sum_pointer(const int *values, size_t n)
{
    long total = 0;
    for (const int *q = values; q < values + n; q++) {
        total += *q;
    }
    return total;
}

/* Pointer style where it genuinely fits: no count is available,
   the terminator ends the walk. */
static size_t length_of(const char *s)
{
    const char *start = s;
    while (*s != '\0') {
        s++;
    }
    return (size_t)(s - start);
}

/* Reverse traversal done safely: never forms values - 1. */
static void print_backwards(const int *values, size_t n)
{
    for (const int *q = values + n; q-- > values; ) {
        printf("%d ", *q);
    }
    putchar('\n');
}

/* An output parameter that is itself a pointer. */
static void point_at_largest(int *values, size_t n, int **out)
{
    if (n == 0) {
        *out = NULL;
        return;
    }
    int *best = values;
    for (int *q = values + 1; q < values + n; q++) {
        if (*q > *best) {
            best = q;
        }
    }
    *out = best;
}

int main(void)
{
    int values[] = { 10, 20, 30, 40, 50 };
    const size_t n = sizeof values / sizeof values[0];
    int *p = values;

    puts("== scaling ==");
    printf("  sizeof(int)        = %zu\n", sizeof(int));
    printf("  p                  = %p\n", (void *)p);
    printf("  p + 1              = %p   (+%td bytes)\n",
           (void *)(p + 1), (char *)(p + 1) - (char *)p);
    printf("  p + 3              = %p   (+%td bytes)\n",
           (void *)(p + 3), (char *)(p + 3) - (char *)p);

    puts("\n== the four spellings of one element ==");
    printf("  values[2]=%d  *(values+2)=%d  p[2]=%d  *(p+2)=%d\n",
           values[2], *(values + 2), p[2], *(p + 2));

    puts("\n== subtraction gives elements, not bytes ==");
    int *first = &values[1];
    int *last  = &values[4];
    printf("  &values[4] - &values[1] = %td elements\n", last - first);

    puts("\n== three traversals, one answer ==");
    printf("  sum_indexed = %ld\n", sum_indexed(values, n));
    printf("  sum_pointer = %ld\n", sum_pointer(values, n));
    printf("  backwards:    ");
    print_backwards(values, n);

    puts("\n== pointer style where it belongs ==");
    const char *text = "pointer arithmetic";
    printf("  length_of(\"%s\") = %zu   strlen = %zu\n",
           text, length_of(text), strlen(text));

    puts("\n== array of pointers ==");
    const char *names[] = { "Ada", "Dennis", "Ken", "Bjarne" };
    const size_t names_count = sizeof names / sizeof names[0];
    printf("  the array holds %zu pointers, %zu bytes total\n",
           names_count, sizeof names);
    for (size_t i = 0; i < names_count; i++) {
        printf("    names[%zu] -> \"%s\" (%zu chars)\n",
               i, names[i], strlen(names[i]));
    }

    puts("\n== pointer to pointer as an output parameter ==");
    int *largest = NULL;
    point_at_largest(values, n, &largest);
    if (largest != NULL) {
        printf("  largest value %d is at index %td\n",
               *largest, largest - values);
    }

    puts("\n== one past the end ==");
    const int *end = values + n;
    printf("  values + %zu is a legal address to form: %p\n", n, (const void *)end);
    puts("  dereferencing it would be undefined behavior");

    return EXIT_SUCCESS;
}
gcc -std=c17 -Wall -Wextra -g -fsanitize=address -o ptrmath ptrmath.c
./ptrmath

Prove that index and pointer style compile the same

Do not take the claim on trust. Generate the assembly at -O2 and compare the two functions:

gcc -std=c17 -O2 -S ptrmath.c -o ptrmath.s
sed -n '/sum_indexed:/,/ret/p' ptrmath.s
sed -n '/sum_pointer:/,/ret/p' ptrmath.s

On x86-64 with GCC the two bodies are typically instruction-for-instruction identical. The compiler recognizes both as the same loop and emits the same code. Choose the one that reads better, and let week 34 handle the cases where the generated code really does matter.

Break a rule on purpose

/* The reverse loop written the tempting way. */
for (const int *q = values + n - 1; q >= values; q--) {
    printf("%d ", *q);
}

This appears to work everywhere. It is still undefined: on the final iteration q-- forms values - 1, an address before the array. The sanitizer will not report it and the program will not crash — which is precisely why knowing the rule matters more here than testing does. The q-- > values form in the example above never forms that address.

Now try one the sanitizer will catch:

printf("%d\n", *(values + n));    /* dereferencing one past the end */
==1234==ERROR: AddressSanitizer: stack-buffer-overflow
READ of size 4 at 0x7ffd... 'values' (line 96)

7Common mistakes

MistakeWhat happensFix
Expecting p + 1 to advance one byteIt advances sizeof(*p) bytesCast to char * if you really want bytes.
for (q = v + n - 1; q >= v; q--)Forms v - 1: undefinedfor (q = v + n; q-- > v; ).
Dereferencing one past the endOut-of-bounds readThe address may be formed but never read.
Printing ptrdiff_t with %dFormat mismatch on 64-bit%td.
Comparing pointers into different arrays with <Unspecified resultOnly == and != are meaningful across objects.
int *a[5] when you meant int (*a)[5]An array of pointers, not a pointer to an arrayParenthesize; see week 16's reading rule.
Losing the original pointer by incrementing itCannot free or restartWalk a copy, keep the original.
Believing pointer style is fasterLess readable code, identical outputCompare the assembly and stop worrying.

8Check yourself

Why does p + 1 advance four bytes for an int * but one byte for a char *?

Because pointer arithmetic counts elements, not bytes, and the pointer's type supplies the element size. This is what makes p[i] equivalent to *(p + i): the scaling is exactly what indexing needs. To move by raw bytes, cast to char *, whose element size is 1 by definition.

What does subtracting two pointers give, and what type is it?

The number of elements between them, not bytes, with type ptrdiff_t from <stddef.h>. It is signed because the difference can be negative, which is why it is not size_t. Print it with %td. It is only defined for pointers into the same array.

Why is forming values + n legal but values + n + 1 not?

The standard specifically permits a pointer one past the last element so that loop conditions such as q < values + n can be written. Anything beyond that — or before the first element — is undefined even if you never dereference it, because an implementation is allowed to trap on forming such an address.

What is the difference between int *a[5] and int (*a)[5]?

Brackets bind tighter than the asterisk, so the first is an array of five int * — five separate pointers. The parentheses in the second force the pointer to apply first, giving a single pointer to an array of five int. This is the declaration form used when passing a two-dimensional array to a function.

Is pointer-style traversal faster than index-style?

Not on any compiler released in the last twenty-five years. Strength reduction turns the indexed form into the same address arithmetic, and the generated assembly is typically identical — which you can confirm with gcc -O2 -S. Choose on readability: index style by default, pointer style for strings and buffers where there is no count to index against.

9Where this leads

Week 16 turns pointers into a design tool rather than a mechanism: output parameters, const correctness as a contract the compiler enforces, the dangling-return trap, and a systematic method for reading declarations like char *(*f[3])(int) without guessing. After that, week 19 hands you memory that you must manage yourself.