Function Pointers in C Explained — Syntax, Callbacks, and Real-World Patterns
Every non-trivial C program eventually hits a wall: you need a piece of code to behave differently depending on context, but you don't want to litter your logic with a dozen if-else branches. Sort algorithms need comparison logic. Event loops need to dispatch to different handlers. Plugin systems need to call code that didn't exist when the core was compiled. In every one of these cases, function pointers are the tool C gives you — and understanding them is what separates C programmers who write clever hacks from those who write clean, extensible systems.
The problem function pointers solve is simple: in C, functions are not first-class values you can pass around the way you'd pass an integer. But their addresses are. A function pointer stores that address, which means you can store a function in a variable, pass it as an argument, return it from another function, or keep a whole table of them. This is how the C standard library's qsort works, how operating system kernels register interrupt handlers, and how game engines implement entity behavior without a class hierarchy.
By the end of this article you'll be able to declare and call function pointers without second-guessing the syntax, build a working callback system, construct a dispatch table that replaces a cascade of if-else statements, and spot the two errors that burn every developer the first time they use function pointers in production code.
Declaring and Calling a Function Pointer — Getting the Syntax Right Once and For All
The syntax for function pointers trips people up because the asterisk belongs to the name, not the return type. Read the declaration from the inside out: the name of the variable is in the middle, wrapped in parentheses with an asterisk, and the surrounding parts describe what function signature it can point to.
For a function that takes two ints and returns an int, the pointer type is: int (operation)(int, int). That parentheses around operation is mandatory — without it, int operation(int, int) is a completely different thing: a function named operation that returns int .
Once you have the pointer, calling it is straightforward. Modern C allows you to call it directly as operation(a, b) — the compiler knows it's a pointer and handles the dereference. The older explicit-dereference syntax (*operation)(a, b) also works and makes the pointer nature more obvious. Both styles are valid; pick one and be consistent within a codebase.
The cleanest long-term habit is to use a typedef to name your function pointer type. It costs you one line and saves you from writing the hairy syntax everywhere the type appears — especially in struct definitions and function parameters.
#include <stdio.h> // Three simple math operations — all share the same signature: (int, int) -> int int add(int a, int b) { return a + b; } int subtract(int a, int b) { return a - b; } int multiply(int a, int b) { return a * b; } // typedef gives the function pointer type a readable name. // Read as: MathOperation is a pointer to a function taking two ints, returning int. typedef int (*MathOperation)(int, int); int main(void) { // Declare a function pointer and assign it — no & needed, function name decays to its address MathOperation operation = add; printf("add(10, 4) = %d\n", operation(10, 4)); // calls add via pointer operation = subtract; // reassign — now points to subtract printf("subtract(10, 4) = %d\n", operation(10, 4)); operation = multiply; // reassign again printf("multiply(10, 4) = %d\n", operation(10, 4)); // You can also use the explicit dereference form — identical behaviour MathOperation explicit_op = add; printf("(*explicit_op)(10, 4) = %d\n", (*explicit_op)(10, 4)); return 0; }
subtract(10, 4) = 6
multiply(10, 4) = 40
(*explicit_op)(10, 4) = 14
Callbacks — Passing Functions as Arguments the Way qsort Does
A callback is nothing more than a function pointer you pass to another function so that function can call yours at the right moment. It's the foundational pattern behind event-driven systems, custom sorting, plugin architectures, and async I/O notification.
The C standard library's qsort is the example every C programmer meets first. You hand qsort your array and a comparator — a function pointer that tells qsort how to decide which of two elements is 'less than' the other. qsort doesn't care what you're sorting or how you define order; it just calls your comparator whenever it needs to compare two elements. That separation of concerns is exactly why callbacks are powerful.
Building your own callback-based API follows the same pattern. You design a function that accepts a function pointer parameter. Callers provide different functions to customize behavior. Your core logic stays untouched.
This is also how GUI toolkits register button-click handlers, how operating systems deliver timer signals, and how networking libraries notify your code when data arrives. Once you recognize the pattern, you'll see it everywhere.
#include <stdio.h> #include <stdlib.h> #include <string.h> // --- Part 1: Building a custom qsort comparator --- typedef struct { char name[32]; int score; } Player; // Comparator for ascending score order. // qsort passes void* pointers — we cast them to the type we know they are. int compare_score_ascending(const void *a, const void *b) { const Player *player_a = (const Player *)a; const Player *player_b = (const Player *)b; return player_a->score - player_b->score; // negative = a comes first } // Comparator for descending score order — just flip the subtraction int compare_score_descending(const void *a, const void *b) { const Player *player_a = (const Player *)a; const Player *player_b = (const Player *)b; return player_b->score - player_a->score; } // --- Part 2: Our own callback-based filter function --- typedef int (*PlayerPredicate)(const Player *player); // Calls predicate on every player; prints those for which predicate returns non-zero. // The caller decides the filter rule — this function stays generic. void print_filtered_players(const Player *players, int count, PlayerPredicate predicate) { for (int i = 0; i < count; i++) { if (predicate(&players[i])) { // callback invoked here printf(" %-15s %d\n", players[i].name, players[i].score); } } } // Two different filter predicates — same signature, different logic int is_high_scorer(const Player *player) { return player->score >= 80; } int is_low_scorer(const Player *player) { return player->score < 50; } int main(void) { Player players[] = { {"Alice", 92}, {"Bob", 45}, {"Charlie", 78}, {"Diana", 33}, {"Eve", 88} }; int count = sizeof(players) / sizeof(players[0]); // Sort ascending using qsort + our comparator callback qsort(players, count, sizeof(Player), compare_score_ascending); printf("Sorted ascending by score:\n"); for (int i = 0; i < count; i++) { printf(" %-15s %d\n", players[i].name, players[i].score); } // Sort descending — swap the callback, everything else stays the same qsort(players, count, sizeof(Player), compare_score_descending); printf("\nSorted descending by score:\n"); for (int i = 0; i < count; i++) { printf(" %-15s %d\n", players[i].name, players[i].score); } // Use our custom filter with two different predicate callbacks printf("\nHigh scorers (>= 80):\n"); print_filtered_players(players, count, is_high_scorer); // passes predicate printf("\nLow scorers (< 50):\n"); print_filtered_players(players, count, is_low_scorer); // different predicate, same function return 0; }
Diana 33
Bob 45
Charlie 78
Alice 92
Eve 88
Sorted descending by score:
Alice 92
Eve 88
Charlie 78
Bob 45
Diana 33
High scorers (>= 80):
Alice 92
Eve 88
Low scorers (< 50):
Bob 45
Diana 33
Dispatch Tables — Replacing if-else Chains With an Array of Function Pointers
Once you can store a function pointer in a variable, you can store them in an array. An array of function pointers is called a dispatch table (or jump table), and it's one of the most useful patterns in systems programming.
Consider a simple calculator that handles four operations. The naive approach is a chain of if-else or a switch statement. That works for four operations, but what about forty? You'd have forty branches, and adding a new operation means touching the dispatcher every single time — a maintenance headache and a violation of the open-closed principle even in C.
A dispatch table maps an index (or enum value) directly to a function. Adding a new operation is adding one entry to the table. The dispatcher doesn't change at all.
This pattern is how CPU emulators dispatch opcodes, how HTTP servers route methods, and how state machines transition between states. The Linux kernel uses tables of function pointers (called file_operations, net_proto_ops, etc.) to implement its driver interface — every device driver is essentially a struct full of function pointers.
#include <stdio.h> #include <string.h> // --- A tiny command dispatcher using a dispatch table --- // All command handler functions share this signature typedef void (*CommandHandler)(const char *argument); // The actual command implementations void handle_hello(const char *argument) { printf("Hello, %s!\n", argument[0] ? argument : "world"); } void handle_shout(const char *argument) { // Print the argument in uppercase-style by prepending SHOUT: printf("SHOUT: "); for (int i = 0; argument[i] != '\0'; i++) { // Convert lowercase letters to uppercase manually char c = argument[i]; if (c >= 'a' && c <= 'z') c -= 32; putchar(c); } putchar('\n'); } void handle_count(const char *argument) { int length = 0; while (argument[length] != '\0') length++; printf("'%s' has %d characters.\n", argument, length); } void handle_reverse(const char *argument) { int length = 0; while (argument[length] != '\0') length++; printf("Reversed: "); for (int i = length - 1; i >= 0; i--) putchar(argument[i]); putchar('\n'); } // --- The dispatch table itself --- // A struct pairs a command name with its handler — clean and extensible. typedef struct { const char *command_name; CommandHandler handler; // function pointer stored inside a struct } DispatchEntry; // Adding a new command = adding one row here. The dispatcher never changes. static const DispatchEntry dispatch_table[] = { { "hello", handle_hello }, { "shout", handle_shout }, { "count", handle_count }, { "reverse", handle_reverse }, }; // Calculate table size at compile time — avoids magic numbers static const int TABLE_SIZE = sizeof(dispatch_table) / sizeof(dispatch_table[0]); // The dispatcher: walks the table, finds the matching entry, calls the handler. void dispatch(const char *command, const char *argument) { for (int i = 0; i < TABLE_SIZE; i++) { if (strcmp(dispatch_table[i].command_name, command) == 0) { dispatch_table[i].handler(argument); // function pointer call return; } } printf("Unknown command: '%s'\n", command); } int main(void) { dispatch("hello", "Alice"); dispatch("shout", "make some noise"); dispatch("count", "function pointers"); dispatch("reverse", "dispatch"); dispatch("quit", ""); // not in the table — triggers unknown-command path return 0; }
SHOUT: MAKE SOME NOISE
'function pointers' has 17 characters.
Reversed: hctapsid
Unknown command: 'quit'
Function Pointers in Structs — How C Fakes Object-Oriented Design
Here's where function pointers go from useful to genuinely powerful. If you put function pointers inside a struct, the struct gains behavior — it's not just data anymore. This is exactly how C++ implements virtual functions under the hood, and it's how the C standard library and the Linux kernel achieve polymorphism without C++.
The pattern works like this: you define a struct that holds both the data fields and function pointer fields. Different 'instances' can point their function pointers at different implementations. Code that operates on the struct calls the function through the pointer without knowing which implementation it's talking to. That's runtime polymorphism in plain C.
This matters in practice because C is used in environments where C++ isn't an option — embedded microcontrollers, kernel modules, real-time systems. Knowing how to build a clean interface with function pointers lets you write genuinely reusable, extensible C code rather than copying and modifying logic for every new variant.
The pattern also teaches you something important about C++: when you understand that a vtable is just a compiler-generated dispatch table of function pointers attached to every object through a hidden pointer, virtual functions stop feeling like magic.
#include <stdio.h> #include <math.h> // --- Runtime polymorphism in C using function pointers in structs --- // Forward declaration — Shape needs to refer to itself typedef struct Shape Shape; // A 'virtual function table': a struct of function pointers. // Every Shape type provides its own implementation of these. typedef struct { double (*area)(const Shape *self); // how to compute this shape's area double (*perimeter)(const Shape *self); // how to compute its perimeter void (*describe)(const Shape *self); // how to print a description } ShapeOps; // The base Shape struct: holds a pointer to its ops table and shared data. struct Shape { const ShapeOps *ops; // pointer to the function table — this IS the polymorphism const char *label; // a human-readable name for printing }; // --- Circle implementation --- typedef struct { Shape base; // MUST be first — allows safe casting between Shape* and Circle* double radius; } Circle; double circle_area(const Shape *self) { const Circle *circle = (const Circle *)self; // safe: base is first member return M_PI * circle->radius * circle->radius; } double circle_perimeter(const Shape *self) { const Circle *circle = (const Circle *)self; return 2.0 * M_PI * circle->radius; } void circle_describe(const Shape *self) { const Circle *circle = (const Circle *)self; printf("Circle (radius=%.2f)\n", circle->radius); } // The ops table for circles — one shared instance for all circles static const ShapeOps CIRCLE_OPS = { circle_area, circle_perimeter, circle_describe }; void circle_init(Circle *circle, double radius) { circle->base.ops = &CIRCLE_OPS; // wire up the function table circle->base.label = "circle"; circle->radius = radius; } // --- Rectangle implementation --- typedef struct { Shape base; double width; double height; } Rectangle; double rect_area(const Shape *self) { const Rectangle *rect = (const Rectangle *)self; return rect->width * rect->height; } double rect_perimeter(const Shape *self) { const Rectangle *rect = (const Rectangle *)self; return 2.0 * (rect->width + rect->height); } void rect_describe(const Shape *self) { const Rectangle *rect = (const Rectangle *)self; printf("Rectangle (%.2f x %.2f)\n", rect->width, rect->height); } static const ShapeOps RECT_OPS = { rect_area, rect_perimeter, rect_describe }; void rect_init(Rectangle *rect, double width, double height) { rect->base.ops = &RECT_OPS; rect->base.label = "rectangle"; rect->width = width; rect->height = height; } // --- Generic function that works on ANY Shape --- // It never knows if it has a circle or rectangle — it just calls through the ops pointer. void print_shape_stats(const Shape *shape) { shape->ops->describe(shape); // polymorphic call printf(" Area: %.4f\n", shape->ops->area(shape)); printf(" Perimeter: %.4f\n", shape->ops->perimeter(shape)); } int main(void) { Circle c; rect_init(NULL, 0, 0); // just declarations Rectangle r; circle_init(&c, 5.0); rect_init(&r, 4.0, 6.0); // An array of base Shape pointers — holds mixed types const Shape *shapes[] = { (Shape *)&c, (Shape *)&r }; int shape_count = sizeof(shapes) / sizeof(shapes[0]); for (int i = 0; i < shape_count; i++) { print_shape_stats(shapes[i]); // same call, different behavior printf("\n"); } return 0; }
Area: 78.5398
Perimeter: 31.4159
Rectangle (4.00 x 6.00)
Area: 24.0000
Perimeter: 20.0000
| Aspect | Function Pointer | Direct Function Call |
|---|---|---|
| Flexibility | Runtime decision — any matching function | Compile-time decision — fixed function |
| Performance | One extra pointer dereference (negligible) | Inlineable — zero overhead possible |
| Syntax complexity | Requires careful declaration and typedef | Straightforward, no extra setup |
| Use case | Callbacks, plugins, dispatch tables, polymorphism | Known, fixed behavior — most code |
| Debuggability | Harder — must inspect pointer value in debugger | Easy — function name visible in call stack |
| Null risk | Calling NULL pointer = crash (undefined behavior) | No null risk — call site is always resolved |
| Testability | Easy to swap implementations in tests | Requires wrapping or recompiling to mock |
🎯 Key Takeaways
- The asterisk in a function pointer declaration belongs to the name, not the return type —
int (op)(int, int)is a pointer to a function;int op(int, int)is a function that returns a pointer. Get this backwards and you get a confusing compiler error. - typedef your function pointer types immediately — it's one line of investment that makes every struct, parameter list, and return type that uses the type readable and maintainable.
- A dispatch table (array of function pointers keyed by a command name or enum value) is the clean alternative to long switch or if-else chains — adding a new case means adding one table entry, not touching dispatcher logic.
- Function pointers inside structs give you runtime polymorphism in C — the same mechanism the Linux kernel uses for device drivers and that C++ compilers use to implement virtual functions via vtables.
⚠ Common Mistakes to Avoid
- ✕Mistake 1: Missing parentheses around the pointer name in the declaration — writing
int handler(int)instead ofint (handler)(int)— the first declares a function that returns int, not a pointer to a function. Your compiler will catch this but the error message is confusing. Fix: always wrap the asterisk and name together in parentheses, then write the parameter list outside:int (handler)(int). - ✕Mistake 2: Calling a NULL function pointer — if a function pointer is uninitialized or set to NULL and you call it, you get undefined behavior (typically a segfault). This is silent and devastating in embedded systems where you can't always attach a debugger. Fix: add a NULL guard before every call through a function pointer you didn't personally initialize:
if (handler != NULL) { handler(arg); }. - ✕Mistake 3: Casting a function pointer to an incompatible signature and calling it — for example, casting a
void ()(int)tovoid ()(int, int)to 'add a parameter.' This compiles without warnings if you're explicit about the cast, but it's undefined behavior. The calling convention mismatch corrupts the stack. Fix: every function you assign to a pointer must exactly match the declared signature — return type, parameter count, and parameter types all included. If you need flexibility, use void* parameters with a documented convention (the qsort model) rather than casting.
Interview Questions on This Topic
- QCan you explain the difference between `int *foo(int)` and `int (*foo)(int)` in C, and why does the distinction matter?
- QHow would you implement a basic plugin system in C where new behaviors can be added without modifying the core dispatcher? Walk me through the data structures and function signatures you'd design.
- QWhat is a vtable in C++, and how does its mechanism relate to what you'd build manually with function pointers in a C struct? What is the runtime cost of a virtual function call compared to a direct call?
Frequently Asked Questions
What is a function pointer in C and when should I use one?
A function pointer is a variable that holds the memory address of a function. Use one when you need to decide at runtime which function to call — for callbacks, custom sort comparators, event handlers, or dispatch tables. If you find yourself writing a long if-else chain that calls different functions based on a mode flag, a function pointer table is almost always the cleaner solution.
Do I need the ampersand (&) when assigning a function to a function pointer?
No. In C, a function name in an expression context automatically decays to a pointer to that function — exactly the same way an array name decays to a pointer to its first element. Writing handler = my_func and handler = &my_func are equivalent. Most C programmers omit the & to keep it concise, but both forms are valid.
Is calling a function through a pointer slower than calling it directly?
In practice, the difference is negligible for the vast majority of code — it's one extra memory dereference. The measurable cost comes indirectly: the compiler cannot inline a call through a function pointer the way it can inline a direct call, so you lose the compiler's ability to optimize across the call boundary. This matters in tight inner loops. For everything else — event handlers, dispatch tables, callbacks that run occasionally — the flexibility is worth far more than the tiny overhead.
Written and reviewed by senior developers with real-world experience across enterprise, startup and open-source projects. Every article on TheCodeForge is written to be clear, accurate and genuinely useful — not just SEO filler.