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".
#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;
}count is 42
*p is 42
count is now 7
and *p is 7Read 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.
#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;
}int 4
double 8
int * 4
double * 4
char * 4The 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).
#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;
}10
20
50
30
30
elements apart: 4int *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:
#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;
}1.50
2.25
3.75p++ 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.
#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;
}sizeof the array in main: 20
first element: 2
sizeof the parameter: 4In 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.
#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;
}sum: 9
sum of the first two: 4
sum of the last two: 5const 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.
#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;
}tally is 7The & 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:
#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;
}47 / 5 = 9 remainder 2
-47 / 5 = -9 remainder -2Two 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.
#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;
}left=1 right=9The 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:
#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;
}before: left=1 right=9
after: left=9 right=1Every 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.
#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;
}value is 17
nothing to reportIt 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 *.
#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;
}pallet
first character: p
fourth character: l
strlen says: 6
counted: 6That 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.
#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;
}11 22 33 44 55
55 44 33 22 11reverse 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.