C / Advanced: Language Boundaries and Systems / Lesson 14
Callbacks and Generic Interfaces
Pass behavior through correctly typed function pointers, and keep generic data access tied to explicit size and context contracts.
On this page
Lesson goals
- Read and call function-pointer types.
- Write a valid qsort ordering without arithmetic overflow.
- Specify callback context lifetime, mutation, and retention.
Behavior has a type too
A function pointer identifies callable behavior with a particular return and parameter type. int (*operation)(int) is a pointer to a function accepting int and returning int; the parentheses distinguish it from a function returning a pointer. Calling through an incompatible function-pointer type is undefined behavior, even when a cast suppresses a diagnostic.
Use a typedef when it improves the readability of an interface, and publish one authoritative callback declaration. ISO C does not guarantee arbitrary interchangeability between function pointers and void * object pointers. A platform API may define such a convention separately, but it must be taught as that platformโs contract.
A comparator defines a coherent order
qsort receives a base pointer, element count, element size, and a comparison callback. The callback reads two elements and returns negative, zero, or positive according to the order. Returning left - right for int values can overflow, so use relational comparisons that produce only small results. The comparison must remain consistent throughout the sort.
The example casts each generic object pointer back to the actual element type and reads it without modification. qsort is not required to preserve the original order of elements that compare equal, and its name does not promise a particular algorithm or complexity. If stability is required, choose or implement a sort with an explicit stability guarantee.
#include <stdio.h>
#include <stdlib.h>
static int compare_ints(const void *left, const void *right)
{
int a = *(const int *)left;
int b = *(const int *)right;
return (a > b) - (a < b);
}
int main(void)
{
int values[] = {4, 1, 3, 2};
size_t count = sizeof values / sizeof values[0];
qsort(values, count, sizeof values[0], compare_ints);
for (size_t i = 0; i < count; ++i) printf("%d%s", values[i], i + 1 == count ? "\n" : " ");
return 0;
}
1 2 3 4
Relational results are zero or one, so their difference is always -1, 0, or 1.
Pass state as an explicit context
A callback often needs state beyond the current element. A void * context parameter can carry a pointer to a caller-owned record, provided the callback restores the correct type and the record remains alive for all calls. This is an object-pointer conversion pattern, not permission to store any kind of function pointer in the context.
The traversal below is synchronous and does not retain either pointer. Its callback adds each fixed small value into a live int accumulator. A real interface should state whether callbacks may stop iteration, fail, modify the container, call the interface recursively, or retain elements after return. Those choices affect safety more than the function-pointer syntax.
#include <stddef.h>
#include <stdio.h>
typedef void (*visitor)(int value, void *context);
static void visit(const int *data, size_t count, visitor action, void *context)
{
for (size_t i = 0; i < count; ++i) action(data[i], context);
}
static void add_value(int value, void *context)
{
int *total = context;
*total += value;
}
int main(void)
{
const int data[] = {2, 3, 4};
int total = 0;
visit(data, 3, add_value, &total);
printf("total=%d\n", total);
return 0;
}
total=9
The context is a live int and is used only during visit; the fixed sum is safely nine.
Generic does not mean untyped reality
void * lets an interface transport an object pointer without knowing its concrete type. The actual storage still has bounds, alignment, lifetime, and an access contract. Generic byte stepping uses unsigned char * with a validated element size, not arithmetic on void *, which is not ISO C17 pointer arithmetic.
Before computing count * element_size, prove the multiplication fits size_t and the provided region covers it. Define how element copy and destruction work when values contain owned pointers. A raw byte copy can be correct for a trivially copied record yet wrong for an owning record whose resources must be duplicated or transferred.
Define control returning from a callback
An interface that calls user-provided behavior gives up control temporarily. If a callback mutates the traversed structure, stored indices or pointers may become invalid before the interface resumes. Either prohibit that mutation or design a mutation-safe protocol and document which operations remain permitted.
For fallible callbacks, return a status and define whether traversal stops immediately and whether previous effects remain committed. Retaining context for asynchronous use requires an ownership or lifetime agreement extending beyond the initial call. Do not pass the address of a short-lived local object to a service that may invoke it later.
Common errors and fixes
A comparator fails for extreme integers.
- Why it happens
- It subtracts operands and can overflow.
- How to fix it
- Return (a > b) - (a < b) for integer ordering.
- Prevent it next time
- Test INT_MIN, zero, INT_MAX, and equal operands.
Casting a callback silences a warning but calls fail.
- Why it happens
- The actual function type is incompatible.
- How to fix it
- Write an adapter with exactly the declared callback signature.
- Prevent it next time
- Never use a cast as a substitute for matching callable types.
A delayed callback accesses an expired context.
- Why it happens
- A local object was borrowed beyond its lifetime.
- How to fix it
- Extend ownership appropriately or cancel and complete callbacks before scope exit.
- Prevent it next time
- Document whether contexts are retained and when borrowing ends.
Check your understanding
1. Design the return convention for a visitor that can stop early after finding a value.
Show worked solution
Use an enum such as CONTINUE, STOP, and ERROR. State that STOP is a successful early termination, ERROR is a failure, and previously performed callback effects remain unless an explicit transaction is implemented. The traversal returns which condition ended it. Define whether the found element is borrowed only until return and prohibit structural mutation during the visit unless supported.
2. Explain why a generic vector of owning string records cannot define duplication as memcpy alone.
Show worked solution
memcpy duplicates the pointer values, leaving both records referring to the same allocations. If both copies destroy their strings, ownership is released twice. The vector needs an element-copy callback that allocates and copies each string, a transfer operation that empties the source, or a documented borrowed/shared model. Its failure path must destroy only successfully created destination elements.
Key takeaways
- Function-pointer compatibility is a safety requirement.
- Context pointers require a concrete type and a lifetime contract.
- Generic containers need size, copy, destruction, and mutation rules.