Skip to content

C lesson 6 of 8

Pointers in C

What a pointer actually holds, how & and * work together, why pointer arithmetic counts elements rather than bytes, how passing a pointer lets a function change its caller's variable, and how to write a working swap.

Published · Every example on this page was run before it was published.

Every variable in a running program lives somewhere: at some numbered position in memory. A pointer is a variable whose value is one of those positions. That is the entire idea, and everything pointers are used for follows from it - letting a function reach a caller's variable, walking an array, building a structure whose size is not known until the program runs, and handling text without copying it.

Pointers have a reputation for difficulty that comes almost entirely from two symbols doing double duty. Take the symbols slowly and the concept is small.

& Gives an Address, * Follows One

&x means "the address of x". A variable declared with a * in its type holds such an address, and *p means "the thing p points at".

C
#include <stdio.h>

int main(void) {
    int count = 42;
    int *p = &count;

    printf("count is %d\n", count);
    printf("*p is %d\n", *p);

    *p = 7;

    printf("count is now %d\n", count);
    printf("and *p is %d\n", *p);
    return 0;
}
Output
count is 42
*p is 42
count is now 7
and *p is 7

Read the declaration int *p as "p is a pointer to int", or equivalently "*p is an int". The * in a declaration is part of the type; the * in an expression is an operator that follows the pointer. They look identical and mean different things, which is the one genuine cruelty of the syntax.

The important line is *p = 7;. It did not change p - p still holds the same address. It changed what lives at that address, which is count. Following a pointer to reach the value at the other end is called dereferencing.

The type matters as much as the address. An int * and a double * may hold numerically similar addresses, but they promise different things about how to read the bytes there, and how far apart consecutive elements are. A pointer is always a pointer to something.

C
#include <stdio.h>

int main(void) {
    printf("int      %zu\n", sizeof(int));
    printf("double   %zu\n", sizeof(double));
    printf("int *    %zu\n", sizeof(int *));
    printf("double * %zu\n", sizeof(double *));
    printf("char *   %zu\n", sizeof(char *));
    return 0;
}
Output
int      4
double   8
int *    4
double * 4
char *   4

The things being pointed at are different sizes; the pointers are all the same size, because an address is an address. Four bytes here, because this site's target is 32-bit WebAssembly; eight bytes on a 64-bit desktop. As ever, write sizeof rather than the number.

Pointers and Arrays

An array's name, used in almost any expression, becomes a pointer to its first element. This is called decay, and it is why indexing and pointers are two spellings of one operation: p[i] is defined to mean *(p + i).

C
#include <stdio.h>

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

    printf("%d\n", *p);
    printf("%d\n", *(p + 1));
    printf("%d\n", *(p + 4));
    printf("%d\n", p[2]);
    printf("%d\n", values[2]);

    int *last = &values[4];
    printf("elements apart: %d\n", (int) (last - p));
    return 0;
}
Output
10
20
50
30
30
elements apart: 4

int *p = values; needs no &, because values already turns into an address. From there, p[2] and values[2] are the same expression written two ways.

The line to study is *(p + 1). Adding 1 to an int * does not move one byte along - it moves one element, four bytes here, because the compiler knows what p points at. That is pointer arithmetic, and it is why the type of a pointer matters so much. Subtracting two pointers into the same array runs the rule backwards and gives the number of elements between them, which is why last - p is 4 rather than 16.

Pointer arithmetic is only defined inside an array, or one position past its end - and that one-past-the-end address may be compared but never dereferenced. Computing an address further out than that is undefined behaviour even if you never follow it.

Walking with a pointer is an equally common style:

C
#include <stdio.h>

int main(void) {
    double readings[3] = {1.5, 2.25, 3.75};
    double *p = readings;

    for (int i = 0; i < 3; i++) {
        printf("%.2f\n", *p);
        p++;
    }
    return 0;
}
Output
1.50
2.25
3.75

p++ advances by one double, eight bytes. Whether you prefer this or readings[i] is mostly taste; the index version is easier to read, and the pointer version is what a great deal of existing C looks like.

Passing an Array Really Means Passing a Pointer

This is the mechanism behind the sizeof warning from the arrays lesson.

C
#include <stdio.h>

void show_first(int *data) {
    printf("first element: %d\n", data[0]);
    printf("sizeof the parameter: %zu\n", sizeof data);
}

int main(void) {
    int values[5] = {2, 4, 6, 8, 10};

    printf("sizeof the array in main: %zu\n", sizeof values);
    show_first(values);
    return 0;
}
Output
sizeof the array in main: 20
first element: 2
sizeof the parameter: 4

In main the compiler can still see the declaration int values[5], so sizeof values is 20 bytes. Inside show_first all that arrived was an address, so sizeof data is the size of a pointer. Writing the parameter as int data[] instead changes nothing at all - it is the same pointer, just spelled to look like an array.

The consequence is the C convention you will see everywhere: a function that takes an array takes its length beside it.

C
#include <stdio.h>

int sum_of(const int *data, int length) {
    int total = 0;

    for (int i = 0; i < length; i++) {
        total += data[i];
    }
    return total;
}

int main(void) {
    int values[4] = {3, 1, 4, 1};

    printf("sum: %d\n", sum_of(values, 4));
    printf("sum of the first two: %d\n", sum_of(values, 2));
    printf("sum of the last two: %d\n", sum_of(values + 2, 2));
    return 0;
}
Output
sum: 9
sum of the first two: 4
sum of the last two: 5

const int *data says this function will read through the pointer and never write through it. The compiler enforces that, so const here is a promise to the caller that their array is safe, checked rather than documented. Put it on every pointer parameter you do not intend to write through; it costs nothing and it tells a reader what a function does before they read the body.

The third call, sum_of(values + 2, 2), is worth noticing: because an array is passed as a pointer, any position inside it can be treated as the start of a shorter array. That is the whole basis of how recursive array algorithms are written in C.

Passing Pointers So a Function Can Change Something

Pass by value means a function cannot touch a caller's variable. Pass a pointer and it can - not because the rule changed, but because a copy of an address still leads to the same place.

C
#include <stdio.h>

void add_one(int *target) {
    *target = *target + 1;
}

int main(void) {
    int tally = 5;

    add_one(&tally);
    add_one(&tally);

    printf("tally is %d\n", tally);
    return 0;
}
Output
tally is 7

The & at the call site and the * in the body are a matched pair, and forgetting either is the usual mistake. Note that target itself is still a copy: assigning to target inside the function would change nothing outside. It is *target - the thing at the far end - that reaches the caller.

That mechanism also lets a function produce more than one result, which a single return cannot do:

C
#include <stdio.h>

void divide(int numerator, int denominator, int *quotient, int *remainder) {
    *quotient = numerator / denominator;
    *remainder = numerator % denominator;
}

int main(void) {
    int q = 0;
    int r = 0;

    divide(47, 5, &q, &r);
    printf("47 / 5 = %d remainder %d\n", q, r);

    divide(-47, 5, &q, &r);
    printf("-47 / 5 = %d remainder %d\n", q, r);
    return 0;
}
Output
47 / 5 = 9 remainder 2
-47 / 5 = -9 remainder -2

Two values out of one call, through two output parameters. The truncation-toward-zero rule from the variables lesson is visible in the second line. A more careful version of divide would also check that denominator is not zero and that neither pointer is null, and would return a success flag - that shape, int do_thing(inputs, outputs) returning zero for success, is one of the most common in C libraries.

The Working Swap

Swapping two variables is the standard demonstration, because the broken version compiles and runs without complaint.

C
#include <stdio.h>

void broken_swap(int a, int b) {
    int temp = a;
    a = b;
    b = temp;
}

int main(void) {
    int left = 1;
    int right = 9;

    broken_swap(left, right);
    printf("left=%d right=%d\n", left, right);
    return 0;
}
Output
left=1 right=9

The swap did happen - to a and b, two local copies which were then discarded. Nothing was ever wrong enough to report.

Take addresses instead, and the same three lines work:

C
#include <stdio.h>

void swap(int *a, int *b) {
    int temp = *a;
    *a = *b;
    *b = temp;
}

int main(void) {
    int left = 1;
    int right = 9;

    printf("before: left=%d right=%d\n", left, right);
    swap(&left, &right);
    printf("after:  left=%d right=%d\n", left, right);
    return 0;
}
Output
before: left=1 right=9
after:  left=9 right=1

Every a and b in the body gained a *, and the two calls gained an &. The temporary is still an ordinary int - you are moving values between two known locations, not moving the locations themselves.

NULL, and Why You Must Check It

NULL is a pointer value guaranteed not to be the address of any object. It is the standard way to say "this pointer does not currently point at anything", and functions that might fail to give you something return it.

Dereferencing a null pointer is undefined behaviour. The only defence is to check.

C
#include <stdio.h>

void report(const int *value) {
    if (value == NULL) {
        printf("nothing to report\n");
        return;
    }
    printf("value is %d\n", *value);
}

int main(void) {
    int reading = 17;

    report(&reading);
    report(NULL);
    return 0;
}
Output
value is 17
nothing to report

It is worth being blunt about why that if is not optional, especially here. On a typical desktop operating system, following a null pointer usually stops the program immediately, because address zero is deliberately left unmapped - unpleasant, but at least loud. In the WebAssembly sandbox this site runs your code in, address zero is ordinary readable memory, so a read through a null pointer does not necessarily stop anything or print anything unusual. The bug is exactly as real and exactly as undefined; it just does not announce itself. Never treat "it seemed to run" as evidence that a pointer was valid, and never leave out the check because a test happened not to crash.

Two habits follow. Check any pointer that came from somewhere you do not control, before the first dereference. And set a pointer to NULL when it stops being valid, so that a later mistake has something to test for.

Pointers to Characters

A string is a run of chars, and the natural way to refer to one without copying it is a char *.

C
#include <stdio.h>
#include <string.h>

int main(void) {
    const char *text = "pallet";

    printf("%s\n", text);
    printf("first character: %c\n", *text);
    printf("fourth character: %c\n", text[3]);
    printf("strlen says: %zu\n", strlen(text));

    size_t counted = 0;
    const char *walk = text;

    while (*walk != '\0') {
        counted++;
        walk++;
    }

    printf("counted: %zu\n", counted);
    return 0;
}
Output
pallet
first character: p
fourth character: l
strlen says: 6
counted: 6

That while loop is strlen, written out. It starts at the first byte and advances until it finds the terminator, counting as it goes.

The const on const char *text matters. A string literal may live in memory the program is not allowed to write to, so modifying one through a pointer is undefined behaviour; const makes the compiler stop you instead. When you need a writable string, declare an array - char text[] = "pallet"; - which copies the literal into storage of your own.

A Worked Example

Reversing an array in place with two pointers walking toward each other, using the swap from earlier.

C
#include <stdio.h>

void swap(int *a, int *b) {
    int temp = *a;
    *a = *b;
    *b = temp;
}

void reverse(int *data, int length) {
    int *front = data;
    int *back = data + length - 1;

    while (front < back) {
        swap(front, back);
        front++;
        back--;
    }
}

void print_all(const int *data, int length) {
    for (int i = 0; i < length; i++) {
        printf("%d", data[i]);
        if (i < length - 1) {
            printf(" ");
        }
    }
    printf("\n");
}

int main(void) {
    int values[5] = {11, 22, 33, 44, 55};

    print_all(values, 5);
    reverse(values, 5);
    print_all(values, 5);
    return 0;
}
Output
11 22 33 44 55
55 44 33 22 11

reverse receives an address and a length, which is all it can receive - the array itself never moves. back starts at data + length - 1, the last element; note the - 1, without which back would be the one-past-the-end position, legal to compute and illegal to dereference.

The condition front < back is a comparison between two pointers into the same array, which is well defined and means "front is at a lower position". It stops when they meet or cross, so an odd-length array leaves its middle element alone - correctly, since it is already where it belongs. Five elements means two swaps, not five.

print_all takes a const int *, because printing has no business modifying anything, and the compiler will hold it to that. reverse cannot be const, because changing the array is its whole purpose. That difference, visible in the signature, tells a reader which of the two to be careful with.

Common Mistakes

Dereferencing a pointer that was never given a value. int *p; *p = 5; writes through whatever address happened to be in p. Initialise every pointer, to a real address or to NULL.

Confusing the two meanings of *. In int *p = &count; the * is part of the type. In *p = 7; it is the dereference operator. Read the first as "pointer to int" and the second as "the thing p points at".

Leaving out the & at a call site. add_one(tally) where the parameter is int * passes an int where an address was wanted; the compiler will report a type mismatch, so this one is caught. The harder version is passing & where it was not wanted, which can also be a type error, or - between compatible pointer types - can compile and be wrong.

Assuming pointer arithmetic counts bytes. p + 1 advances by sizeof(*p). If you want byte-level movement, use a char *, where one element is one byte by definition.

Going one element too far. data + length is the one-past-the-end position: fine to hold and compare, never to dereference. data + length - 1 is the last element.

Returning a pointer to a local variable. The local disappears when the function returns, so the pointer is left aiming at memory that has been given back. Return the value itself, take an output pointer from the caller, or allocate - which is the next lesson.

Skipping a null check because nothing crashed. In this sandbox, reading through a null pointer does not reliably fail. A test passing is not evidence the pointer was good.

Comparing strings with == because they are pointers. Comparing two char *s compares addresses. Comparing the text is strcmp.

Next Steps

In the C playground, write a function void bump(int *value, int by) and call it twice on the same variable. Then deliberately drop the & at one call site and read the error, so you recognise it later. Finally, write a function that takes an array and two output pointers and fills them with the smallest and largest values it found - that single exercise uses almost everything on this page.

The next lesson is dynamic memory: malloc, free, and how to hand a block of data out of a function without it vanishing.