Skip to content
academia.sh

Lesson 06 / 15

Function Pointers

Pointers that point at a function itself and can be passed as a parameter to another function; the ownership of a callback's context belongs to the caller.

Published Updated

In this lesson

  • Defines a function pointer and calls it to indirectly run another function
  • Passes a function as a parameter to another function to set up a callback
  • Measures that ownership of a callback's carried context belongs to the caller
Contents

The five lessons up to this point measured cases where a pointer points at data — an integer, an array, an opaque record. This last lesson measures a pointer that points at something different: the function itself. A function pointer holds the address of a compiled function and lets that function be called indirectly — this is the foundation of choosing a function at runtime, or of passing one to another function as a parameter.

A function pointer’s declaration looks confusing at first glance: the parentheses in int (*op)(int, int) are not arbitrary, they are required — had we written int *op(int, int), this would be the declaration of a function named op that returns int *, not a pointer named op. The parentheses group *op as a name first and tell the compiler “op is a pointer, and what it points at is a function matching this signature.” This complexity is usually hidden behind a typedef — indeed the void (*func)(int, void *) parameter type in callback_context.c follows the same pattern as the BinaryOp definition in operation_table.c; one just writes the type directly, the other binds it to a name.

Defining and Calling a Function Pointer

// function_pointer.c — a function pointer holds the address of a compiled function
#include <stdio.h>

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

static int multiply(int a, int b) {
    return a * b;
}

int main(void) {
    int (*op)(int, int) = add;
    printf("%s %d\n", "indirect call through add", op(3, 4));

    op = multiply;
    printf("%s %d\n", "indirect call through multiply", op(3, 4));

    printf("%s %d\n", "is op equal to add", (int)(op == add));
    printf("%s %d\n", "is op equal to multiply", (int)(op == multiply));
    return 0;
}
indirect call through add 7
indirect call through multiply 12
is op equal to add 0
is op equal to multiply 1

PO25. The declaration int (*op)(int, int) says that op will hold the address of a function that takes two ints and returns an int. After the assignment op = add, the call op(3, 4) gives 7 — it indirectly runs add. op is then reassigned to multiply, and the same call syntax (op(3, 4)) now gives 12. op == add is 0, op == multiply is 1 — this shows function pointers can be compared with equality just like data pointers; op matches whichever function it currently points at.

Callback: Passing a Function to Another Function

// callback.c — passing a function as a parameter chooses behavior at runtime
#include <stdio.h>

static int square(int x) {
    return x * x;
}

static int negate(int x) {
    return -x;
}

static void apply_and_print(int value, int (*transform)(int), const char *label) {
    printf("%s %d\n", label, transform(value));
}

int main(void) {
    apply_and_print(6, square, "square transform");
    apply_and_print(6, negate, "negate transform");
    return 0;
}
square transform 36
negate transform -6

PO26. The apply_and_print function, through its int (*transform)(int) parameter, lets the caller decide which function runs — the names square and negate never appear inside the function’s own body. The first call passes square and the result is 36 (6*6), the second passes negate and the result comes out -6. This pattern achieves different behaviors without changing the same apply_and_print body — the behavior is chosen as a parameter, at call time rather than compile time. This pattern is called a callback: transform calls back, from inside apply_and_print, into the function the caller supplied.

What differs from the add/multiply example is that the function itself is no longer held in a variable, it flows directly as a parameter. The op variable was persistent inside main and could be reassigned; transform, on the other hand, is a temporary parameter that only exists while apply_and_print is running. Both uses share the same function-pointer mechanism, but one demonstrates holding state (which function op currently points at), the other one-shot dispatch (which function to use for this call).

Ownership of Context: The Data Carried to a Callback

A callback often carries not just a value, but a context that accompanies that value. Who should own this context? The following example measures the case where the caller supplies a context and manages that context’s lifetime itself.

// callback_context.c — the owner of the context carried to a callback is the caller that supplied it
#include <stdio.h>

typedef struct {
    int factor;
    int sum;
} Context;

static void accumulate(int value, void *context) {
    Context *ctx = context;
    ctx->sum += value * ctx->factor;
}

static void run_over_array(const int *array, int length,
                            void (*func)(int, void *), void *context) {
    for (int i = 0; i < length; i++) {
        func(array[i], context);
    }
}

int main(void) {
    int values[4] = {1, 2, 3, 4};
    Context ctx = {.factor = 3, .sum = 0};

    run_over_array(values, 4, accumulate, &ctx);
    printf("%s %d\n", "sum with factor", ctx.sum);

    ctx.factor = 1;
    ctx.sum = 0;
    run_over_array(values, 4, accumulate, &ctx);
    printf("%s %d\n", "sum without factor", ctx.sum);
    return 0;
}
sum with factor 30
sum without factor 10

PO27. The Context struct is defined as a local variable inside main — it is never allocated with count_alloc or malloc, its class is language. But it is passed as &ctx to run_over_array, and that function forwards it to accumulate through the void *context parameter. accumulate does not take ownership of context itself — it only writes into memory that main owns. On the first run the factor is 3 and the sum is 30 (3*(1+2+3+4)); on the second run the factor is reset to 1 and the sum comes out 10 — ctx can be reused because its lifetime still belongs to main. The same rule would hold even if the context passed to a callback had been dynamically allocated: the party supplying the context (the caller) would remain obligated to free it, a callback function like accumulate never takes ownership of it itself.

This pattern shares the same ownership shape as this course’s first lesson’s opaque record observation, but from the opposite direction: there, the callee (owner.c) held its own data and only lent it to the caller; here, the caller holds its own data, and the callee (accumulate, run_over_array) only accesses that data through a pointer. In both cases the data stays with a single side; what changes is only which side holds the memory. The void *context parameter here follows the same rule as the void * in the void and opaque pointers lesson (05): the accumulate function first converts context to the Context * type, and only then accesses its fields — the type information is carried by the caller, the callback function only recovers it.

An Array of Function Pointers: A Dispatch Table

Just as a function pointer can be reassigned on its own, it can also be held inside an array — this makes it possible to select which function runs at runtime through an index.

// operation_table.c — an array of function pointers builds a runtime dispatch table
#include <stdio.h>

typedef int (*BinaryOp)(int, int);

static int add(int a, int b) { return a + b; }
static int subtract(int a, int b) { return a - b; }
static int multiply(int a, int b) { return a * b; }

int main(void) {
    BinaryOp table[3] = { add, subtract, multiply };
    const char *names[3] = { "add", "subtract", "multiply" };
    int results[3];

    for (int i = 0; i < 3; i++) {
        results[i] = table[i](10, 4);
        printf("%s %d\n", names[i], results[i]);
    }

    int total = 0;
    for (int i = 0; i < 3; i++) {
        total += results[i];
    }
    printf("%s %d\n", "sum of results", total);
    return 0;
}
add 14
subtract 6
multiply 40
sum of results 60

This table is a dispatch form that replaces an if/else if chain or a switch statement: adding a new operation is limited to adding an element to the table and names arrays, the calling code itself does not change.

PO28. Each element of the table array holds the address of a different function; the call table[i](10, 4) selects which function runs at runtime, based on the index i — there is no need to write three separate lines like add(10, 4), subtract(10, 4), multiply(10, 4) in the source code. The results array, like table itself, is defined as a local variable inside main; both have class caller — main allocates them, fills and reads them, and once the function calls are done they go out of scope and are returned automatically. This is the same ownership shape as Context ctx in callback_context.c: both the array and the struct are managed by the automatic lifetime of the language class, but the array’s contents (which functions sit in which order) are entirely main’s decision — add, subtract, and multiply have no say in that decision, they only do their own job when called.

The Bounding Measurement

A function pointer’s signature (like int (*)(int, int)) only matches parameter and return types — it says nothing about what the function it points at does or what its side effects are. add and multiply above share the same signature but do completely different things; a function taking a callback parameter gets no compile-time guarantee at all about the behavior of the function passed to it. This is another form of the same limit found in earlier lessons — that pointer types do not carry ownership information: a type guarantees shape, not meaning.

This limit turns into a separate risk when combined with the context carried to a callback. In callback_context.c, at the moment the run_over_array call passes to accumulate, ctx is still within main‘s scope, so the memory context points at is valid. But if a callback is registered to run later — say, in an event queue, after the calling function has already returned — the address passed as context may by then have outlived the automatic lifetime of the language class — the same dangling pointer condition from lesson 04, here in callback form. This block is deliberately never run: a function would write the address of a local Context variable into a callback record and return, then when the registered callback fires, context would be pointing at a call frame whose scope has already closed — dereferencing it is undefined behavior. In the run_over_array example this risk does not exist, because the callback runs while still inside main‘s own call frame, while ctx is still alive; the risk only appears when the context’s lifetime and the callback’s execution moment diverge. This is why the obligation of the party supplying the context (the caller) is not limited to freeing the memory — it must also guarantee the context stays alive until whenever the callback might fire.

This pattern’s most common use is in the C standard library: qsort requires an int (*)(const void *, const void *) comparison function to sort an array, bsearch uses the same signature for searching. qsort itself does not know which type it is sorting — the same indirection pattern as the table[i] call in operation_table.c, but this time the function being indirected is a comparison decision supplied by the caller. The comparison function passed to qsort also has to first convert its own parameters to the correct type — just like accumulate converting its void *context to Context * first; the const void * in the signature does not carry type information here either, it only stores it. The difference between the dispatch-table pattern and the qsort pattern is only scale: one chooses among three fixed functions, the other lets the caller supply a different comparison logic each time — both rest on the same foundation: carrying a function like data and calling it indirectly when needed.

The Ownership Axis Across This Topic: Four Classes, One Outcome

The Pointers topic’s six lessons asked the same question in six different pointer forms: who owns this memory? In lessons 01 and 02 the answer was language — local variables and arrays inside main were returned automatically once scope closed, no side called free. In lesson 03 ownership stayed with language again, but the problem was less about ownership and more about information loss: an array lost its length when it decayed to a pointer, which required largest, matrix_decay.c, and returns_length_too.c to fill in the same missing information through three different channels. In lesson 04 ownership split into three sources (language, caller, callee), and the shared result was a dangling pointer — a value that keeps pointing at a block that has already been freed. In lesson 05 ownership moved for the first time to a hidden class: the opaque type was a contract where the callee held its own data and only lent it to the caller. In this last lesson ownership returned to the caller again — but this time what is at stake is not data, but a function choice and the context accompanying it; the Context ctx in callback_context.c and the table/results arrays in operation_table.c were memory that main allocated and main reclaimed.

What the four classes (language, caller, callee, unowned) have in common is that none of them ever showed up in the pointer’s type — neither int *, nor Record *, nor int (*)(int, int) said which side was obligated to free it. This information always had to be carried from outside, either through a naming convention (_create/_destroy pairs), a comment, or a written contract. The next topic, Dynamic Memory, applies these same four classes to the malloc/free pair itself; the classification here will not change, only which functions represent which class will become clearer.

Summary

  • A function pointer (int (*)(int, int)) holds the address of a compiled function and lets that function be called indirectly; it can be reassigned to different functions.
  • Passing a function as a parameter to another function sets up a callback; the called function’s behavior changes at runtime depending on which function is passed.
  • The context carried to a callback is usually the address of a local variable of the language class; the callback function does not own this context, it only uses it.
  • The party supplying the context is the caller, and even if the context had been dynamically allocated, the obligation to free it would still remain with the caller.
  • A function pointer’s signature only matches parameter and return types; it does not guarantee the behavior of the function it points at.
  • An array of function pointers (table in operation_table.c) builds a dispatch table selected by index at runtime; qsort/bsearch’s comparison parameter uses the same pattern in the standard library.

Next Step

The Pointers topic closes here: the four classes (language, caller, callee, unowned) were measured across six lessons, through both data pointers and function pointers. In every lesson, what determined the class was never the pointer’s type but which side took on allocation and freeing — this distinction never showed up directly in the source code, it was only ever conveyed through naming and contract. The next topic, Dynamic Memory, carries these same four classes into the behavior of malloc, calloc, realloc, and free themselves — failed allocation, the risks carried by resizing, and the discipline of freeing.

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ ↵ Esc navigate · open · close