Course outline

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.

About 5 min readContent reviewed
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.

c An overflow-free integer comparator
#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;
}
Expected output
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.

c A synchronous callback with borrowed context
#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;
}
Expected output
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.

Official references