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 thedivfunction 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* ctxpointer 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_operationsis exactly this pattern. When you write a character device driver, you fill in afile_operationsstruct with pointers to yourread,write,open,release, andioctlfunctions. 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:
| Trait | Captures by | Can call | Analogy |
|---|---|---|---|
Fn | shared reference | many times | const void *ctx |
FnMut | mutable ref | many times | void *ctx (mutating) |
FnOnce | move (ownership) | exactly once | consuming 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 withimpl 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 Shapeis 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 withconst 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
Trianglestruct that implementsShape. Add it to theshapesvector and verify polymorphic dispatch works.
Knowledge Check
- In C, why must you use parentheses in
int (*fn)(int, int)-- what happens if you writeint *fn(int, int)instead? - Why does the C callback pattern need both a function pointer and a
void*context, while a Rust closure needs only one value? - How does
dyn Traitin 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
fnandFnin Rust --fnis a bare function pointer;Fnis a trait that closures implement. They are not interchangeable. - Forgetting
moveon closures passed to threads or stored in structs -- the closure captures references to locals that will be dropped.