Pointers
Memory addresses as values — dereferencing, pointer arithmetic, NULL, and common pitfalls.
What is a pointer? The complete mental model.
A pointer is like a sticky note with a street address written on it. The sticky note is not the house — it just tells you where the house is. If you want to read what is inside the house, you go to that address and look. If you want to change the contents, you go there and modify it. The sticky note (pointer) itself is stored somewhere in memory too — it is just a small piece of paper (8 bytes on 64-bit systems) holding an address.
Every variable in your program lives at some memory address. A pointer is a variable that stores one of those addresses as its value. That is the entire idea — a pointer's value is an address, not data like an integer or character.
Two operators, both using familiar symbols:
The & operator (address-of) gives you the memory address of a variable. &x means "give me the address where x lives". You have already used this with scanf("%d", &x) — now you understand why: scanf needs the address so it can write the result to that location.
The * operator has two completely different meanings depending on where it appears:
In a declaration, int *p means "p is a pointer to int". In an expression, *p means "go to the address stored in p and read (or write) the value there" — this is called dereferencing.
NULL is the zero address. A pointer set to NULL does not point to any valid memory. Dereferencing NULL (writing *p when p is NULL) causes a segmentation fault — the program crashes. Always initialize pointers to NULL when you declare them and check before dereferencing.
Pointer arithmetic scales by type size. p + 1 on an int * advances by 4 bytes (sizeof int), not 1 byte. On a double *, it advances 8 bytes. This is why arrays and pointers work together so seamlessly — the array name decays to a pointer and adding 1 steps to the next element regardless of type.
Pointer size is always 8 bytes on 64-bit systems — regardless of what the pointer points to. sizeof(int *), sizeof(char *), sizeof(double *) all return 8. This is a classic exam question in COMP2017.
# Python hides pointers entirely
# All objects live on the heap, names are
# references, garbage collector manages memory
x = 42
y = x # y is a new binding to the value
# Java has references (managed pointers)
# Object obj = new Object();
# 'obj' is a managed reference — no manual
# address manipulation, no pointer arithmetic
/* C exposes raw memory addresses */
int x = 42;
int *p = &x; /* p holds the address of x */
*p = 99; /* changes x through the pointer */
printf("%d\n", x); /* prints 99 */
/* sizeof any pointer = 8 on 64-bit */
sizeof(int *) == sizeof(char *) == 8
1. Uninitialized pointer: int *p; — p contains garbage. Dereferencing it is undefined behavior.
2. NULL dereference: int *p = NULL; *p = 5; — segmentation fault, program crashes.
3. Dangling pointer: A pointer to memory that has been freed or went out of scope. Using it is undefined behavior (Topic 12).
On a 64-bit machine with 64-bit addressable memory, every pointer is 8 bytes, regardless of what it points to. sizeof(int *) = 8. sizeof(char *) = 8. sizeof(void *) = 8. The exam Q1 in 2024 tested exactly this — the answer was 8 bytes for int * where int is 32 bits.
Declaration, dereferencing, arithmetic, and const
/* Declaration: "p is a pointer to int" */ int *p; /* ^ ^ | └── variable name: p └───── * means "pointer to" — type is (int *) */ /* Assignment: make p point to x */ int x = 42; p = &x; /* & = address-of operator */ /* Dereferencing: read or write through pointer */ int val = *p; /* READ: val = 42 */ *p = 99; /* WRITE: x is now 99 */ /* NULL pointer — safe initial value */ int *q = NULL; if (q != NULL) { *q = 5; } /* always check before dereferencing */ /* Pointer arithmetic — scales by sizeof(*p) */ int arr[4] = {10, 20, 30, 40}; int *ptr = arr; /* points to arr[0] */ *(ptr + 1) /* → 20 (advances 4 bytes) */ *(ptr + 3) /* → 40 */
* in a declaration means "pointer to". * in an expression means "dereference" (go to that address). These are different operations that share the same symbol.
const pointers — two different meanings
/* Read right-to-left for clarity */ /* const int *p — pointer to a constant int */ const int *p = &x; /* *p = 5; ILLEGAL — can't modify value through p */ /* p = &y; LEGAL — can point to a different variable */ /* int * const p — constant pointer to int */ int * const p2 = &x; /* *p2 = 5; LEGAL — can modify value */ /* p2 = &y; ILLEGAL — can't change what p2 points to */ /* const int * const p — both constant */ const int * const p3 = &x; /* nothing can be changed through p3 */
Pointer to pointer
int x = 42; int *p = &x; /* p holds address of x */ int **pp = &p; /* pp holds address of p */ *pp /* → value of p (address of x) */ **pp /* → value of x (42) */ /* Common use: argv in main, or pointer passed to function so function can change the pointer itself */ int main(int argc, char **argv)
The restrict Qualifier
restrict is a promise to the compiler: "no other pointer in this scope will alias the same memory." This unlocks optimisations like vectorisation — the compiler can safely reorder or cache memory reads because it knows no other pointer will modify the same location behind its back.
/* Without restrict — compiler must re-read *src on every iteration */ /* because it cannot rule out that dst == src (aliasing possible) */ void copy(int *dst, int *src, int n); /* With restrict — compiler KNOWS dst and src don't overlap → can vectorise */ void copy(int *restrict dst, const int *restrict src, int n); /* This is WHY memcpy uses restrict (non-overlapping buffers required) */ void *memcpy(void *restrict dst, const void *restrict src, size_t n); /* memmove does NOT use restrict — it handles overlapping buffers */
restrict is a promise, not a check. If you lie (the pointers DO alias), the behaviour is undefined. Only add restrict when you can guarantee no overlap.Complete programs you can compile and run
#include <stdio.h>
int main(void) {
int count = 2;
int *ptr; /* declare pointer (uninitialized — danger!) */
ptr = &count; /* make ptr point to count */
/* Three ways to see what's happening */
printf("count = %d\n", count); /* 2: value of count */
printf("*ptr = %d\n", *ptr); /* 2: value AT address ptr holds */
printf("&count = %p\n", (void*)&count); /* address of count */
printf("ptr = %p\n", (void*)ptr); /* same address (ptr == &count) */
/* Modify count through the pointer */
*ptr = 99;
printf("After *ptr=99, count = %d\n", count); /* 99 */
/* Pointer arithmetic */
int arr[] = {10, 20, 30, 40};
int *p = arr; /* points to arr[0] = 10 */
printf("%d %d %d\n", *p, *(p+1), *(p+2)); /* 10 20 30 */
/* sizeof any pointer is 8 on 64-bit */
printf("sizeof(int *) = %zu\n", sizeof(int *)); /* 8 */
printf("sizeof(char*) = %zu\n", sizeof(char *)); /* 8 */
return 0;
}
*ptr = 2
&count = 0x7ffd... (some address)
ptr = 0x7ffd... (same address)
After *ptr=99, count = 99
10 20 30
sizeof(int *) = 8
sizeof(char*) = 8
#include <stdio.h>
/* Pass by value — does NOT work */
void swap_fail(int a, int b) {
int tmp = a;
a = b;
b = tmp;
/* a and b are local copies — caller's variables unchanged */
}
/* Pass by pointer — WORKS */
void swap(int *a, int *b) {
int tmp = *a; /* save value at a's address */
*a = *b; /* write b's value to a's address */
*b = tmp; /* write saved value to b's address */
}
int main(void) {
int x = 5, y = 10;
swap_fail(x, y);
printf("After swap_fail: x=%d, y=%d\n", x, y); /* still 5, 10 */
swap(&x, &y); /* pass addresses so swap can modify originals */
printf("After swap: x=%d, y=%d\n", x, y); /* 10, 5 */
return 0;
}
After swap: x=10, y=5
#include <stdio.h>
int main(void) {
int arr[] = {10, 20, 30, 40, 50};
int *p = arr; /* points to first element */
int *end = arr + 5; /* one past the last element */
/* Pointer + n advances by n * sizeof(int) bytes */
printf("p+0: %d\n", *(p + 0)); /* 10 */
printf("p+2: %d\n", *(p + 2)); /* 30 */
printf("p+4: %d\n", *(p + 4)); /* 50 */
/* Pointer subtraction gives number of elements between */
int *p2 = &arr[3];
printf("p2 - p = %td\n", p2 - p); /* 3 elements apart */
/* Iterate with pointer increment */
for (int *it = arr; it < end; it++) {
printf("%d ", *it);
}
printf("\n");
/* NULL check before dereference */
int *q = NULL;
if (q != NULL) {
printf("%d\n", *q); /* safe — only runs if q is valid */
} else {
printf("q is NULL, not dereferencing\n");
}
return 0;
}
p+2: 30
p+4: 50
p2 - p = 3
10 20 30 40 50
q is NULL, not dereferencing
Practice problems with solutions
The 2024 COMP2017 exam asked: "What is the size of an int * type, where the size of int is 32 bits and the CPU uses 64-bit addressable memory?" Select the correct answer: 4 bytes, 2 bytes, 1 byte, 8 bytes, 64 bytes, or 32 bytes.
/* On a 64-bit system: */
sizeof(int *) == 8 /* pointer to int: 8 bytes */
sizeof(char *) == 8 /* pointer to char: 8 bytes */
sizeof(double *) == 8 /* pointer to double: 8 bytes */
sizeof(int) == 4 /* the int itself: only 4 bytes */
/* A pointer holds a 64-bit (8-byte) memory address.
The type it points to (int = 4 bytes) is IRRELEVANT
to the size of the pointer. */
int (32 bits = 4 bytes) is irrelevant. Common wrong answers: 4 bytes (confusing int size with pointer size) or 64 bytes (confusing bits with bytes).
Predict what is printed. Assume a 64-bit system where sizeof(int) = 4.
#include <stdio.h>
int main(void) {
int count = 17;
int *countp = &count;
printf("%d\n", count);
printf("%d\n", *countp);
return 0;
}
17
17
count is the variable itself — its value is 17. countp holds the address of count. *countp dereferences that address, giving us the value stored there — also 17. From the Week 2 lecture: "What is output? 17, 17." The pointer and the variable refer to the same memory location.
The code below has a NULL dereference bug. Fix it so the program runs safely.
#include <stdio.h>
#include <stdlib.h>
int main(void) {
int *p = malloc(sizeof(int));
/* malloc can return NULL if allocation fails */
*p = 42;
printf("%d\n", *p);
free(p);
return 0;
}
#include <stdio.h>
#include <stdlib.h>
int main(void) {
int *p = malloc(sizeof(int));
if (p == NULL) { /* FIX: check before dereferencing */
fprintf(stderr, "malloc failed\n");
return 1;
}
*p = 42;
printf("%d\n", *p);
free(p);
p = NULL; /* good practice: NULL after free */
return 0;
}
malloc returns NULL when it cannot allocate memory. Dereferencing a NULL pointer is a segmentation fault. Fix: Always check the return value of malloc before using the pointer. Setting p = NULL after free prevents accidental use-after-free. This pattern — check for NULL, use, free, nullify — is the correct C idiom.
Consider the following C code. Draw the memory layout after all statements execute and trace what is printed. Assume sizeof(char *) is 8 bytes on a 64-bit system. No word alignment.
#include <stdio.h>
#include <stdlib.h>
typedef struct {
int val;
char tag[4];
} Holder;
int main() {
Holder *h = malloc(sizeof(Holder));
h->val = -47;
h->tag[0] = 'X';
h->tag[1] = 'Y';
h->tag[2] = 'Z';
h->tag[3] = 'A';
printf("%d %s\n", h->val, h->tag);
free(h);
return 0;
}
/* OUTPUT: -47 XYZA */
/* Memory layout:
Stack:
h → (pointer, 8 bytes) → points to heap address
Heap (at the address h points to):
+0 val : 4 bytes → stores -47 (int, two's complement)
+4 tag : 4 bytes → stores {'X','Y','Z','A'}
= 0x58 0x59 0x5A 0x41
Total sizeof(Holder) = sizeof(int) + 4 = 8 bytes
*/
-47 XYZAMemory layout:
h is a pointer variable on the stack (8 bytes on a 64-bit system). It stores the address of a heap-allocated Holder struct.The
Holder struct occupies sizeof(int) + 4 = 8 bytes on the heap:• Bytes 0–3:
val = -47 (stored as a 32-bit two's complement integer)• Bytes 4–7:
tag[4] = {'X', 'Y', 'Z', 'A'}Important caveat:
tag is not null-terminated — it fills all 4 bytes with 'X','Y','Z','A' and has no '\0'. The printf("%s", h->tag) reads bytes until it finds a zero byte in memory. This is technically undefined behaviour — but in practice, the malloc'd memory often has a zero byte immediately after the struct, so it prints XYZA. On a real exam, point out this UB risk for full marks.Key concepts tested: heap vs stack distinction, struct memory layout, pointer size, and null-termination of C strings.
Write a function void increment(int *x) that adds 1 to the integer pointed to by x. Demonstrate that the original variable is changed (unlike pass-by-value). Also write void double_val(int *x) that doubles the value.
#include <stdio.h>
void increment(int *x) {
(*x)++; /* dereference x, then increment the value */
/* note: *x++ would be wrong — increments the pointer, not the value */
}
void double_val(int *x) {
*x = *x * 2;
}
int main(void) {
int n = 5;
increment(&n);
printf("After increment: %d\n", n); /* 6 */
double_val(&n);
printf("After double: %d\n", n); /* 12 */
return 0;
}
(*x)++ increments the value at the address. Without parentheses, *x++ would first use *x (the value), then increment the pointer x itself — because ++ has higher precedence than *. This is a common off-by-one pointer bug.
Key concepts to memorize
Test your understanding
sizeof(char *) on a 64-bit machine with 64-bit addressable memory?LO4int x = 5; int *p = &x; *p = 10;, the value of x is now 10.LO4int arr[] = {5,10,15}; int *p = arr;, what does *(p+2) evaluate to?LO9const int *p = &x; means you cannot change the ______ through p, but you can change where p points.LO4int *p;
*p = 42;
printf("%d\n", *p);
p+1 is evaluated on an int *p, how many bytes does the pointer advance (assuming sizeof(int) = 4)?LO4