C Function Pointers: Callbacks and Runtime Dispatch

Function pointers in the C programming language provide a way to store, pass, and invoke executable code dynamically at runtime. Because C lacks native object-oriented features like polymorphism and high-level abstractions like first-class functions, function pointers act as the foundational mechanism for implementing both callbacks and runtime dispatch. This article explores how function pointers operate, how they decouple producers and consumers through callback architectures, and how they emulate dynamic method dispatch and jump tables for flexible system design.

The Fundamentals of Function Pointers

In C, functions reside in memory within the text (code) segment, and their names resolve to their entry-point addresses. A function pointer variable holds this memory address rather than a data value.

The declaration syntax mirrors the signature of the target function:

return_type (*pointer_name)(parameter_types);

For instance, a pointer to a function taking two integers and returning an integer is declared as:

int (*operation)(int, int);

By assigning an address to this pointer, the program can invoke the underlying function indirectly using standard call syntax: operation(5, 3).

Enabling Callback Mechanisms

A callback is a function passed as an argument to another function, which the receiving code invokes when a specific event occurs or a condition is met. This pattern decouples high-level policy from low-level execution logic.

1. Inversion of Control

Without function pointers, a processing function must hardcode the actions it takes on data. By accepting a function pointer, the function delegates execution back to the caller's specific implementation.

A classic example is the C Standard Library's qsort function:

void qsort(void *base, size_t nitems, size_t size, int (*compar)(const void *, const void*));

The sorting algorithm handles array partitioning and element swapping, but it delegates comparison logic to the user-supplied compar callback.

2. Custom Callback Implementation

Consider an asynchronous or decoupled event processing pipeline:

#include <stdio.h>

typedef void (*DataHandler)(const char *data);

void fetch_network_data(DataHandler on_success, DataHandler on_error) {
    int success = 1; // Simulated operation result
    if (success) {
        on_success("Payload: 200 OK");
    } else {
        on_error("Error: 500 Server Error");
    }
}

void log_success(const char *data) {
    printf("Success: %s\n", data);
}

void log_error(const char *data) {
    fprintf(stderr, "Failure: %s\n", data);
}

int main(void) {
    fetch_network_data(log_success, log_error);
    return 0;
}

Here, fetch_network_data remains agnostic about how data or errors are handled, allowing callers to plug in logging, UI updates, or custom retry routines without modifying the network logic.

Implementing Runtime Dispatch

Runtime dispatch refers to determining which concrete function executes while the program is actively running, rather than fixing it during compilation. C relies on function pointers to achieve this via two primary patterns: Jump Tables and Virtual Method Tables (Vtables).

1. Jump Tables (Arrays of Function Pointers)

A jump table maps keys, command codes, or state machine states directly to function addresses. This eliminates lengthy switch-case or if-else chains and provides \(O(1)\) time-complexity dispatching.

#include <stdio.h>

int add(int a, int b) { return a + b; }
int sub(int a, int b) { return a - b; }
int mul(int a, int b) { return a * b; }

typedef int (*MathOp)(int, int);

int main(void) {
    // Array of function pointers serving as a jump table
    MathOp operations[] = { add, sub, mul };

    int op_code = 2; // Selected at runtime (e.g., user input)
    int a = 10, b = 5;

    if (op_code >= 0 && op_code < 3) {
        int result = operations[op_code](a, b);
        printf("Result: %d\n", result); // Output: 50
    }

    return 0;
}

2. Simulated Polymorphism via Structs

Object-oriented languages automatically manage virtual method tables to support polymorphism. In C, developers achieve identical behavior by embedding function pointers within a struct, effectively creating an interface contract.

#include <stdio.h>

typedef struct Shape Shape;

struct Shape {
    void (*draw)(Shape *self);
    int x, y;
};

void draw_circle(Shape *self) {
    printf("Drawing circle at (%d, %d)\n", self->x, self->y);
}

void draw_square(Shape *self) {
    printf("Drawing square at (%d, %d)\n", self->x, self->y);
}

int main(void) {
    Shape circle = { .draw = draw_circle, .x = 10, .y = 20 };
    Shape square = { .draw = draw_square, .x = 5,  .y = 15 };

    Shape *shapes[] = { &circle, &square };

    for (int i = 0; i < 2; i++) {
        shapes[i]->draw(shapes[i]); // Dynamic dispatch
    }

    return 0;
}

The loop processes instances uniformly, invoking the appropriate implementation determined at initialization.

Performance and Safety Considerations

While function pointers introduce significant architectural flexibility, they carry distinct trade-offs:

  • Compiler Optimizations: Indirect calls through pointers generally prevent inlining, which introduces minor call overhead and limits dead-code elimination across call boundaries.
  • Branch Prediction: Modern CPUs rely on branch target buffers (BTB) to predict indirect branch targets. Irregular or erratic function pointer targets can lead to pipeline stalls due to mispredictions.
  • Type Safety: Improper casting of function pointers invokes undefined behavior under the C standard. Signatures must match strictly to ensure that stack frames and register conventions remain intact.
  • Security: Function pointers located in writable memory segments can be targets for memory corruption exploits (e.g., buffer overflows leading to control-flow hijacking). Where possible, dispatch tables should be marked const to reside in read-only memory.