Pointers in C

Pointers are the feature C is both famous and infamous for - the source of its raw power over memory, and the source of most of its scariest bugs. Every other guide in this series leans on pointers constantly: structs passed by pointer, arrays decaying to pointers, function pointers as callbacks, FILE * handles. This guide is where pointers get covered properly, on their own terms, from the basic &/* operators all the way to double pointers, dynamic memory, and the bugs that make pointers earn their reputation.


0. Why they're needed, why industry can't avoid them, and why your syllabus treats them so gently

Why they're needed at all

Every value in a running program lives somewhere in memory, at some address. A pointer is simply a variable that stores an address instead of an ordinary value - which sounds abstract until you realize it's the only mechanism C gives you for a function to modify a caller's data (the functions guide's whole section 5), for building a data structure where one element refers to another (the structs guide's linked lists), or for managing memory whose size isn't known until the program is running. Without pointers, C would have no arrays that scale to runtime sizes, no linked data structures, and no way to hand a large chunk of data to a function without copying it.

Why real projects can't avoid them

Why your syllabus (probably) went gently here

Once pointers click, they stop feeling like C's scariest feature and start feeling like its most direct, honest one - there's no hidden reference-counting, no garbage collector quietly doing things behind your back. You're looking at exactly what the machine is doing. That's also exactly why getting it wrong is so unforgiving.


1. What a pointer is: & and *

A pointer is a variable that stores a memory address. & (address-of) gets a variable's address; * (dereference) accesses the value at an address a pointer holds.

#include <stdio.h>

int main(void) {
    int x = 42;
    int *p = &x;   // p now holds the ADDRESS of x, not x's value

    printf("value of x:          %d\n", x);
    printf("address of x:        %p\n", (void *)&x);
    printf("value stored in p:   %p\n", (void *)p);      // same as &x
    printf("value p points to:   %d\n", *p);              // dereference -- gives 42

    *p = 100;   // modifies x THROUGH the pointer
    printf("x is now:            %d\n", x);               // 100 -- x itself changed

    return 0;
}

p and x are two entirely separate variables; p just happens to store x's address. Changing *p changes x's actual memory; changing p itself (e.g. p = &otherVariable;) would just make p point somewhere else, leaving x untouched.


2. Declaring pointers, and NULL

A pointer's declaration includes the type it points to - int *p means "p is a pointer to an int." An uninitialized pointer is dangerous (section 15); NULL is the standard way to represent "points to nothing."

#include <stdio.h>
#include <stddef.h>   // for NULL

int main(void) {
    int *p1;             // UNINITIALIZED -- holds garbage, NOT NULL, dangerous to dereference
    int *p2 = NULL;       // explicitly points to nothing -- safe, checkable state

    if (p2 == NULL) {
        printf("p2 points to nothing\n");
    }

    int x = 5;
    p1 = &x;   // now safely initialized

    // Declaration syntax quirk: the '*' binds to the VARIABLE, not the type,
    // despite how it's often read:
    int *a, b;    // 'a' is "int *", but 'b' is just a plain "int", NOT "int *"!
    int *c, *d;   // this is how you declare TWO pointers on one line correctly

    (void)a; (void)b; (void)c; (void)d;   // silence unused-variable warnings for this example
    printf("%d\n", *p1);
    return 0;
}

The int *a, b; gotcha is a real, common source of confusion - many C style guides recommend declaring only one pointer per line specifically to avoid it.


3. Pointer arithmetic

Adding an integer to a pointer moves it by that many elements of its pointed-to type, not by that many raw bytes - the compiler automatically scales the arithmetic by sizeof(the pointed-to type).

#include <stdio.h>

int main(void) {
    int numbers[5] = {10, 20, 30, 40, 50};
    int *p = numbers;   // points to numbers[0]; an array name decays to a pointer to its first element

    printf("%d\n", *p);         // 10
    printf("%d\n", *(p + 1));   // 20 -- moved by 1 int (4 bytes on most platforms), NOT 1 byte
    printf("%d\n", *(p + 2));   // 30

    p++;                        // now points to numbers[1]
    printf("%d\n", *p);         // 20

    int *start = numbers;
    int *end = numbers + 5;     // one PAST the last element -- valid to compute, NOT valid to dereference
    printf("elements between start and end: %ld\n", (long)(end - start));   // pointer subtraction: 5

    return 0;
}

Pointer arithmetic is only well-defined within (or one-past-the-end of) a single array - computing or dereferencing a pointer that wanders outside those bounds is undefined behavior, even if it happens not to crash.


4. Pointers and arrays - equivalence and decay

An array name, used in most expressions, decays to a pointer to its first element - which is why array indexing and pointer arithmetic are, underneath, the exact same operation.

#include <stdio.h>

int main(void) {
    int numbers[5] = {10, 20, 30, 40, 50};
    int *p = numbers;   // valid WITHOUT '&' -- numbers already decays to &numbers[0]

    // These four are all completely equivalent ways to get the 3rd element:
    printf("%d\n", numbers[2]);
    printf("%d\n", *(numbers + 2));
    printf("%d\n", p[2]);
    printf("%d\n", *(p + 2));

    // But 'numbers' and 'p' are NOT the same kind of thing, despite behaving
    // similarly here -- 'numbers' is an array (its size is baked into its
    // type), 'p' is just a pointer (no size information at all):
    printf("%zu\n", sizeof(numbers));   // 20 -- the whole array's size
    printf("%zu\n", sizeof(p));         // 8 (or 4) -- just a pointer's size

    return 0;
}

This is exactly the mechanism behind the "array parameters decay to pointers" gotcha from the functions guide (section 6 there) - inside a function, an array parameter is really just a pointer, having lost its original size information entirely.


5. ptr[i] is really *(ptr + i)

C's array indexing syntax is literally defined in terms of pointer arithmetic - arr[i] is, by the language definition, exactly equivalent to *(arr + i). This has one genuinely surprising (if rarely useful) consequence.

#include <stdio.h>

int main(void) {
    int numbers[5] = {10, 20, 30, 40, 50};

    printf("%d\n", numbers[2]);       // 30, the normal way to write it
    printf("%d\n", *(numbers + 2));   // 30, exactly equivalent
    printf("%d\n", *(2 + numbers));   // 30, addition is commutative!
    printf("%d\n", 2[numbers]);       // 30, ALSO legal C, because array indexing
                                        // literally IS pointer arithmetic under
                                        // the hood -- 2[numbers] means *(2 + numbers)

    return 0;
}

2[numbers] is a real, standards-legal piece of C (a frequent trivia/ interview question) - nobody should actually write code this way, but understanding why it compiles is a genuine test of whether you understand what indexing actually means in C.


6. The sizeof pointer gotcha, revisited

This was introduced in the fundamentals guide, but deserves a focused look here: sizeof on a pointer always gives you the pointer's own size, never the size of whatever it points to - a mistake that's easy to make the moment an array has decayed to a pointer.

#include <stdio.h>

void processArray(int *arr) {
    // arr is a POINTER here, not an array -- this is ALWAYS wrong inside
    // a function that received an array as a parameter:
    printf("inside function, sizeof(arr) = %zu\n", sizeof(arr));   // 8 (pointer size)
    // int wrongCount = sizeof(arr) / sizeof(arr[0]);  // silently WRONG -- computes 2, not 5!
}

int main(void) {
    int numbers[5] = {1, 2, 3, 4, 5};

    printf("in main, sizeof(numbers) = %zu\n", sizeof(numbers));   // 20 (whole array)
    int correctCount = sizeof(numbers) / sizeof(numbers[0]);       // 5 -- correct HERE, in main
    printf("correct count: %d\n", correctCount);

    processArray(numbers);   // numbers decays to a pointer the moment it's passed
    return 0;
}

The sizeof(arr) / sizeof(arr[0]) element-count idiom only works in the same scope where the array was actually declared - the instant it's passed to a function (or otherwise decays), that idiom silently computes garbage instead of failing loudly.


7. Pointers to pointers (multi-level pointers)

A pointer can itself point to another pointer - int **pp is "a pointer to a pointer to an int." This is genuinely needed, not just a party trick, whenever a function needs to modify what a caller's pointer points to (section 19), or for arrays of pointers (section 17).

#include <stdio.h>

int main(void) {
    int x = 42;
    int *p = &x;      // p points to x
    int **pp = &p;     // pp points to p

    printf("%d\n", x);      // 42, directly
    printf("%d\n", *p);     // 42, one level of dereference
    printf("%d\n", **pp);   // 42, two levels of dereference

    **pp = 100;    // modifies x, going through BOTH levels of indirection
    printf("%d\n", x);   // 100

    int y = 7;
    *pp = &y;      // changes what p itself points to, WITHOUT touching pp
    printf("%d\n", *p);   // 7 -- p now points to y instead of x

    return 0;
}

Read int **pp right-to-left, one star at a time: pp is a pointer, to a pointer, to an int - the same reading technique scales to arbitrarily deep pointer levels (rarely more than two in practice).


8. Void pointers - generic pointers

void * is a pointer with no associated type - it can point to anything, but can't be dereferenced directly (the compiler doesn't know how many bytes to read) and must be cast to a concrete type first. This is how C achieves a rough form of generic programming.

#include <stdio.h>
#include <stdlib.h>

int main(void) {
    int x = 42;
    void *generic = &x;   // can point to ANY type

    // *generic;   // COMPILE ERROR -- can't dereference void*, size unknown
    int *typed = (int *)generic;   // must cast back to a concrete type first
    printf("%d\n", *typed);         // 42

    // This is exactly why malloc returns void* -- it doesn't know or care
    // what TYPE of data you're going to store in the memory it allocates:
    void *raw = malloc(sizeof(int) * 5);
    int *numbers = (int *)raw;      // cast (optional in C, required in C++) to use it as ints
    numbers[0] = 100;
    printf("%d\n", numbers[0]);
    free(raw);

    return 0;
}

qsort's comparator (from the functions guide, section 15) takes const void * parameters for exactly this reason - it's how a single generic sorting function can work on arrays of any type at all, without the library needing a separate qsort for each possible type.


9. const and pointers - four combinations

const can apply to the pointed-to value, the pointer itself, both, or neither - each combination means something genuinely different, and the syntax placement is what determines which.

#include <stdio.h>

int main(void) {
    int x = 10, y = 20;

    int *p1 = &x;                  // plain pointer: can change BOTH the pointer and the value
    *p1 = 11;                       // OK
    p1 = &y;                        // OK

    const int *p2 = &x;            // pointer to const int: value is read-only, pointer can move
    // *p2 = 99;                    // COMPILE ERROR -- can't modify the pointed-to value
    p2 = &y;                        // OK -- the pointer itself isn't const

    int *const p3 = &x;            // const pointer: value can change, pointer CANNOT move
    *p3 = 99;                       // OK
    // p3 = &y;                     // COMPILE ERROR -- p3 itself is const

    const int *const p4 = &x;      // const pointer to const int: NEITHER can change
    // *p4 = 99;                    // COMPILE ERROR
    // p4 = &y;                     // COMPILE ERROR

    printf("%d %d %d %d\n", *p1, *p2, *p3, *p4);
    return 0;
}

Reading tip: read the declaration right-to-left starting from the variable name - const int *p2 reads as "p2 is a pointer to a const int," while int *const p3 reads as "p3 is a const pointer to an int." This same technique extends to the arbitrarily complex declarations in section 24.


10. Dynamic memory allocation: malloc, calloc, realloc

<stdlib.h> provides functions for allocating memory at runtime, whose size doesn't need to be known until the program is actually executing - returned as void *, and always in need of an eventual matching free (section 11).

#include <stdio.h>
#include <stdlib.h>

int main(void) {
    // malloc: allocates raw, UNINITIALIZED memory of the given byte size
    int *a = malloc(5 * sizeof(int));
    if (a == NULL) {   // ALWAYS check -- malloc can fail, especially for large requests
        fprintf(stderr, "allocation failed\n");
        return 1;
    }
    for (int i = 0; i < 5; i++) a[i] = i * 10;   // must initialize yourself -- contents were garbage

    // calloc: allocates memory for 'count' elements of 'size' bytes each,
    // and ZERO-INITIALIZES it -- unlike malloc
    int *b = calloc(5, sizeof(int));
    if (b == NULL) { fprintf(stderr, "allocation failed\n"); free(a); return 1; }
    printf("calloc'd memory is pre-zeroed: %d\n", b[0]);   // 0, guaranteed

    // realloc: resizes a previous allocation, preserving existing content
    // up to the smaller of the old/new sizes -- may MOVE the memory elsewhere
    int *c = realloc(a, 10 * sizeof(int));   // grow 'a' from 5 to 10 ints
    if (c == NULL) {
        // realloc failure: the ORIGINAL pointer 'a' is still valid and must
        // still be freed -- this is why you never do "a = realloc(a, ...)" directly
        fprintf(stderr, "realloc failed\n");
        free(a);
        free(b);
        return 1;
    }
    a = c;   // safe now: c is the new (possibly moved) block
    printf("%d\n", a[0]);   // 0 -- original content preserved after the resize

    free(a);
    free(b);
    return 0;
}

Always check malloc/calloc/realloc's return value for NULL - on systems with limited memory (embedded devices especially, but even desktop systems under memory pressure), allocation failure is a real, recoverable condition, not something to assume away.


11. free() and memory leaks

Every successful malloc/calloc/realloc needs an eventual free - forgetting one is a memory leak: memory the program can no longer reach, but the OS still considers "in use," until the process exits.

#include <stdio.h>
#include <stdlib.h>

void leaky(void) {
    int *data = malloc(100 * sizeof(int));
    if (data == NULL) return;

    data[0] = 42;
    // MISSING free(data) -- this memory is now unreachable (the only
    // pointer to it, 'data', is a local variable about to go out of
    // scope) but the OS still considers it allocated. Call this
    // function a million times and you'll exhaust available memory.
}   // 'data' the POINTER is destroyed here; the MEMORY it pointed to is not

void correct(void) {
    int *data = malloc(100 * sizeof(int));
    if (data == NULL) return;

    data[0] = 42;
    free(data);   // memory returned to the system -- no leak
}

int main(void) {
    leaky();
    correct();
    return 0;
}

In a long-running program (a server, a game, anything that doesn't just run briefly and exit), even small leaks accumulate into serious memory exhaustion - tools like Valgrind or AddressSanitizer are the standard real-world way to actually find leaks, since they're often invisible in normal testing.


12. Dangling pointers and use-after-free

A dangling pointer points to memory that's no longer valid - most commonly because it was free'd, but a variable's memory becomes "invalid" (from the pointer's perspective) the moment its lifetime ends for any reason. Using a dangling pointer is undefined behavior, and it often "seems to work" right up until it catastrophically doesn't.

#include <stdio.h>
#include <stdlib.h>

int main(void) {
    int *p = malloc(sizeof(int));
    *p = 42;

    free(p);   // the memory is returned to the system...
    // p still holds the SAME address it did before -- it is now a
    // DANGLING pointer, even though it looks unchanged

    // printf("%d\n", *p);   // USE-AFTER-FREE: undefined behavior. Might
                               // print 42 anyway (if the memory hasn't
                               // been reused yet), might print garbage,
                               // might crash -- there's no way to know,
                               // and that unpredictability IS the danger

    p = NULL;   // best practice: null out a pointer immediately after
                 // freeing it, so any accidental future use fails LOUDLY
                 // (a crash on *p) instead of silently corrupting data

    if (p != NULL) {
        printf("%d\n", *p);   // never reached -- p is NULL, safely checkable
    }

    return 0;
}

Setting a pointer to NULL immediately after freeing it is one of the single most effective, cheap habits for avoiding use-after-free bugs - it turns a silent, unpredictable corruption into an immediate, debuggable crash.


13. Double free

Calling free on the same pointer twice is undefined behavior - it corrupts the memory allocator's internal bookkeeping, and can be exploited as a security vulnerability in the worst cases.

#include <stdio.h>
#include <stdlib.h>

int main(void) {
    int *p = malloc(sizeof(int));
    *p = 42;

    free(p);
    // free(p);   // DOUBLE FREE: undefined behavior. The memory allocator's
                    // internal bookkeeping is now corrupted -- this can crash
                    // immediately, corrupt unrelated memory, or (in the worst
                    // real-world cases) be exploitable by an attacker who can
                    // influence what gets allocated next

    p = NULL;   // exactly the same fix as section 12 -- once nulled, a
                 // second "free(p)" becomes free(NULL), which is explicitly
                 // defined by the standard to safely do nothing at all
    free(p);     // SAFE -- freeing NULL is a guaranteed no-op

    return 0;
}

free(NULL) being a guaranteed safe no-op (rather than an error) is exactly why "null it out after freeing" (section 12) also protects against double-free, for free (pun intended) - it's the same single habit defending against two related bugs at once.


14. NULL pointer dereference and defensive checks

Dereferencing a NULL pointer is undefined behavior - on most desktop platforms it triggers an immediate crash (a segmentation fault), which is actually the good outcome, since it fails loudly and immediately rather than corrupting something silently.

#include <stdio.h>
#include <stdlib.h>

int *findValue(int *arr, int len, int target) {
    for (int i = 0; i < len; i++) {
        if (arr[i] == target) {
            return &arr[i];
        }
    }
    return NULL;   // explicitly signal "not found" -- the standard C idiom
}

int main(void) {
    int numbers[5] = {10, 20, 30, 40, 50};

    int *found = findValue(numbers, 5, 30);
    if (found != NULL) {   // ALWAYS check before dereferencing a pointer that
                             // might legitimately be NULL, e.g. from malloc or
                             // a "search" function like this one
        printf("found: %d\n", *found);
    }

    int *notFound = findValue(numbers, 5, 999);
    if (notFound != NULL) {
        printf("found: %d\n", *notFound);
    } else {
        printf("not found\n");   // this branch runs -- and safely so
    }
    // printf("%d\n", *notFound);   // would crash: dereferencing NULL

    return 0;
}

Returning NULL to mean "no valid result" (as malloc and this findValue function both do) is one of the most common conventions in C - and checking for it before dereferencing is one of the most important defensive habits.


15. Wild (uninitialized) pointers

A wild pointer is one that was never initialized at all - unlike a NULL pointer (an intentional, checkable "points to nothing"), a wild pointer holds a genuinely random address left over from whatever was previously in that memory. This is arguably more dangerous than NULL, because it usually doesn't crash immediately.

#include <stdio.h>

int main(void) {
    int *wild;   // UNINITIALIZED -- holds a random, garbage address, NOT NULL

    // *wild = 42;   // undefined behavior: might crash immediately, might
                       // silently corrupt some unrelated variable's memory,
                       // might even appear to "work" -- there's no way to
                       // know in advance, which is exactly what makes wild
                       // pointers more insidious than a NULL pointer (which
                       // at least crashes predictably and immediately)

    int *safe = NULL;   // ALWAYS initialize pointers, even if just to NULL,
                          // so an accidental early dereference fails loudly
                          // and predictably instead of corrupting memory

    if (safe != NULL) {
        printf("%d\n", *safe);
    } else {
        printf("safe was never assigned a real address\n");
    }

    return 0;
}

The practical rule: never leave a pointer uninitialized, even momentarily - initialize to NULL if you don't have a real address yet, exactly the same discipline as section 1 of the fundamentals guide applied specifically to pointers.


16. Pointers and strings

A C string is, fundamentally, just a char * (or char[]) pointing to a sequence of characters terminated by a '\0' byte - there's no separate "string type." This has real consequences for mutability that trip up a lot of beginners.

#include <stdio.h>
#include <string.h>

int main(void) {
    char *literal = "Hello";   // points to a STRING LITERAL -- typically stored
                                 // in READ-ONLY memory by the compiler/OS

    // literal[0] = 'h';   // undefined behavior on most platforms -- modifying
                             // a string literal often crashes (though the C
                             // standard doesn't strictly forbid it, real
                             // compilers/OSes commonly place literals in
                             // write-protected memory)

    char array[] = "Hello";   // COPIES the characters into a local, WRITABLE array
    array[0] = 'h';            // perfectly fine -- this is real, mutable memory
    printf("%s\n", array);     // hello

    // Best practice: use 'const char *' for a pointer to a string literal,
    // so the compiler catches accidental modification attempts:
    const char *safeLiteral = "World";
    // safeLiteral[0] = 'w';   // COMPILE ERROR -- caught at compile time, not runtime

    printf("%zu\n", strlen(safeLiteral));   // 5 -- strlen doesn't count the '\0' terminator
    printf("%zu\n", sizeof("World"));        // 6 -- sizeof DOES count the '\0' terminator

    return 0;
}

char *literal = "Hello"; (a pointer to a literal) and char array[] = "Hello"; (a real, local, writable copy) look almost identical but behave very differently - this distinction is one of the most common sources of "why did modifying my string crash" bugs.


17. Arrays of pointers - arrays of strings, and argv

An array whose elements are themselves pointers is a common pattern for representing a list of strings (since each string is itself just a pointer) - exactly the shape of argv from the functions guide's section 20.

#include <stdio.h>

int main(void) {
    // An array of 3 pointers, each pointing to a different string literal
    const char *fruits[3] = {"apple", "banana", "cherry"};

    for (int i = 0; i < 3; i++) {
        printf("%s\n", fruits[i]);   // fruits[i] is a char*, printed with %s
    }

    // This is EXACTLY the shape of argv in "int main(int argc, char *argv[])":
    // an array of char* pointers, each one pointing to one argument string.
    // argv[0] is the program name, argv[1] the first real argument, etc.

    printf("address of fruits[0]: %p\n", (void *)&fruits[0]);
    printf("address fruits[0] points to: %p\n", (void *)fruits[0]);
    // these two addresses are DIFFERENT -- fruits[0] itself lives in the
    // array; the STRING "apple" it points to lives somewhere else entirely
    // (typically the same read-only literal storage from section 16)

    return 0;
}

18. Pointer to an array vs. array of pointers - the syntax difference

int *arr[5] and int (*arr)[5] look almost identical but mean genuinely different things - one is an array of 5 pointers, the other is a single pointer to an array of 5 ints. The parentheses are what changes the meaning entirely.

#include <stdio.h>

int main(void) {
    int a = 1, b = 2, c = 3;

    int *arrayOfPointers[3] = {&a, &b, &c};   // an ARRAY containing 3 separate int*
    printf("%d %d %d\n", *arrayOfPointers[0], *arrayOfPointers[1], *arrayOfPointers[2]);

    int numbers[3] = {10, 20, 30};
    int (*pointerToArray)[3] = &numbers;      // a SINGLE pointer to one whole array of 3 ints
    printf("%d %d %d\n", (*pointerToArray)[0], (*pointerToArray)[1], (*pointerToArray)[2]);

    printf("%zu\n", sizeof(arrayOfPointers));   // 3 pointers' worth of space (e.g. 24)
    printf("%zu\n", sizeof(pointerToArray));    // ONE pointer's worth of space (e.g. 8) --
                                                   // even though it "points to" 3 ints' worth
                                                   // of data elsewhere

    return 0;
}

The [] binds tighter than * unless parentheses say otherwise - that's the entire reason int *arr[5] (array of pointers) and int (*arr)[5] (pointer to an array) mean such different things despite looking so similar. Section 24 covers the general rule this follows.


19. Modifying a pointer itself - why some functions need a double pointer

Passing a plain pointer lets a function modify what it points to (section 1), but not what the caller's pointer variable itself points to - for that, the function needs a pointer to the caller's pointer, i.e. a double pointer.

#include <stdio.h>
#include <stdlib.h>

// WRONG for this purpose: reassigning the LOCAL COPY 'p' inside the
// function has no effect on the caller's pointer at all.
void allocateWrong(int *p) {
    p = malloc(sizeof(int));   // only changes the local copy of the pointer
    *p = 42;
}

// RIGHT: takes a pointer TO the caller's pointer, so it can modify what
// the CALLER's own pointer variable points to.
void allocateRight(int **p) {
    *p = malloc(sizeof(int));   // dereference once to reach the caller's actual pointer
    **p = 42;                    // dereference again to reach the allocated int
}

int main(void) {
    int *a = NULL;
    allocateWrong(a);
    printf("%p\n", (void *)a);   // still NULL -- allocateWrong never actually changed it

    int *b = NULL;
    allocateRight(&b);           // pass the ADDRESS of the pointer itself
    printf("%d\n", *b);          // 42 -- b now correctly points to the allocated memory
    free(b);

    return 0;
}

This exact pattern - a function taking a Type ** so it can allocate and hand back a new pointer through an "output parameter" - is extremely common in real-world C APIs, anywhere a function's job is specifically to create something and give the caller a way to reach it.


20. Function pointers - a pointer to executable code

Briefly recapped here for completeness (the functions guide covers this in full depth, sections 14–16 there): a function pointer holds the address of a function rather than a data value, letting you store, pass, and call functions indirectly.

#include <stdio.h>

int add(int a, int b) { return a + b; }

int main(void) {
    int (*operation)(int, int) = add;   // 'operation' points to the FUNCTION 'add'
    printf("%d\n", operation(3, 4));     // 7 -- called indirectly through the pointer

    return 0;
}

See the functions guide for callbacks (qsort), dispatch tables, and the full syntax breakdown - it's covered here only to place function pointers in context alongside the rest of C's pointer family.


21. Pointers to structs

Also briefly recapped here (full depth in the structs guide, sections 1.6 and 1.17): the -> operator is shorthand for dereferencing a struct pointer and accessing a member in one step.

#include <stdio.h>

typedef struct {
    int x, y;
} Point;

int main(void) {
    Point p = {3, 4};
    Point *ptr = &p;

    printf("%d\n", ptr->x);      // shorthand for (*ptr).x
    ptr->x = 100;                 // modifies the original p through the pointer
    printf("%d\n", p.x);          // 100

    return 0;
}

22. Dynamically allocated 2D arrays

A true 2D array's size is fixed at compile time (like int grid[3][4]), but real programs often need dimensions only known at runtime - built out of an array of pointers, each pointing to a separately-allocated row.

#include <stdio.h>
#include <stdlib.h>

int main(void) {
    int rows = 3, cols = 4;

    // Allocate an array of 'rows' int* pointers...
    int **grid = malloc(rows * sizeof(int *));
    if (!grid) return 1;

    // ...then allocate each ROW separately.
    for (int i = 0; i < rows; i++) {
        grid[i] = malloc(cols * sizeof(int));
        if (!grid[i]) return 1;   // (a real program would also free earlier rows here)
    }

    for (int i = 0; i < rows; i++) {
        for (int j = 0; j < cols; j++) {
            grid[i][j] = i * cols + j;   // grid[i][j] works exactly like a real 2D array
        }
    }

    for (int i = 0; i < rows; i++) {
        for (int j = 0; j < cols; j++) {
            printf("%d ", grid[i][j]);
        }
        printf("\n");
    }

    // Must free in the OPPOSITE order: every row first, THEN the array of row pointers
    for (int i = 0; i < rows; i++) {
        free(grid[i]);
    }
    free(grid);

    return 0;
}

The rows aren't guaranteed to be contiguous in memory this way (unlike a true 2D array) - for performance-sensitive code, a common alternative is a single malloc(rows * cols * sizeof(int)) block with manual row * cols + col indexing, trading a bit of readability for one allocation instead of rows + 1.


23. Pointer casting and strict aliasing

Casting a pointer from one type to another is common (void * to a concrete type, one numeric type to another), but reading memory through a pointer of a different, incompatible type than what actually created it is undefined behavior under C's "strict aliasing" rule - the same underlying issue as the union type-punning caveat from the structs guide.

#include <stdio.h>

int main(void) {
    int x = 0x12345678;

    // Reading the same memory through an INCOMPATIBLE pointer type
    // violates strict aliasing -- undefined behavior, even though it
    // often appears to "work" on common compilers/platforms:
    // float *asFloat = (float *)&x;
    // printf("%f\n", *asFloat);   // technically UB, avoid this pattern

    // The safe, portable way to reinterpret bytes between types is memcpy,
    // exactly as covered in the structs guide's protocol-parsing section:
    float asFloatSafe;
    // (Only meaningful if sizeof(int) == sizeof(float), true on most platforms)
    if (sizeof(int) == sizeof(float)) {
        __builtin_memcpy(&asFloatSafe, &x, sizeof(x));   // or plain memcpy() with <string.h>
        printf("%f\n", asFloatSafe);
    }

    // Casting between pointer-to-COMPATIBLE-type is fine and common,
    // e.g. void* to a concrete type (section 8), or up/down a type
    // hierarchy of struct types intentionally designed for it.

    return 0;
}

This is the same underlying rule referenced in the file handling guide's advice to memcpy a raw byte buffer into a struct rather than casting the buffer's pointer directly - casting-and-dereferencing across incompatible types is the actual violation; memcpy sidesteps it entirely and is the portable, standard-compliant choice.


24. Reading complex pointer declarations

C's declaration syntax is famously hard to read once pointers, arrays, and functions combine - the reliable technique is reading outward from the variable name, applying [] and () before * at each step (informally called the "spiral rule" or "clockwise/spiral rule").

#include <stdio.h>

int main(void) {
    int x = 42;

    int *p1 = &x;                    // p1 is a pointer to int
    int **p2 = &p1;                   // p2 is a pointer to (a pointer to int)
    int a1[5];                        // a1 is an array of 5 ints
    int *a2[5];                       // a2 is an array of 5 (pointers to int)
    int (*p3)[5];                     // p3 is a pointer to (an array of 5 ints)
    int (*fp)(int, int);              // fp is a pointer to (a function taking (int,int), returning int)
    int *(*fp2)(int);                 // fp2 is a pointer to (a function taking int, returning int*)

    (void)p2; (void)a1; (void)a2; (void)p3; (void)fp; (void)fp2;

    printf("Reading rule demo compiled successfully.\n");
    printf("%d\n", **p2 == x);   // 1 -- confirms p2 really does reach x through two levels
    return 0;
}

The practical version of the rule: start at the variable's name, look immediately to the right first ([] for array, () for function), then to the left (* for pointer), and repeat outward - this parses even the gnarliest real-world declarations (like fp2 above) mechanically, without needing to guess.


25. Common pitfalls checklist


Quick reference

FeatureSyntaxPurposeSection
Address-of&xGet a variable's memory address1
Dereference*pAccess the value a pointer points to1
Null pointerNULL (or 0)Explicit, checkable "points to nothing"2
Pointer arithmeticp + 1, p++Move by one ELEMENT (scaled by type size), not one byte3
Array/pointer equivalencearr[i] == *(arr + i)Indexing is defined in terms of pointer arithmetic5
Pointer to pointerint **ppA pointer whose target is itself a pointer7
Generic pointervoid *Untyped pointer, must be cast before dereferencing8
Pointer to constconst int *pValue read-only through p; p itself can move9
Const pointerint *const pp cannot move; value through it can change9
Heap allocationmalloc, calloc, reallocRuntime-sized memory allocation10
Deallocationfree(p)Release allocated memory back to the system11
Safe post-free habitp = NULL; after free(p)Defends against use-after-free and double-free12, 13
String as pointerchar * / const char *C strings are just pointers to '\0'-terminated bytes16
Array of stringsconst char *arr[N]Common shape for string lists and argv17
Output-parameter patternvoid f(Type **out)Let a function hand back a newly allocated pointer19
Function pointerRetType (*fp)(ArgTypes)Pointer to executable code (full detail: functions guide)20
Struct pointer accessptr->memberShorthand for (*ptr).member (full detail: structs guide)21
Dynamic 2D arrayarray of malloc'd row pointersRuntime-sized 2D array via pointer-to-pointer22
Safe type reinterpretationmemcpy(&dst, &src, size)Avoids strict-aliasing UB from incompatible pointer casts23
Reading declarationsstart at name, []/() before *, work outwardMechanically parse any pointer declaration24

Coverage note

This guide covers the full practical scope of pointers in C: the basic &/* model, pointer arithmetic and its equivalence with array indexing, multi-level pointers, void * and const combinations, dynamic memory allocation and its classic bug classes (leaks, use-after-free, double-free, wild pointers), pointers to strings and arrays of pointers, the output-parameter double-pointer pattern, and how to mechanically read even the gnarliest pointer declarations. Combined with the rest of the Realworld C series - especially the structs/unions and functions guides, which this one cross-references throughout - this covers everything needed to read, write, and debug pointer-based C code with real confidence.

Previous:
Structs and Unions in C
Next:
Functions in C