Introduction to Pointers
Week 1 said memory is a numbered list of bytes. A pointer is simply a variable that holds one of those numbers. Everything difficult about pointers comes not from that idea but from what C lets you do with it — and this week closes three loose ends left open since week 8.
- Take the address of a variable and store it, and read the value back through it.
- Explain what a pointer's type is for, given that every address is the same size.
- Distinguish a null pointer, an uninitialized pointer, and a dangling pointer.
- Fix the
swapthat failed in week 11 and explain the&thatscanfdemanded in week 8. - Explain array decay, and why
sizeofchanged inside a function in week 12.
1Addresses
Every object in memory occupies a run of bytes starting at some address. The address-of operator & gives you that starting address:
int count = 42;
printf("value = %d\n", count);
printf("address = %p\n", (void *)&count);value = 42
address = 0x7ffd3c2a4b6cThe exact number is meaningless and will differ on every run — modern operating systems randomize layout deliberately. What matters is that the variable has an address, and that you can hold it.
The cast to void * in that printf is required: %p expects a void *, and passing any other pointer type is a mismatch of the kind week 8 warned about.
Declaring a pointer
int *p = &count; /* p holds the address of count */Read the declaration from the inside out: *p is an int, therefore p is a pointer to int. Week 16 turns that reading rule into a systematic method for declarations far worse than this one.
The asterisk belongs to the variable, not the type. This matters:
int* a, b; /* a is int*, b is a plain int — almost never intended */
int *a, *b; /* both are pointers */Writing int *p rather than int* p makes the trap visible, which is why most C style guides — and the standard library's own headers — attach the asterisk to the name.
Dereferencing
The indirection operator *, applied to a pointer, gives the object it points to:
printf("%d\n", *p); /* 42 — read through the pointer */
*p = 99; /* write through the pointer */
printf("%d\n", count); /* 99 — count itself changed */That last line is the whole point. p and count are different variables, but *p and count are the same object.
Two variables. One holds a number that happens to be the other's address.
2Why a pointer has a type
Every address is the same size — 8 bytes on a 64-bit machine, whatever it points to. So why does C distinguish int * from char *?
Two reasons, and both are load-bearing.
Dereferencing needs to know how many bytes to read. *p on an int * reads four bytes and interprets them as an integer; on a char * it reads one. The address alone does not say.
Pointer arithmetic scales by the element size. Adding 1 to an int * advances four bytes, not one. That is what makes p[i] work, and it is week 15's subject.
void *
A void * is an address with the type deliberately removed. It can hold any object pointer, which makes it the basis of generic code — malloc returns one in week 19, and week 30 builds generic containers on it.
int value = 42;
void *anything = &value; /* fine: any object pointer converts */
int *back = anything; /* fine in C: converts back implicitly */
/* *anything; — error: the compiler does not know what is there */You cannot dereference a void * without converting it to a real pointer type first. It is an address you have promised to remember the meaning of yourself.
3The three ways a pointer goes wrong
Uninitialized — the wild pointer
int *p; /* contains garbage, like any uninitialized variable */
*p = 42; /* writes to an arbitrary address: undefined behavior */This is week 5's uninitialized-variable problem with a much larger blast radius. A garbage int gives a wrong number; a garbage pointer corrupts memory somewhere unrelated. Always initialize — to a real address, or to NULL.
Null
int *p = NULL; /* explicitly points at nothing */
if (p != NULL) {
*p = 42; /* guarded */
}NULL is a pointer value guaranteed to compare unequal to the address of any object. It is the conventional "no result" return — malloc and fopen both use it — so checking for it is routine rather than defensive.
Dereferencing NULL is undefined behavior, but in practice it is the good failure: page zero is unmapped on every mainstream operating system, so the program dies immediately with a clear signal rather than corrupting something and continuing. C23 adds nullptr, which is type-safe; NULL remains universal.
Dangling
int *make_bad(void)
{
int local = 42;
return &local; /* the frame dies at return */
}The address was valid when it was taken. By the time the caller uses it, week 11's stack frame has been popped and that memory belongs to the next call. GCC warns — function returns address of local variable — and this is the reason week 11 insisted that locals do not survive the return. Week 20 meets the heap version, which the compiler cannot catch.
A habit worth forming now. Initialize every pointer at declaration, set it to NULL when what it pointed to is gone, and check before dereferencing anything that could legitimately be null. Those three rules prevent most of what weeks 19 and 20 spend their time diagnosing.
4Pointers as parameters — the fix for swap
Week 11 established that arguments are copied, so a function cannot change a caller's variable. The way around it is not to pass the variable but its address: the address is copied, and both copies refer to the same object.
void swap(int *a, int *b)
{
int temp = *a;
*a = *b;
*b = temp;
}
int x = 1, y = 2;
swap(&x, &y); /* now x == 2, y == 1 */Compare with the version that failed. The body is nearly identical; the difference is three asterisks and two ampersands. Nothing about the copying rule changed — what changed is what got copied.
This also finally explains scanf. scanf("%d", &age) passes the address of age so the function can write into it. Omit the & and you pass the value of age — an uninitialized integer — which scanf treats as an address and writes to. That is why the mistake corrupts memory instead of failing cleanly.
C has no pass-by-reference. It has pass-by-value applied to addresses, which achieves the same result with the mechanism left visible. Week 16 builds the output-parameter conventions that follow from it.
5Array decay
Now the two mysteries from week 12.
In almost every context, an array name converts to a pointer to its first element. This conversion is called decay, and it happens silently.
int values[5] = { 1, 2, 3, 4, 5 };
values /* in most expressions, this means &values[0] */
*values /* therefore this is values[0], i.e. 1 */Indexing is defined in terms of it: values[i] is exactly *(values + i). That is not an analogy; it is the definition, and week 15 exploits it.
Why sizeof changed
Decay does not happen in three places: as the operand of sizeof, as the operand of &, and for a string literal used to initialize an array. So:
int values[5];
sizeof values /* 20 — the array, no decay here */
void f(int values[]) /* the parameter is really int *values */
{
sizeof values /* 8 — a pointer */
}A function parameter written as int values[] is not an array parameter. C has no array parameters. The compiler silently rewrites it to int *values, which is why the length must be passed separately and why sizeof reports 8.
Why a function can modify an array
Because what was copied is a pointer to the caller's memory. The function received an address, and writing through it writes to the original. Pass-by-value was never violated: the pointer was copied faithfully.
void zero_all(int values[], size_t n) /* really int *values */
{
for (size_t i = 0; i < n; i++) {
values[i] = 0; /* writes to the caller's array */
}
}The two spellings int values[] and int *values are identical to the compiler. Use the bracket form when the parameter conceptually is an array — it documents intent — and the star form when it is a pointer to a single object.
6Worked example: closing three loose ends
#include <stdio.h>
#include <stdlib.h>
#include <stdbool.h>
/* Week 11's version: swaps two copies, achieves nothing. */
static void swap_broken(int a, int b)
{
int temp = a;
a = b;
b = temp;
}
/* The fix: three asterisks and two ampersands at the call site. */
static void swap(int *a, int *b)
{
int temp = *a;
*a = *b;
*b = temp;
}
/* An output parameter: return a status, write the result through a pointer.
This is the convention week 16 develops. */
static bool safe_divide(int numerator, int denominator, int *out)
{
if (denominator == 0) {
return false;
}
*out = numerator / denominator;
return true;
}
/* Demonstrates decay: sizeof here reports a pointer, not the array. */
static void show_parameter_size(int values[], size_t n)
{
printf(" inside the function: sizeof values = %zu (a pointer)\n",
sizeof values);
printf(" the length had to be passed separately: n = %zu\n", n);
}
/* Writes through the decayed pointer, so the caller's array changes. */
static void scale_all(int values[], size_t n, int factor)
{
for (size_t i = 0; i < n; i++) {
values[i] *= factor;
}
}
int main(void)
{
puts("== 1. addresses ==");
int count = 42;
int *p = &count;
printf(" count = %d\n", count);
printf(" &count = %p\n", (void *)&count);
printf(" p = %p\n", (void *)p);
printf(" *p = %d\n", *p);
*p = 99;
printf(" after *p = 99, count = %d\n", count);
printf(" sizeof(int) = %zu, sizeof(int *) = %zu\n",
sizeof(int), sizeof(int *));
puts("\n== 2. swap, broken and fixed ==");
int x = 1, y = 2;
swap_broken(x, y);
printf(" after swap_broken: x=%d y=%d (unchanged)\n", x, y);
swap(&x, &y);
printf(" after swap: x=%d y=%d (swapped)\n", x, y);
puts("\n== 3. output parameters ==");
int result;
if (safe_divide(10, 3, &result)) {
printf(" 10 / 3 = %d\n", result);
}
if (!safe_divide(10, 0, &result)) {
puts(" 10 / 0 refused, and result was left untouched");
}
puts("\n== 4. null and guarded access ==");
int *maybe = NULL;
printf(" maybe is %s\n", maybe == NULL ? "NULL" : "valid");
if (maybe != NULL) {
printf(" %d\n", *maybe);
} else {
puts(" guarded: no dereference attempted");
}
puts("\n== 5. array decay ==");
int values[5] = { 1, 2, 3, 4, 5 };
const size_t n = sizeof values / sizeof values[0];
printf(" where declared: sizeof values = %zu, length = %zu\n",
sizeof values, n);
show_parameter_size(values, n);
printf(" values == &values[0]? %s\n",
(void *)values == (void *)&values[0] ? "yes" : "no");
printf(" *values = %d, values[0] = %d, *(values + 2) = %d, values[2] = %d\n",
*values, values[0], *(values + 2), values[2]);
puts("\n== 6. a function modifying the caller's array ==");
printf(" before: ");
for (size_t i = 0; i < n; i++) printf("%d ", values[i]);
scale_all(values, n, 10);
printf("\n after: ");
for (size_t i = 0; i < n; i++) printf("%d ", values[i]);
puts("\n pass-by-value was never broken: the pointer was copied");
return EXIT_SUCCESS;
}gcc -std=c17 -Wall -Wextra -g -o pointers pointers.c
./pointersWhat the compiler says
Section 5 of the program provokes a warning you should read rather than silence:
pointers.c:34:12: warning: 'sizeof' on array function parameter 'values'
will return size of 'int *' [-Wsizeof-array-argument]GCC knows exactly what mistake people make here and says so by name.
Two experiments
The dangling pointer. Add this and rebuild:
static int *make_dangling(void)
{
int local = 42;
return &local;
}
/* in main: */
int *bad = make_dangling();
printf("%d\n", *bad); /* undefined behavior */warning: function returns address of local variable [-Wreturn-local-addr]Run it. It may print 42, may print garbage, may crash. Then run it under -fsanitize=address, which reports stack-use-after-return precisely.
The missing ampersand. Change swap(&x, &y) to swap(x, y):
error: passing argument 1 of 'swap' makes pointer from integer
without a cast [-Wint-conversion]Here the type system catches it. With scanf it cannot, because a variadic function declares nothing about its later arguments — which is exactly why forgetting the & there is so much more dangerous.
7Common mistakes
| Mistake | What happens | Fix |
|---|---|---|
int *p; *p = 5; | Writes to a garbage address | Initialize to a real address or NULL. |
int* a, b; | b is an int, not a pointer | Write int *a, *b; — one declaration per line is better still. |
Returning &local | Dangling pointer | Return by value, or let the caller own the storage. |
| Dereferencing without a null check | Crash on the failure path | Check whatever can legitimately be NULL. |
sizeof on an array parameter | Gives the pointer size | Pass the length; heed -Wsizeof-array-argument. |
Omitting & in scanf | Memory corruption, no type error | Always pass the address. |
printf("%p", p) without a cast | Format mismatch | (void *)p. |
Confusing *p = 5 with p = 5 | The second sets the pointer to address 5 | Read * as "the thing at". |
8Check yourself
If every address is 8 bytes, why does a pointer need a type?
Because the address alone does not say how many bytes to read or how to interpret them, and because pointer arithmetic scales by the element size — adding 1 to an int * moves four bytes. The type is what makes *p and p[i] meaningful.
Why does swap(int *a, int *b) work when swap(int a, int b) does not?
Both copy their arguments — the rule never changes. The difference is what is copied: in the second case, the values, so only the copies are exchanged. In the first, the addresses, so *a and *b refer to the caller's own variables and the exchange reaches them.
Why is dereferencing NULL better than dereferencing an uninitialized pointer?
Both are undefined behavior, but a null dereference targets an unmapped page on every mainstream system, so the program dies immediately with a clear signal at the point of the error. An uninitialized pointer holds an arbitrary address that may be writable, so the program corrupts something and continues — and the symptom appears far from the cause.
What is array decay, and where does it not happen?
In most expressions an array name converts to a pointer to its first element. It does not happen as the operand of sizeof, as the operand of &, or when a string literal initializes a character array. Those three exceptions are why sizeof reports the real array size where it is declared and a pointer size inside a function.
If arguments are always copied, how can a function change the contents of an array you passed?
Because the array decayed to a pointer before the call, so what was copied is an address pointing into the caller's memory. Writing through that copy writes to the original array. Pass-by-value is intact; the copied value simply happens to be a reference.
9Where this leads
Week 14 applies all of this to strings, which in C are not a type at all but an array of characters ending in '\0' — and which are therefore handled entirely through the pointers you just met. Week 15 then develops the arithmetic that makes values[i] and *(values + i) the same expression.