advanced-c Lesson 8 22 min read

How Do I Build Generic Data Structures with Pointers and Functions?

Linked lists with void pointers and function pointers — building Java-like generics by hand

Reading: C Text: Ch. 13 §3–6 (pp. 750–789, Linked Lists, Queues, Stacks, Trees)

After this lesson, you will be able to:

  • Describe the four-callback pattern for generic containers (build, print, compare, clean)
  • Implement a generic linked list node using void *data
  • Write a free_list function that saves next before freeing each node
  • Write type-specific callback functions that cast void * to the correct type

The Big Idea

You have function pointers (parameterize behavior) and void pointers (parameterize type). Combine them, and you can build a container that stores any type and operates on it through callbacks — without the container knowing the type.

This is C’s version of Java generics: ArrayList<T> built from raw pointers and function addresses.


The Generic Container Pattern

Four Callbacks

A generic container needs four operations, each provided by the caller:

Callback Purpose Signature
build Create a data item void *(*build)(FILE *fp)
print Display a data item void (*print)(void *data)
compare Compare two items int (*compare)(void *a, void *b)
clean Free a data item void (*clean)(void *data)

The container calls these functions without knowing the type. Each callback casts the void * to the real type internally.

The Trick: Think of the generic container as a machine with four plugs. Different data types provide different plug implementations: ints plug in print_int, compare_int, etc. Strings plug in print_string, compare_string. Same machine, different plugs.

Check Your Understanding
A generic container uses four callbacks: build, print, compare, and clean. Why does the container need a clean callback instead of just calling free(data)?
A The data might have nested allocations (like a struct with a char * field) that require multiple frees
B free doesn't work on void * pointers
C The container doesn't have permission to call free
D clean is optional and never actually needed
Answer: A. A simple free(data) only frees the outer allocation. If the data is a Word struct with char *letters, you need to free the string first, then the struct. The container doesn't know the internal structure — only the type-specific clean callback knows how to properly free everything.

Linked List Node

A generic linked list stores void * data:

typedef struct node
{
    void *data;
    struct node *next;
} Node;

Building the List

Node *prepend(Node *head, void *data)
{
    Node *new_node = calloc(1, sizeof(Node));
    if (new_node == NULL) return head;

    new_node->data = data;
    new_node->next = head;
    return new_node;
}

Traversal with Callbacks

void print_list(Node *head, void (*print_fn)(void *))
{
    Node *current = head;
    while (current != NULL)
    {
        print_fn(current->data);
        current = current->next;
    }
}

Freeing the List

void free_list(Node *head, void (*clean_fn)(void *))
{
    Node *current = head;
    while (current != NULL)
    {
        Node *temp = current->next;
        if (clean_fn != NULL)
        {
            clean_fn(current->data);    // Free the data
        }
        free(current);                   // Free the node
        current = temp;
    }
}

Common Pitfall: When freeing a linked list, save current->next before freeing current. If you free the node first, you lose the next pointer — you can’t continue traversal. Use a temp variable.

Check Your Understanding
In the free_list function, what happens if you write free(current); current = current->next; instead of saving next first?
A It works fine — free doesn't erase the memory immediately
B Compile error — you can't access a freed pointer
C The program prints an error message and stops
D Undefined behavior — reading current->next after freeing current accesses deallocated memory
Answer: D. After free(current), the memory at current is deallocated. Accessing current->next reads freed memory — this is undefined behavior. It might appear to work sometimes (the old data is still there) but will eventually cause crashes or data corruption. Always save current->next in a temp variable before freeing.

Type-Specific Callbacks

For integers:

void print_int(void *data)
{
    int *p = (int *)data;
    printf("%d\n", *p);
}

int compare_ints(void *a, void *b)
{
    int *ia = (int *)a;
    int *ib = (int *)b;
    return *ia - *ib;
}

void clean_int(void *data)
{
    free(data);
}

For a Word struct with a dynamically allocated string:

typedef struct
{
    char *letters;
    int length;
} Word;

void print_word(void *data)
{
    Word *w = (Word *)data;
    printf("%s (%d chars)\n", w->letters, w->length);
}

void clean_word(void *data)
{
    Word *w = (Word *)data;
    free(w->letters);    // Free string first
    free(w);             // Then free struct
}

Key Insight: Each callback follows the same pattern: check for NULL, cast void * to the real type, do the work. The generic container never knows what type it holds — it just calls whatever callbacks the user provides.

From Java: This is equivalent to Java’s ArrayList<T> with custom Comparator, toString(), and close() methods. Java achieves this with interfaces and generics (compile-time type safety). C achieves it with void * and function pointers (no type safety — you must get it right yourself).

Why does this matter?

The generic container pattern is the culmination of everything in Series 4. Structs give you data, function pointers give you pluggable behavior, void pointers give you type erasure. Combined, they produce reusable containers — the same code handles ints, strings, or any struct. This is exactly what Lab 8 asks you to build.

Deep dive: Tracing a callback through the generic container

Let’s trace exactly what happens when the generic container calls print_fn(current->data) on a Word:

Step 1: Container calls print_fn(current->data)
        print_fn is a void (*)(void *)
        current->data is a void * pointing to a Word struct

Step 2: print_fn resolves to print_word (the callback we plugged in)

Step 3: Inside print_word:
        void print_word(void *data)    ← data is the void * from step 1
        {
            Word *w = (Word *)data;    ← cast to real type
            printf("%s (%d chars)\n", w->letters, w->length);
        }

Step 4: Memory layout being accessed:
        ┌──────────────┐
        │ Node         │
        │  data ───────────→ ┌──────────┐
        │  next ──→ ...│     │ Word     │
        └──────────────┘     │  letters ───→ "hello\0"
                             │  length = 5│
                             └──────────┘

The container sees: “I have a void *. I’ll call whatever function the user gave me.” The callback sees: “I received a void *. I know it’s really a Word *, so I cast and use it.”

Neither side knows the other’s implementation. The container doesn’t know about Word. The callback doesn’t know about linked lists. They communicate through the void * interface. This is the same separation that Java’s interfaces provide — the container depends on an interface, not a concrete type.

Linked Lists vs. Arrays

Feature Array Linked List
Random access O(1) O(n)
Insert at front O(n) O(1)
Memory layout Contiguous Scattered
Size Fixed or realloc Grows naturally
Cache performance Excellent Poor
Quick Check: Why does the generic container use void *data instead of a specific type?

So it can store any type. The container doesn’t know or care what the data is — it stores a void * and delegates all type-specific operations (print, compare, clean) to callback functions that the user provides.

Quick Check: Why must you save current->next before freeing current in a linked list?

free(current) deallocates the node’s memory. After that, current->next reads freed memory (undefined behavior). Saving next in a temp variable before freeing ensures you can continue traversal.

Quick Check: Why does clean_word free w->letters before w?

w->letters points to separately allocated memory (the string). If you free w first, you lose access to w->letters — the string leaks. Always free inner allocations before the outer struct.


Big Picture: Generic containers are how C libraries achieve reusability without templates or generics. The Linux kernel’s linked list implementation, SQLite’s B-tree, and the C standard library’s qsort all use this same pattern: void pointers for data, function pointers for operations. You’ve now built the same machinery that powers real-world systems software.


What’s Next

You’ve built the generic container pattern from scratch. In the next lesson, you’ll see how this applies to Lab 8 — implementing type-specific callbacks for a generic array that stores Word structs.