Function Pointers and Callbacks

A function pointer stores the address of a function. Combined with a context pointer, it becomes a callback -- the mechanism C uses for polymorphism, event handling, and plugin architectures. Rust replaces the pattern with closures and traits.

C Function Pointer Syntax

The syntax is notoriously hard to read. The parentheses around *fn are mandatory -- without them you declare a function returning a pointer, not a pointer to a function.

/* fnptr_basic.c */
#include <stdio.h>

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

int main(void)
{
    /* Declare a function pointer */
    int (*op)(int, int);

    op = add;
    printf("add(3,4) = %d\n", op(3, 4));   /* 7 */

    op = mul;
    printf("mul(3,4) = %d\n", op(3, 4));   /* 12 */

    /* You can also call through the pointer explicitly */
    printf("(*op)(5,6) = %d\n", (*op)(5, 6));  /* 30 */

    return 0;
}
  Memory layout:

  op (8 bytes on x86-64)
  +------------------+
  | address of add() |   or   | address of mul() |
  +------------------+
          |
          v
  .text section: machine code for the function

typedef for Readability

Always typedef function pointer types. Compare:

/* Without typedef */
int (*get_op(char c))(int, int);

/* With typedef */
typedef int (*binop_fn)(int, int);
binop_fn get_op(char c);

The second is instantly readable. The first requires right-left parsing.

/* fnptr_typedef.c */
#include <stdio.h>

typedef int (*binop_fn)(int, int);

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; }

binop_fn get_op(char c)
{
    switch (c) {
    case '+': return add;
    case '-': return sub;
    case '*': return mul;
    default:  return NULL;
    }
}

int main(void)
{
    char ops[] = "+-*";
    for (int i = 0; i < 3; i++) {
        binop_fn f = get_op(ops[i]);
        if (f)
            printf("10 %c 3 = %d\n", ops[i], f(10, 3));
    }
    return 0;
}

Try It: Add a division operator to get_op. Handle division by zero inside the div function by returning 0 and printing a warning.

Passing Functions as Arguments

The C standard library uses function pointers extensively.

qsort

/* qsort_fp.c */
#include <stdio.h>
#include <stdlib.h>

int ascending(const void *a, const void *b)
{
    return *(const int *)a - *(const int *)b;
}

int descending(const void *a, const void *b)
{
    return *(const int *)b - *(const int *)a;
}

void print_array(const int *arr, int n)
{
    for (int i = 0; i < n; i++)
        printf("%d ", arr[i]);
    printf("\n");
}

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

    qsort(nums, 5, sizeof(int), ascending);
    printf("ascending:  "); print_array(nums, 5);

    qsort(nums, 5, sizeof(int), descending);
    printf("descending: "); print_array(nums, 5);

    return 0;
}

signal

/* signal_fp.c */
#include <stdio.h>
#include <signal.h>
#include <unistd.h>

void handle_sigint(int sig)
{
    /* Async-signal-safe: only write() is safe here */
    const char msg[] = "\nCaught SIGINT\n";
    write(STDOUT_FILENO, msg, sizeof(msg) - 1);
    _exit(0);
}

int main(void)
{
    signal(SIGINT, handle_sigint);
    printf("Press Ctrl-C...\n");
    while (1)
        pause();   /* sleep until a signal arrives */
    return 0;
}

pthread_create

/* pthread_fp.c */
#include <stdio.h>
#include <pthread.h>

void *worker(void *arg)
{
    int id = *(int *)arg;
    printf("Thread %d running\n", id);
    return NULL;
}

int main(void)
{
    pthread_t threads[3];
    int ids[3] = {1, 2, 3};

    for (int i = 0; i < 3; i++)
        pthread_create(&threads[i], NULL, worker, &ids[i]);

    for (int i = 0; i < 3; i++)
        pthread_join(threads[i], NULL);

    return 0;
}

Compile with: gcc -pthread pthread_fp.c -o pthread_fp

Callback Pattern: Function Pointer + void* Context

A callback alone is rarely enough. You usually need to pass some context -- user-defined data the callback can access. In C, this is done with a void*.

/* callback_ctx.c -- callback with context */
#include <stdio.h>

typedef void (*event_handler)(int event_id, void *ctx);

struct logger_ctx {
    const char *prefix;
    int         count;
};

void log_event(int event_id, void *ctx)
{
    struct logger_ctx *log = (struct logger_ctx *)ctx;
    log->count++;
    printf("[%s] event %d (total: %d)\n", log->prefix, event_id, log->count);
}

struct event_source {
    event_handler handler;
    void         *ctx;
};

void event_source_fire(struct event_source *src, int event_id)
{
    if (src->handler)
        src->handler(event_id, src->ctx);
}

int main(void)
{
    struct logger_ctx my_log = { .prefix = "app", .count = 0 };

    struct event_source src = {
        .handler = log_event,
        .ctx     = &my_log,
    };

    event_source_fire(&src, 100);
    event_source_fire(&src, 200);
    event_source_fire(&src, 300);

    return 0;
}
  event_source
  +-------------+-----------+
  | handler: *  | ctx: *    |
  +------+------+-----+-----+
         |            |
         v            v
    log_event()   logger_ctx { prefix, count }

Caution: The void* ctx pointer is completely unchecked. If you register the wrong context type for a given handler, the cast inside the handler will silently interpret garbage. This is a common source of bugs in C codebases.

Vtables: Struct of Function Pointers

When an object needs multiple operations, you group the function pointers into a struct. This is C's version of a vtable -- the same pattern the kernel uses for file_operations, inode_operations, and dozens of other interfaces.

/* vtable.c -- polymorphism via struct of function pointers */
#include <stdio.h>
#include <math.h>

/* The "interface" */
struct shape_ops {
    double (*area)(const void *self);
    double (*perimeter)(const void *self);
    void   (*describe)(const void *self);
};

/* A "class": circle */
struct circle {
    const struct shape_ops *ops;   /* vtable pointer */
    double radius;
};

double circle_area(const void *self)
{
    const struct circle *c = self;
    return M_PI * c->radius * c->radius;
}

double circle_perimeter(const void *self)
{
    const struct circle *c = self;
    return 2.0 * M_PI * c->radius;
}

void circle_describe(const void *self)
{
    const struct circle *c = self;
    printf("Circle(r=%.1f)\n", c->radius);
}

static const struct shape_ops circle_ops = {
    .area      = circle_area,
    .perimeter = circle_perimeter,
    .describe  = circle_describe,
};

/* A "class": rectangle */
struct rectangle {
    const struct shape_ops *ops;
    double width, height;
};

double rect_area(const void *self)
{
    const struct rectangle *r = self;
    return r->width * r->height;
}

double rect_perimeter(const void *self)
{
    const struct rectangle *r = self;
    return 2.0 * (r->width + r->height);
}

void rect_describe(const void *self)
{
    const struct rectangle *r = self;
    printf("Rectangle(%.1f x %.1f)\n", r->width, r->height);
}

static const struct shape_ops rect_ops = {
    .area      = rect_area,
    .perimeter = rect_perimeter,
    .describe  = rect_describe,
};

/* Polymorphic function -- works with any "shape" */
void print_shape_info(const void *shape)
{
    /* The first field of every "shape" is the ops pointer */
    const struct shape_ops *ops = *(const struct shape_ops **)shape;
    ops->describe(shape);
    printf("  area      = %.2f\n", ops->area(shape));
    printf("  perimeter = %.2f\n", ops->perimeter(shape));
}

int main(void)
{
    struct circle c = { .ops = &circle_ops, .radius = 5.0 };
    struct rectangle r = { .ops = &rect_ops, .width = 4.0, .height = 6.0 };

    print_shape_info(&c);
    print_shape_info(&r);

    return 0;
}

Compile with: gcc -lm vtable.c -o vtable

  circle layout:             rectangle layout:

  +----------+----------+    +----------+-------+--------+
  | ops: *   | radius   |    | ops: *   | width | height |
  +----+-----+----------+    +----+-----+-------+--------+
       |                          |
       v                          v
  circle_ops {               rect_ops {
    .area      = circle_area     .area      = rect_area
    .perimeter = circle_peri     .perimeter = rect_perimeter
    .describe  = circle_desc     .describe  = rect_describe
  }                          }

Driver Prep: The kernel's struct file_operations is exactly this pattern. When you write a character device driver, you fill in a file_operations struct with pointers to your read, write, open, release, and ioctl functions. The VFS calls them through the vtable.

Rust: fn Pointers

Rust has bare function pointers, spelled as fn(args) -> ret. They are used less often than closures but exist for FFI and when no state capture is needed.

// fn_pointer.rs
fn add(a: i32, b: i32) -> i32 { a + b }
fn mul(a: i32, b: i32) -> i32 { a * b }

fn apply(f: fn(i32, i32) -> i32, x: i32, y: i32) -> i32 {
    f(x, y)
}

fn main() {
    println!("add(3,4) = {}", apply(add, 3, 4));
    println!("mul(3,4) = {}", apply(mul, 3, 4));

    // Store in a variable
    let op: fn(i32, i32) -> i32 = add;
    println!("op(5,6) = {}", op(5, 6));
}

Rust: Closures and the Fn Traits

Closures capture variables from their environment. They implement one or more of three traits:

TraitCaptures byCan callAnalogy
Fnshared referencemany timesconst void *ctx
FnMutmutable refmany timesvoid *ctx (mutating)
FnOncemove (ownership)exactly onceconsuming the context
// closures.rs
fn apply_fn(f: &dyn Fn(i32) -> i32, x: i32) -> i32 {
    f(x)
}

fn apply_fn_mut(f: &mut dyn FnMut(i32) -> i32, x: i32) -> i32 {
    f(x)
}

fn apply_fn_once(f: impl FnOnce(i32) -> String, x: i32) -> String {
    f(x)
}

fn main() {
    // Fn -- captures `offset` by shared reference
    let offset = 10;
    let add_offset = |x: i32| x + offset;
    println!("Fn: {}", apply_fn(&add_offset, 5));       // 15

    // FnMut -- captures `count` by mutable reference
    let mut count = 0;
    let mut counter = |x: i32| -> i32 {
        count += 1;
        x + count
    };
    println!("FnMut: {}", apply_fn_mut(&mut counter, 5));   // 6
    println!("FnMut: {}", apply_fn_mut(&mut counter, 5));   // 7

    // FnOnce -- moves `name` into the closure
    let name = String::from("event");
    let describe = move |x: i32| -> String {
        format!("{} #{}", name, x)
    };
    println!("FnOnce: {}", apply_fn_once(describe, 42));
    // `describe` is consumed -- cannot call it again
}

Rust Note: Every closure has a unique, anonymous type. You cannot name it. When you need to store closures in a struct, use Box<dyn Fn(...)> for trait objects or generics with impl Fn(...) bounds.

How Closures Capture

By default, closures borrow variables with the least permissions needed. Use move to force ownership transfer -- required when the closure outlives the current scope (e.g., sending to another thread).

  Without move:                  With move:

  stack frame:                   stack frame:
  +---+                          +---+
  | x | = 5                     | x | = 5  (copied into closure)
  +---+                          +---+
  | y | = "hello" (on heap)     | y | = MOVED
  +---+                          +---+
    |
    |  closure captures &y       closure owns y's data
    v                            directly in its anonymous struct

Rust Traits as Vtables

Rust's dyn Trait is the direct equivalent of C's struct-of-function-pointers.

// trait_vtable.rs
use std::f64::consts::PI;

trait Shape {
    fn area(&self) -> f64;
    fn perimeter(&self) -> f64;
    fn describe(&self);
}

struct Circle {
    radius: f64,
}

impl Shape for Circle {
    fn area(&self) -> f64 { PI * self.radius * self.radius }
    fn perimeter(&self) -> f64 { 2.0 * PI * self.radius }
    fn describe(&self) { println!("Circle(r={:.1})", self.radius); }
}

struct Rectangle {
    width: f64,
    height: f64,
}

impl Shape for Rectangle {
    fn area(&self) -> f64 { self.width * self.height }
    fn perimeter(&self) -> f64 { 2.0 * (self.width + self.height) }
    fn describe(&self) {
        println!("Rectangle({:.1} x {:.1})", self.width, self.height);
    }
}

fn print_shape_info(shape: &dyn Shape) {
    shape.describe();
    println!("  area      = {:.2}", shape.area());
    println!("  perimeter = {:.2}", shape.perimeter());
}

fn main() {
    let c = Circle { radius: 5.0 };
    let r = Rectangle { width: 4.0, height: 6.0 };

    print_shape_info(&c);
    print_shape_info(&r);

    // Store heterogeneous shapes in a Vec
    let shapes: Vec<Box<dyn Shape>> = vec![
        Box::new(Circle { radius: 3.0 }),
        Box::new(Rectangle { width: 2.0, height: 8.0 }),
    ];
    for s in &shapes {
        print_shape_info(s.as_ref());
    }
}

Rust Note: &dyn Shape is a fat pointer: it stores a pointer to the data AND a pointer to the vtable. This is exactly the same layout as the C pattern where every struct starts with const struct shape_ops *ops. The difference: Rust enforces it at compile time.

  &dyn Shape (fat pointer):

  +----------+----------+
  | data_ptr | vtbl_ptr |
  +----+-----+----+-----+
       |          |
       v          v
    Circle {    Shape vtable for Circle {
      radius      area:      -> Circle::area
    }             perimeter: -> Circle::perimeter
                  describe:  -> Circle::describe
                  drop:      -> Circle::drop
                }

Try It: Add a Triangle struct that implements Shape. Add it to the shapes vector and verify polymorphic dispatch works.

Knowledge Check

  1. In C, why must you use parentheses in int (*fn)(int, int) -- what happens if you write int *fn(int, int) instead?
  2. Why does the C callback pattern need both a function pointer and a void* context, while a Rust closure needs only one value?
  3. How does dyn Trait in Rust achieve the same result as a C vtable?

Common Pitfalls

  • Calling a NULL function pointer -- undefined behavior. Always check before calling.
  • Mismatched callback signatures -- C will not always warn if the function pointer type does not match the actual function.
  • Context lifetime -- if the void* context points to a local variable that goes out of scope, the callback reads dangling memory.
  • Confusing fn and Fn in Rust -- fn is a bare function pointer; Fn is a trait that closures implement. They are not interchangeable.
  • Forgetting move on closures passed to threads or stored in structs -- the closure captures references to locals that will be dropped.