Pointers
Memory addresses, pointer declaration and indirection, null and void pointers, pointer arithmetic, array decay, pointer parameters, qualifiers, function pointers, and pointer safety.
A pointer is a value that can designate an object or function. It is not the object itself, and it is not merely an integer memory address. Correct pointer code tracks three facts together:
- target: which object or function is designated;
- bounds: which range may be traversed or accessed;
- lifetime: whether the designated entity still exists.
Most serious C failures arise when one of those facts is assumed rather than established.
Memory Addresses
Every object occupies storage. The address operator & produces a pointer to an object:
int score = 91;
int *score_address = &score;
Conceptually:
score_address ─────────► score
+----+
| 91 |
+----+
The actual numeric address is implementation-managed. To print an object pointer for diagnostics, convert it to void * and use %p:
printf("%p\n", (void *)&score);
Do not infer object order, available storage, or validity from printed addresses.
Pointer Declaration
int *pointer;
Read this as “pointer is a pointer to int.” The * belongs to the declarator, which is why this declaration is misleading:
int* first, second; /* first is a pointer; second is an int */
Prefer one declaration per line:
int *first = NULL;
int second = 0;
Different pointed-to types matter because they determine the dereferenced type, pointer arithmetic stride, alignment requirements, and permitted access.
Address Operator
&object produces a pointer to that object when the operand is addressable:
double temperature = 22.5;
double *location = &temperature;
Some expressions are not addressable objects. You cannot take the address of the temporary result of a + b, a bit-field, or an object declared with the register storage-class specifier.
An array expression often converts to a pointer automatically, so &array and array can have the same numeric location but different types:
int values[4];
int *element_pointer = values; /* pointer to first int */
int (*array_pointer)[4] = &values; /* pointer to whole array */
The types advance by different-sized objects under pointer arithmetic.
Dereference Operator
Unary * accesses the designated object:
int score = 91;
int *pointer = &score;
printf("%d\n", *pointer); /* read score */
*pointer = 95; /* modify score */
Dereferencing requires a pointer that is correctly aligned, designates a live object of a permitted type, and points to an actual element rather than one-past an array. Violating these conditions causes undefined behaviour.
The same * token has different grammatical roles:
int *p; /* declarator: p is a pointer */
int x = *p; /* expression: access designated int */
int y = a*b; /* binary multiplication */
Context distinguishes them.
Redirect the pointer between the two live objects below and change the designated value to observe which object an indirect write actually modifies.
Null Pointers
A null pointer deliberately designates no object or function:
int *selected = NULL;
NULL is a null pointer constant macro provided by several standard headers. Test before dereferencing:
if (selected != NULL) {
printf("%d\n", *selected);
}
The shorter if (selected) is equivalent in a condition, but an explicit comparison can be clearer while learning.
A null pointer need not have an all-bits-zero object representation, though source-level initialization with 0 or NULL produces a null pointer through language conversion. Do not create pointer representations by guessing bytes.
Null is not the only invalid pointer state. A non-null pointer can dangle after its target’s lifetime ends, be uninitialized, point one past an array, or designate an object with the wrong effective type.
Void Pointers
void * is a generic pointer to an object type:
int value = 42;
void *generic = &value;
int *specific = generic;
C converts between void * and object-pointer types implicitly. Do not cast the result of malloc in C; the conversion is automatic, and an unnecessary cast can hide a missing <stdlib.h> declaration in poorly configured code.
A void * cannot be dereferenced directly because void has no object representation or size. Convert to the correct pointer type first. Standard C also does not define arithmetic on void *; use a character pointer when traversing raw bytes.
Function pointers are not object pointers. The standard does not generally guarantee conversion between function pointers and void *.
Pointer Arithmetic
Pointer arithmetic is defined relative to arrays. If p points to an array element, p + 1 points to the next element, advancing by sizeof *p bytes conceptually:
int values[] = {10, 20, 30};
int *p = values;
printf("%d\n", *p); /* 10 */
printf("%d\n", *(p + 1)); /* 20 */
For an array of n elements, pointers may range from the first element through the one-past position:
&a[0] &a[1] ... &a[n-1] &a[n]
valid valid valid form/compare only
One-past is useful as an exclusive endpoint but must not be dereferenced.
for (const int *p = values; p != values + count; p++) {
printf("%d\n", *p);
}
Forming pointers outside this range is generally undefined even if they are never dereferenced. Pointer subtraction is defined only for pointers into the same array object (or one-past) and yields ptrdiff_t. Relational comparison of pointers is meaningfully ordered only within one array object.
Array Decay
In most expressions, an array is converted to a pointer to its first element:
int values[4] = {1, 2, 3, 4};
int *first = values; /* same as &values[0] */
Important exceptions include:
- operand of
sizeof, where the whole array size is measured; - operand of unary
&, where a pointer to the whole array is produced; - a string literal used to initialize an array;
- certain language-specific operators outside this course’s core.
Decay loses length information. A pointer alone does not know how many elements follow, so pointer-based interfaces carry an explicit count or endpoint.
long sum(const int *begin, const int *end) {
long total = 0;
for (const int *p = begin; p != end; p++) {
total += *p;
}
return total;
}
This half-open range contract requires begin and end to refer to the same array, with end reachable from begin.
Pointer Parameters
Because parameters receive copied values, a pointer parameter lets a function access an object designated by the copied address:
#include <stdbool.h>
bool divide(double numerator, double denominator, double *result) {
if (result == NULL || denominator == 0.0) {
return false;
}
*result = numerator / denominator;
return true;
}
Usage:
double quotient;
if (divide(10.0, 4.0, "ient)) {
printf("%.2f\n", quotient);
}
The status-return plus output-pointer pattern makes failure distinct from every possible numeric result. The contract should state whether result may be null and whether it remains unchanged on failure.
Swapping Values
void swap_int(int *left, int *right) {
int temporary = *left;
*left = *right;
*right = temporary;
}
Call with addresses:
swap_int(&a, &b);
Preconditions include two valid int pointers. Aliasing them to the same object is harmless for this implementation but may matter for other functions.
Double Pointers
A pointer to a pointer can let a function update a caller’s pointer object:
void select_first(int **selection, int values[], size_t count) {
if (selection == NULL) {
return;
}
*selection = NULL;
if (count > 0 && values != NULL) {
*selection = &values[0];
}
}
State diagram:
selection ─► caller's pointer ─► values[0]
selection itself is a copied pointer. Dereferencing it reaches the caller’s pointer, which can then be changed.
Double pointers also appear with arrays of string pointers such as argv, dynamic allocation helpers, and APIs that return allocated storage. They are not automatically “two-dimensional arrays”; representation determines meaning.
Const Pointers
Read qualifiers from the identifier outward:
const int *pointer_to_const;
int *const const_pointer = &value;
const int *const fixed_view = &value;
const int *: the designatedintcannot be modified through this pointer; the pointer may change.int *const: the pointer value cannot change after initialization; the designatedintmay change.const int *const: neither operation is allowed through this name.
Use pointer-to-const for read-only views:
int maximum(const int values[], size_t count);
Adding pointed-to const is generally safe:
int value = 3;
const int *view = &value;
Discarding it is unsafe. A cast does not make an originally const object modifiable; writing through such a converted pointer is undefined behaviour.
Pointer-to-Pointer Qualifiers
int ** does not safely convert to const int **. If it did, a function could store a pointer-to-const into a location expected to hold a mutable int *, later enabling modification of a genuinely const object. Qualifiers at multiple indirection levels require careful exact types.
Function Pointers
A function pointer designates a compatible function:
int compare_ascending(int left, int right) {
return (left > right) - (left < right);
}
int (*comparison)(int, int) = compare_ascending;
int relation = comparison(4, 9);
Read comparison as “pointer to function taking two int arguments and returning int.” A typedef can improve an interface:
typedef int (*IntComparison)(int left, int right);
int choose(int left, int right, IntComparison compare) {
return compare(left, right) <= 0 ? left : right;
}
Function pointers enable callbacks and runtime policy selection. The pointed-to function type must be compatible; calling through an incompatible converted type causes undefined behaviour.
Standard qsort uses a generic comparator:
int compare_ints(const void *left, const void *right) {
int a = *(const int *)left;
int b = *(const int *)right;
return (a > b) - (a < b);
}
The subtraction form a - b can overflow and is therefore not a safe general comparator.
Pointer Safety
Initialization
Never dereference an uninitialized pointer:
int *pointer; /* indeterminate */
/* *pointer = 3; undefined */
Initialize with a valid address or NULL.
Lifetime
Never return a pointer to an automatic local object:
int *invalid_result(void) {
int local = 42;
return &local; /* dangles after return */
}
The pointer value may remain non-null, but its target no longer exists.
Bounds
Pointer arithmetic must stay within one array object and its one-past endpoint. A pointer to a standalone object behaves like a pointer to an array of length one for these limited rules.
Alignment and Type
Converting an arbitrary byte address to int * does not prove correct alignment or that an int object exists there. Access through incompatible types can violate effective-type and aliasing rules. Use memcpy when copying object representations between byte storage and typed objects.
Aliasing
Two pointers alias when they designate overlapping storage. Aliasing can make updates interact:
void assign_pair(int *left, int *right) {
*left = 1;
*right = 2;
}
If both pointers designate the same int, the final value is 2. Interfaces should permit, forbid, or handle overlap explicitly.
Ownership
A pointer does not inherently say who must release storage, whether mutation is allowed, or how long the target remains available. Names, const, types, and contracts must communicate those properties. Dynamic ownership is the focus of Chapter 11.
Pointer State Trace
int values[] = {5, 8, 13};
int *p = values;
*p += 1;
p++;
int saved = *p;
| Step | p designates | array | saved |
|---|---|---|---|
| initialization | values[0] | [5, 8, 13] | not initialized |
*p += 1 | values[0] | [6, 8, 13] | not initialized |
p++ | values[1] | [6, 8, 13] | not initialized |
saved = *p | values[1] | [6, 8, 13] | 8 |
The pointer increment changes which element is designated; it does not move or modify array elements.
Range Case
A pointer-and-count interface can return both the minimum and maximum of a nonempty array while leaving outputs unchanged on failure. This complete program also demonstrates read-only input and writable output pointers.
#include <stdbool.h>
#include <stdio.h>
#include <stdlib.h>
static bool find_range(const int *values, size_t count,
int *minimum, int *maximum) {
if (values == NULL || count == 0 ||
minimum == NULL || maximum == NULL ||
minimum == maximum) {
return false;
}
int low = values[0];
int high = values[0];
for (const int *cursor = values + 1;
cursor != values + count;
cursor++) {
if (*cursor < low) {
low = *cursor;
}
if (*cursor > high) {
high = *cursor;
}
}
*minimum = low;
*maximum = high;
return true;
}
int main(void) {
int samples[] = {14, -3, 27, 8, 27};
size_t count = sizeof samples / sizeof samples[0];
int low = 0;
int high = 0;
if (!find_range(samples, count, &low, &high)) {
fputs("cannot compute range\n", stderr);
return EXIT_FAILURE;
}
printf("minimum=%d maximum=%d\n", low, high);
return EXIT_SUCCESS;
}
The function’s pointer roles are different:
| Parameter | Designates | Permission | Required extent |
|---|---|---|---|
values | first input element | read only through this pointer | count int objects |
minimum | caller output object | write once on success | one int |
maximum | caller output object | write once on success | one int |
minimum == maximum is rejected because the advertised interface promises two independently observable results. Without that check, the second store would overwrite the first and a “successful” call could not deliver both values. Another defensible API could allow aliasing and define that only the maximum remains, but that would be a surprising contract.
The cursor takes these values:
values + 1 -> designates samples[1]
values + 2 -> designates samples[2]
values + 3 -> designates samples[3]
values + 4 -> designates samples[4]
values + 5 -> one-past endpoint; compare only, never dereference
Both values + 1 and values + count require a valid array relationship. The earlier count == 0 check prevents forming values + 1 for an empty range. The function assumes the caller’s count truthfully describes accessible storage; C stores no hidden length in the pointer.
Object Graph
Draw pointers as directed edges between objects, not as vague “addresses” floating in memory:
int score = 72;
int *selected = &score;
int **slot = &selected;
slot object selected object score object
+-----------+ +--------------+ +----------+
| &selected | --------> | &score | ----------> | 72 |
+-----------+ +--------------+ +----------+
int ** int * int
*slot is the selected pointer object. **slot is the score integer object. Assignments at each level have different effects:
**slot = 80; /* changes score */
*slot = NULL; /* changes selected */
slot = NULL; /* changes only local slot */
After *slot = NULL, evaluating **slot would attempt to dereference the null value now stored in selected. The diagram makes the broken edge visible.
Validity Is Temporal
A pointer is valid for a particular operation at a particular time. Its numeric bits alone cannot answer the question.
target exists? lifetime condition
target has suitable type? type/effective-type condition
address is aligned? representation condition
operation stays in bounds? range condition
write permitted? const and storage condition
ownership permits effect? interface condition
A pointer may be valid for comparison but not dereference: the one-past endpoint is the standard example. A pointer may have been valid a moment ago but dangle after its automatic target leaves scope or allocated target is freed. A non-null test checks only one special invalid value; it does not establish any of the other conditions.
Returning a pointer also creates a lifetime promise. Returning a string literal pointer can be safe for reading for the program’s duration, but not for modification. Returning a pointer to static mutable storage survives the call but makes later calls share and overwrite state. Returning a pointer to an automatic local is invalid immediately after return. The same syntactic return type can carry very different contracts.
Aliasing Decisions
When two pointer ranges may overlap, an update through one can change what the other reads. Consider:
void add_source(int *destination, const int *source) {
*destination += *source;
}
Calling add_source(&value, &value) is well-defined: it doubles value. An implementation that cached assumptions about distinct objects would be wrong because the interface did not forbid aliasing.
Interfaces can:
- support overlap deliberately;
- reject easily detected aliasing;
- document non-overlap as a precondition;
- copy source data before publishing destination changes;
- use a standard operation such as
memmovewhose overlap semantics are defined.
The restrict qualifier, when used, is a strong promise about how an object is accessed during an execution. It can enable optimization, but violating the promise creates undefined behaviour. Add it only when callers can realistically satisfy and understand the access discipline; it is not a decoration meaning merely “probably different pointers.”
Endpoint Interfaces
A half-open pointer range [begin, end) contains every element pointer from begin up to but not including end. Its length is end - begin only when both pointers belong to the same array object and begin <= end.
long sum_range(const int *begin, const int *end) {
long total = 0;
while (begin != end) {
total += *begin;
begin++;
}
return total;
}
For a valid empty range, begin == end and no dereference occurs. A null pair is not automatically a valid empty pointer range for subtraction or arithmetic; define whether the function accepts null only when it tests equality and performs no arithmetic, or require pointers into one actual array even for empty input.
Count-based interfaces make size explicit and can represent an empty range as (NULL, 0) under a documented convention. Endpoint interfaces make subranges convenient but require both endpoints to share provenance. Neither form lets the implementation verify that a lying caller supplied accessible storage.
Qualifier Reading
Read declarations from the identifier outward:
const int *view; /* view may change; designated int is read-only */
int *const fixed = &x; /* fixed cannot change; x may change through it */
const int *const both = &x;
Function parameters cannot make a caller’s pointer object const through top-level qualification because the pointer value is copied. void inspect(const int *const p) prevents the local parameter copy from being reassigned inside the function, while callers see the same parameter type compatibility as pointer-to-const.
const describes permitted access through an expression, not universal immutability. If a mutable int x has both int *writer and const int *reader, changing through writer changes what reader later observes. If the underlying object was defined const, casting and writing through a mutable pointer is undefined.
Callback Case
Function pointers separate traversal from policy:
typedef int (*IntTransform)(int value);
static int square(int value) {
return value * value;
}
static void transform(int values[], size_t count,
IntTransform operation) {
if (values == NULL || operation == NULL) {
return;
}
for (size_t i = 0; i < count; i++) {
values[i] = operation(values[i]);
}
}
The callback’s type is a contract: one int input, one int result, compatible calling convention. It does not state whether squaring can overflow; the policy and traversal still need numeric preconditions. A callback can also have side effects, so a traversal should document call count and order if clients may observe them.
Passing a context pointer is a common way to avoid global callback state:
typedef int (*TransformWithContext)(int value, void *context);
The callback and caller must agree on the context object’s actual type, lifetime, and mutability. void * removes static type information at the boundary; it does not make arbitrary conversions or expired objects safe.
Debugging a Pointer
When inspecting a suspicious pointer, record more than its hexadecimal display:
which operation produced it?
which object and array bounds should it designate?
is that object's lifetime still active?
has reallocation or free invalidated derived values?
is alignment suitable for the pointed-to type?
does the current path permit reading or writing?
Watch the owner and lifecycle operation, not only the eventual dereference. A use-after-free often crashes inside an unrelated library call after memory has been reused. Address sanitizers can identify many such cases, while ownership diagrams explain the correction.
Pointer printing for diagnostics uses %p with a void * argument:
printf("pointer=%p\n", (void *)pointer);
The printed representation is implementation-defined and useful only as an identity clue within that run. Do not parse it back or infer allocation size, object type, or validity from its shape.
Pointer Failures
- Dereferencing null, indeterminate, one-past, or dangling pointers: validity requires more than a nonzero bit pattern.
- Returning an automatic local’s address: its lifetime ends at return.
- Assuming a pointer carries array length: pass a count or endpoint.
- Doing arithmetic on
void *: standard C requires a complete pointed-to type. - Comparing unrelated pointers with
<: ordering is not generally defined across objects. - Casting away
constand writing: an originally const object remains unmodifiable. - Confusing
int **with a 2D array: types and layouts differ. - Writing an overflowing comparator subtraction: compare relationally instead.
Pointer Reasoning
- A pointer can designate an object or function; safe use tracks target, bounds, and lifetime.
&obtains an address and unary*accesses the designated object.- A null pointer designates nothing, but non-null does not guarantee validity.
void *is generic for object pointers but cannot be dereferenced or arithmetically advanced directly.- Pointer arithmetic is defined only within an array and its one-past endpoint.
- Arrays usually convert to pointers to their first elements, losing bound information.
- Pointer parameters enable indirect access; double pointers can update caller pointer objects.
constqualifies either designated data, a pointer object, or both.- Function pointers express callback and runtime-policy interfaces.
Pointer Problems
Name the Relationships
- Name the target, bounds, and lifetime questions needed before dereferencing a pointer.
- What is a one-past pointer allowed to do, and what is forbidden?
- Why is
int **not a general two-dimensional-array type? - Explain the three different meanings of
*in declarations and expressions.
Follow the Addresses
- Trace two pointer variables that initially designate the same
int, then update through each one. - For an array of five elements, list every pointer value that may be formed by adding an index to the first-element pointer and which may be dereferenced.
- Draw the two levels of indirection when a function receives
int **outputand stores a newly selectedint *in the caller.
Repair the Lifetime
- Repair a function that returns the address of a local array.
- Correct a loop that dereferences its endpoint before testing it.
- Replace an integer comparator implemented as
return a - b;. - Explain why casting
const int *toint *does not make a const object writable.
Connect the Objects
- Implement
bool minimum(const int values[], size_t count, int *result)with an explicit empty-input policy. - Write a function that reverses an array using two pointers moving inward. Handle zero and one elements without forming invalid pointers.
- Write
applythat accepts an integer array, count, and function pointer, replacing each element with the callback result.
Define the Ownership
- Specify a buffer-filling function: ownership, capacity, output length, termination, null-pointer policy, and behaviour when space is insufficient.
- Compare count-based
[pointer, count]and endpoint-based[begin, end)array interfaces. List the validity conditions for each.