Pointers
A pointer in C is an object whose value is the address of another object or of a function. Pointers are first-class values: they may be assigned, copied, passed, returned, stored in arrays and structures, and dereferenced. The full mechanics of pointer use — the address-of and dereference operators, pointer arithmetic, the relationship to arrays, the conventions around NULL and void *, and the rules for valid dereferences — together account for a substantial fraction of what is distinctive about working in C. Most non-trivial data structures in the language are constructed from pointers, and most of the language’s notorious pitfalls involve pointers used incorrectly.
Address-of and dereference
The unary & operator yields the address of its operand:
int x = 42;
int *p = &x; /* p holds the address of x */
The type of &x is “pointer to the type of x”, written int * here. The type system tracks the pointed-to type: int * and double * are distinct types, and conversions between them require an explicit cast and are rarely well-defined.
The unary * operator dereferences a pointer, yielding the object it points to:
int x = 42;
int *p = &x;
printf("%d\n", *p); /* prints 42 */
*p = 99; /* changes x through the pointer */
printf("%d\n", x); /* prints 99 */
The dereferenced value is an lvalue: it designates an object and may appear on the left of an assignment. The mechanics — *p = 99 modifies what p points to — is the foundation of every non-trivial use of pointers.
Null pointers
The macro NULL, defined in <stddef.h> and several other headers, expands to a null pointer constant: a value that compares equal to no valid pointer. Conventionally NULL represents “no object”:
char *find(const char *s, char c);
char *p = find("hello", 'z');
if (p == NULL) {
/* not found */
}
The standard does not specify the bit representation of a null pointer; on most platforms it is all-zero bits, but the value 0 and the constant NULL are guaranteed to compare equal to a null pointer by the standard’s conversion rules. C23 introduced nullptr as a typed null-pointer constant of type nullptr_t, addressing several edge cases of variadic-argument passing and generic-selection that the macro NULL could not handle cleanly.
Dereferencing a null pointer is undefined behaviour. In practice, on platforms that protect the zero page, the dereference traps and the program receives a signal; on platforms that do not, the behaviour is more unpredictable.
Pointer arithmetic
Adding an integer n to a pointer of type T * advances the pointer by n * sizeof(T) bytes. Subtracting two pointers of the same type yields the number of elements between them, of type ptrdiff_t:
int arr[5] = {10, 20, 30, 40, 50};
int *p = arr; /* points at arr[0] */
int *q = arr + 3; /* points at arr[3] */
ptrdiff_t d = q - p; /* d == 3 */
printf("%d %d\n", *p, *q); /* 10 40 */
Pointer arithmetic is well-defined only within a single array (or one position past the end). Subtracting pointers into different objects, or comparing them with <, is undefined behaviour. The “one past the end” allowance is what makes the conventional for (T *p = arr; p < arr + n; ++p) loop well-defined.
The relationship to indexing is exact: for any pointer p and integer i, p[i] is defined as *(p + i). The reverse is also true: arr[i], where arr is an array, decays to *(arr + i). Indexing and pointer arithmetic are different surface syntaxes for the same operation.
Arrays decay to pointers
In most expression contexts, an array expression is converted to a pointer to its first element:
int arr[10];
int *p = arr; /* arr decays to &arr[0] */
size_t n = sizeof arr / sizeof arr[0]; /* sizeof does not cause decay */
The principal contexts in which an array does not decay are: the operand of sizeof; the operand of unary & (&arr is a pointer to the whole array, of type int (*)[10]); and a string literal used to initialise an array (char s[] = "hi" copies the literal’s bytes into s).
Decay has the consequence that array dimensions are not propagated through function calls. The two declarations
void f(int arr[10]);
void f(int *arr);
are equivalent: in both cases, the parameter is a pointer to int, and the dimension 10 is documentation only. The function cannot determine the size of the passed array from the parameter alone; that information must be passed explicitly:
size_t array_sum(const int *arr, size_t n) {
size_t total = 0;
for (size_t i = 0; i < n; ++i) total += arr[i];
return total;
}
int main(void) {
int v[5] = {1, 2, 3, 4, 5};
printf("%zu\n", array_sum(v, sizeof v / sizeof *v));
}
C99 introduced variably modified array parameter syntax — void f(size_t n, int arr[n]) — which is more documentation than enforcement; the parameter is still passed as a pointer.
Pointers to pointers
A pointer can point to another pointer; the construction generalises to any number of levels:
int x = 42;
int *p = &x;
int **pp = &p; /* pp points to p, which points to x */
printf("%d\n", **pp); /* dereference twice: prints 42 */
Two-level pointers appear most commonly in two contexts: the argv argument to main, where each element of argv is a pointer to a char and argv itself is a pointer to that array (so its type is char **); and as out-parameters that need to update the caller’s pointer (allocators that set the caller’s pointer, parsers that advance an input pointer through a buffer).
Function pointers
C allows pointers to functions:
int square(int x) { return x * x; }
int (*fn)(int) = square;
int result = fn(5); /* 25 */
int alt = (*fn)(5); /* equivalent: explicit dereference */
The two call syntaxes — fn(5) and (*fn)(5) — are equivalent; the standard defines a function call to take a pointer-to-function as its operand, and the function-name square is itself converted to a function pointer in most contexts.
The principal use of function pointers is to implement callbacks — functions that the caller chooses dynamically. The <stdlib.h> library functions qsort and bsearch take a comparator:
int compare_ints(const void *a, const void *b) {
int ia = *(const int *)a;
int ib = *(const int *)b;
return (ia > ib) - (ia < ib);
}
int arr[] = {3, 1, 4, 1, 5, 9, 2, 6};
qsort(arr, 8, sizeof *arr, compare_ints);
The function-pointer type syntax is among the more challenging in C; typedef is conventionally applied to make declarations readable:
typedef int (*comparator)(const void *, const void *);
void sort_with(int *arr, size_t n, comparator cmp);
void * and the generic-pointer convention
A pointer of type void * may point at an object of any type. The conversions to and from void * are implicit and lossless: the address is preserved, and the original pointed-to type is recovered by an explicit cast.
void *p = malloc(sizeof(int));
int *ip = p; /* implicit conversion */
*ip = 42;
The standard library uses void * extensively for generic operations: malloc returns it, qsort accepts it, memcpy operates on it. The trade-off is loss of type-checking: passing the wrong type to a void *-accepting function is not diagnosed, and the bug surfaces at runtime if at all.
void * may not be dereferenced (because void has no values) and pointer arithmetic on void * is undefined by the standard (GCC permits it as an extension, treating the pointed-to size as 1). Code that does generic-pointer arithmetic should cast to char * first.
const and pointer combinations
const may apply to either the pointed-to type, the pointer itself, or both. The four combinations are distinct:
int x = 0;
int y = 1;
const int *a = &x; /* pointer to const int */
int * const b = &x; /* const pointer to int */
const int * const c = &x; /* const pointer to const int */
int *d = &x; /* pointer to int */
a = &y; /* legal: a may be reassigned */
*a = 5; /* error: *a is const */
b = &y; /* error: b is const */
*b = 5; /* legal: *b is non-const */
The rule for reading the type is to read right-to-left, with the pivot at the asterisk: int * const is “const pointer to int”. The convention for function parameters is to apply const to whatever the function does not modify; for void copy(int *dst, const int *src, size_t n), the source is unmodifiable through the parameter, the destination is not.
The restrict qualifier
A pointer declared with the restrict qualifier (C99) carries the assertion that, for the duration of the access, the object the pointer designates will be accessed only through pointers based on this one. The qualifier permits the compiler to assume non-aliasing and produce code as if the pointers cannot overlap:
void copy(int * restrict dst, const int * restrict src, size_t n) {
for (size_t i = 0; i < n; ++i) dst[i] = src[i];
}
If the caller violates the assertion — passes overlapping buffers — the behaviour is undefined. The qualifier is principally useful in numerical kernels, where the absence of aliasing licenses vectorisation. The standard library’s memcpy is declared with restrict parameters; memmove is not, and it is the routine to use when the source and destination may overlap.
The strict aliasing rule
The standard restricts which pointer types may legally be used to access a given object (§6.5p7):
An object shall have its stored value accessed only by an lvalue expression that has one of the following types: a type compatible with the effective type of the object; a qualified version of the effective type; a signed or unsigned integer type corresponding to the effective type; an aggregate or union type that includes one of the above; or a character type.
Two consequences are practical:
char *(andunsigned char *) may alias any object — useful for byte-level manipulation, byte-by-byte copying.- Pointers to unrelated types (
int *andfloat *) may not alias the same object, even if the bit patterns coincide.
The cleanly-defined way to reinterpret an object’s bytes as a different type is memcpy:
float reinterpret_int_as_float(uint32_t bits) {
float f;
memcpy(&f, &bits, sizeof f);
return f;
}
The compiler is required to honour the rule and may produce code that breaks aliasing-violating programs in surprising ways; the behaviour is undefined and the compiler is at liberty to assume it does not occur.
Pitfalls in summary
The recurring pointer-related defects:
- Dangling pointers — a pointer to an object whose lifetime has ended (a returned local, a freed allocation). Use is undefined.
- Use after free — accessing memory through a pointer after the corresponding
free. Undefined; common. - Double free —
freeing the same allocation twice. Undefined. - Off-by-one in pointer arithmetic — writing one past the end of an allocation. Undefined; classic source of buffer overflows.
- Null-pointer dereference — failing to check the return of an allocator or a search function before dereferencing.
- Type-punning through pointer casts — using
(int *)on afloat *to read the bits. Violates strict aliasing. memcpyof overlapping regions —memcpyrequires non-overlap; usememmove.
The discipline of pointer-correct C is, in practice, the discipline of tracking who owns what memory, when each object’s lifetime begins and ends, and what aliasing is permitted by the standard. Static analysers (-fsanitize=address, valgrind, modern Clang and GCC sanitisers) catch a significant fraction of these errors at runtime; they do not catch all.