C Function Pointers & Callbacks: Build Event-Driven Systems and Plugin Architectures

C Function Pointers & Callbacks: Build Event-Driven Systems and Plugin Architectures
Table of Contents
- Functions Are Memory Addresses
- Declaring Function Pointer Variables
- Assigning and Calling Through Pointers
- typedef: Cleaning Up the Syntax
- Functions as Parameters: Callbacks
- Using qsort with a Comparator Callback
- Dispatch Tables: O(1) Event Handling
- The vtable Pattern: Manual Polymorphism
- Plugin Architecture in C
- Performance Characteristics
- Frequently Asked Questions
- Key Takeaway
Functions Are Memory Addresses
In C, when you define a function like int add(int a, int b), the compiled code for that function is placed in a specific location in your program's text (code) segment. The function's name is a convenient label for that location's address.
Just as int x = 5 has an address (&x), a function int add(int a, int b) has an address (add or equivalently &add). A function pointer is a variable that stores this address:
#include <stdio.h>
int add(int a, int b) { return a + b; }
int mul(int a, int b) { return a * b; }
int main(void) {
printf("Address of add: %p\n", (void*)add);
printf("Address of mul: %p\n", (void*)mul);
// These will print different hex addresses — two actual code locations
return 0;
}Declaring Function Pointer Variables
The syntax for a function pointer follows a specific pattern: ReturnType (*name)(ParameterTypes):
// Pointer to a function that:
// - returns int
// - takes (int, int) as arguments
int (*math_op)(int, int);
// Pointer to a function that:
// - returns void
// - takes no arguments
void (*on_event)(void);
// Pointer to a function that:
// - returns const char*
// - takes (int, const char*) as arguments
const char* (*formatter)(int, const char*);
// Pointer to a function that:
// - returns void*
// - takes a size_t argument
void* (*allocator)(size_t);The (*name) with parentheses is essential. Without them, int *name(int, int) declares a function that returns int* — not a pointer to a function.
Assigning and Calling Through Pointers
#include <stdio.h>
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; }
int main(void) {
int (*op)(int, int); // Declare the function pointer
op = add; // Assign: op now points to add()
printf("5 + 3 = %d\n", op(5, 3)); // Call: same as add(5, 3)
op = subtract; // Reassign to a different function
printf("5 - 3 = %d\n", op(5, 3)); // Now calls subtract()
op = &multiply; // &function is also valid (equivalent to 'multiply')
printf("5 * 3 = %d\n", (*op)(5, 3)); // (*op)() is also valid call syntax
return 0;
}Both op(5, 3) and (*op)(5, 3) are valid — the C standard permits both. The modern style omits the * for cleaner code.
typedef: Cleaning Up the Syntax
Function pointer syntax is notoriously verbose. Professional code almost always uses typedef to create a clean type alias:
#include <stdio.h>
#include <stdint.h>
// Define 'MathOperation' as the type for any function: int f(int, int)
typedef int (*MathOperation)(int, int);
// Define 'EventHandler' as the type for any function: void f(int event_code)
typedef void (*EventHandler)(int event_code);
// Define 'Comparator' as the type for any function: int f(const void*, const void*)
typedef int (*Comparator)(const void*, const void*);
int add(int a, int b) { return a + b; }
void display_result(MathOperation op, int x, int y) {
printf("Result: %d\n", op(x, y));
}
int main(void) {
MathOperation my_op = add;
display_result(my_op, 10, 7); // Result: 17
return 0;
}With typedef, MathOperation my_op = add reads almost like a regular variable declaration — a massive improvement in readability.
Functions as Parameters: Callbacks
The canonical use of function pointers is as callback parameters — passing behavior to a function rather than hardcoding it:
#include <stdio.h>
#include <stdint.h>
typedef void (*Transformer)(int32_t *value);
void apply_to_array(int32_t *arr, size_t count, Transformer callback) {
for (size_t i = 0; i < count; i++) {
callback(&arr[i]);
}
}
// Three different behaviors to plug in:
void double_it(int32_t *v) { *v *= 2; }
void square_it(int32_t *v) { *v = (*v) * (*v); }
void negate_it(int32_t *v) { *v = -(*v); }
int main(void) {
int32_t data[] = {1, 2, 3, 4, 5};
apply_to_array(data, 5, double_it);
// data: {2, 4, 6, 8, 10}
apply_to_array(data, 5, square_it);
// data: {4, 16, 36, 64, 100}
for (size_t i = 0; i < 5; i++) printf("%d ", data[i]);
printf("\n");
return 0;
}This is the Strategy Pattern in C — the algorithm (the transformer) is swapped out at runtime without changing the container logic (apply_to_array). This exact pattern appears in the C standard library's qsort.
Using qsort with a Comparator Callback
qsort from <stdlib.h> sorts any array using a user-provided comparator:
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
// Comparator: returns negative, zero, or positive
int compare_ints(const void *a, const void *b) {
int ia = *(const int*)a;
int ib = *(const int*)b;
return (ia > ib) - (ia < ib); // Branchless comparison
}
int compare_strings(const void *a, const void *b) {
return strcmp(*(const char**)a, *(const char**)b);
}
int main(void) {
int numbers[] = {64, 34, 25, 12, 22, 11, 90};
size_t n = sizeof(numbers) / sizeof(numbers[0]);
qsort(numbers, n, sizeof(int), compare_ints);
for (size_t i = 0; i < n; i++) printf("%d ", numbers[i]);
// 11 12 22 25 34 64 90
printf("\n");
// Sort array of strings
const char *words[] = {"banana", "apple", "cherry", "date"};
qsort(words, 4, sizeof(char*), compare_strings);
for (int i = 0; i < 4; i++) printf("%s ", words[i]);
// apple banana cherry date
return 0;
}Dispatch Tables: O(1) Event Handling
Instead of a long switch statement mapping event codes to handlers, a dispatch table (an array of function pointers) achieves O(1) dispatch:
#include <stdio.h>
typedef void (*Handler)(void);
// Handlers for each event type
void on_mouse_click(void) { printf("Mouse clicked\n"); }
void on_key_press(void) { printf("Key pressed\n"); }
void on_resize(void) { printf("Window resized\n"); }
void on_close(void) { printf("Window closed\n"); }
// Dispatch table indexed by event code
Handler event_table[] = {
on_mouse_click, // Event code 0
on_key_press, // Event code 1
on_resize, // Event code 2
on_close, // Event code 3
};
#define EVENT_TABLE_SIZE (sizeof(event_table) / sizeof(event_table[0]))
void handle_event(int event_code) {
if ((size_t)event_code >= EVENT_TABLE_SIZE || !event_table[event_code]) return;
event_table[event_code](); // O(1) dispatch — no switch needed
}
int main(void) {
handle_event(0); // Mouse clicked
handle_event(2); // Window resized
handle_event(1); // Key pressed
return 0;
}This pattern is used in operating system interrupt descriptor tables (IDT), protocol handlers in network stacks, and GUI event systems.
The vtable Pattern: Manual Polymorphism
C++ virtual methods are implemented as function pointer tables (vtables). In C, you can replicate this explicitly. This is exactly how the Linux Virtual File System (VFS) works — struct file_operations contains function pointers for read, write, ioctl, etc.:
#include <stdio.h>
#include <stddef.h>
// "Interface" definition — every Animal implements these operations
typedef struct {
void (*speak)(void *self);
void (*move)(void *self);
const char* (*get_name)(const void *self);
} AnimalVTable;
// Concrete type: Dog
typedef struct {
const AnimalVTable *vtable; // Pointer to the vtable — FIRST member
char name[32];
} Dog;
void dog_speak(void *self) { printf("Woof!\n"); }
void dog_move(void *self) { printf("Dog runs\n"); }
const char* dog_name(const void *self) { return ((Dog*)self)->name; }
static const AnimalVTable dog_vtable = {
.speak = dog_speak,
.move = dog_move,
.get_name = dog_name,
};
// Generic function that works with ANY animal through the vtable
void polymorphic_act(void *animal) {
const AnimalVTable *vt = *(const AnimalVTable**)animal;
printf("Name: %s\n", vt->get_name(animal));
vt->speak(animal);
vt->move(animal);
}
int main(void) {
Dog rex = { .vtable = &dog_vtable, .name = "Rex" };
polymorphic_act(&rex);
return 0;
}This is the foundation of how GObject (used in GTK, GNOME), GLib, and the Linux kernel implement OOP in C.
Plugin Architecture in C
Function pointers via dlopen/dlsym (POSIX) enable dynamic plugin loading:
#include <dlfcn.h>
#include <stdio.h>
int main(void) {
// Load a shared library at runtime
void *handle = dlopen("./my_plugin.so", RTLD_LAZY);
if (!handle) { fprintf(stderr, "%s\n", dlerror()); return 1; }
// Look up a function by name — returns void* (cast to function pointer)
typedef int (*PluginInit)(const char*);
PluginInit init = (PluginInit)dlsym(handle, "plugin_init");
if (init) {
int result = init("config.json");
printf("Plugin initialized: %d\n", result);
}
dlclose(handle);
return 0;
}This is how Apache modules, Nginx modules, and countless plugin-based systems load behavior at runtime without recompiling the host application.
Performance Characteristics
A function pointer call ((*fp)(args)) is an indirect call:
- Compiles to
CALL [register]on x86-64. - The CPU's branch predictor cannot predict the destination as easily as a direct call.
- Typical overhead: 1-5 nanoseconds per call (negligible for most use cases).
- In tight inner loops called millions of times per second, direct function calls or inlining are preferred.
Modern CPUs have improved indirect branch prediction (especially post-Spectre mitigations), so the overhead is rarely significant outside of the most performance-critical microarchitecture-sensitive code.
Frequently Asked Questions
Can a function pointer to a function with the wrong signature cause crashes?
Yes — this is undefined behavior. If you cast a function of type void f(int) to void (*)(double) and call it, the argument passing convention will be wrong and the result is unpredictable. Always ensure the function pointer type precisely matches the function's actual signature.
Can I store NULL in a function pointer?
Yes — NULL (or in C23, nullptr) is a valid null function pointer. You should check for NULL before calling: if (callback != NULL) callback(args);. This is the standard pattern in callback-based APIs where a callback is optional.
Are there any tools for finding function pointer bugs?
Clang's -fsanitize=function flag enables Function Sanitizer, which detects calls through function pointers with mismatched types. AddressSanitizer also detects NULL function pointer dereferences.
What is the signal() function signature — a famous example?
signal() from <signal.h> has one of the most complex function pointer signatures in the standard library: void (*signal(int sig, void (*func)(int)))(int). It takes a signal number and a handler pointer, and returns a pointer to the previous handler. Most code uses typedef void (*SigHandler)(int) to make this readable.
Key Takeaway
Function pointers are the secret weapon of Extensible C Systems. They transform logic from a compile-time fixed thing into a runtime-configurable variable. By mastering callbacks, dispatch tables, and vtable patterns, you gain the ability to build systems that are as architecturally sophisticated as any C++ or Java framework — but with zero language overhead.
Read next: Void Pointers & Generic C: Type-Erased Programming →
Part of the C Mastery Course — 30 modules from C basics to expert systems engineering.
