Skip to main content Generics: Stop Using void Pointers for Generic Code | IoT Worker

Generics: Stop Using void Pointers for Generic Code

C has several common ways to write generic code:

  • void * plus size and callbacks
  • macros that generate code for different types
  • duplicated functions for different concrete types

All of them work, but each has a cost. void * loses type information, macros are harder to debug, and duplicated functions drift.

Rust generics solve the same problem: one piece of code can work for multiple types while keeping type checking.

C void Pointers Lose Type Information

Consider a generic C search function:

int find_item(const void *items, size_t len, size_t item_size,
              const void *target,
              int (*cmp)(const void *, const void *))
{
    const unsigned char *base = items;

    for (size_t i = 0; i < len; i++) {
        const void *item = base + i * item_size;
        if (cmp(item, target) == 0) {
            return (int)i;
        }
    }

    return -1;
}

This is flexible, but callers must guarantee:

  • items points to the right element type
  • item_size matches the real element size
  • target has the same type as the elements
  • cmp interprets both pointers as that same type

The type relationship is not in the signature. Many mistakes show up only in tests or at runtime.

Rust Type Parameters Keep the Type

Rust can write a version that only requires equality:

fn find_item<T: PartialEq>(items: &[T], target: &T) -> Option<usize> {
    for (index, item) in items.iter().enumerate() {
        if item == target {
            return Some(index);
        }
    }

    None
}

T is a type parameter. It means this function can work for many types, but in one call, the element type of items and the type of target must be the same.

T: PartialEq is a trait bound: T must support equality comparison.

Usage:

let numbers = [10, 20, 30];
let index = find_item(&numbers, &20);

let names = ["uart", "spi", "i2c"];
let index = find_item(&names, &"spi");

The same function works for integer slices and string slices, but the type is not erased into void *.

Generics Do Not Guess Types at Runtime

The key point is not runtime type guessing. The compiler knows the concrete type for each call.

So this call is invalid:

let numbers = [10, 20, 30];
let target = "20";

let index = find_item(&numbers, &target);

numbers contains integers, while target is a string. They cannot be the same T.

In C with void *, this mistake might turn into misinterpreting memory inside the comparison callback. Rust rejects it at the call site.

The first model is:

C void *: remove type information, recover it by convention
Rust generics: keep the type in the signature, let the compiler check relationships

Trait Bounds Say What You Can Do

Inside a generic function, you cannot do arbitrary operations on T. You can only use behavior required by trait bounds.

Without a bound:

fn debug_value<T>(value: T) {
    println!("{value:?}");
}

This does not compile because not every T supports {:?} formatting.

Write:

fn debug_value<T: std::fmt::Debug>(value: T) {
    println!("{value:?}");
}

T: Debug means this function works for all types that implement Debug.

This is somewhat like requiring the caller to provide a printable callback in C, but Rust puts the capability requirement into the type constraint.

Generic Structs Keep Types Too

Generics also apply to structs:

struct Buffer<T> {
    items: Vec<T>,
}

impl<T> Buffer<T> {
    fn len(&self) -> usize {
        self.items.len()
    }
}

Buffer<u8> and Buffer<String> are different concrete types. They share the same code structure, but type relationships remain clear.

If a method needs extra behavior, add a bound only there:

impl<T: Clone> Buffer<T> {
    fn duplicate_first(&self) -> Option<T> {
        self.items.first().cloned()
    }
}

This method exists only when T supports Clone.

Generics and dyn Trait Are Different

The previous article covered dyn Trait. Generics and dyn Trait both express “I do not care about the concrete type, only behavior,” but they work differently.

Generic:

fn write_all<W: Writer>(writer: &mut W, data: &[u8]) {}

Trait object:

fn write_all_dyn(writer: &mut dyn Writer, data: &[u8]) {}

The first distinction is:

generic T: Trait   concrete type kept at compile time, usually static dispatch
dyn Trait          runtime method table, dynamic dispatch

Most library APIs can start with generics. Use dyn Trait when you need to store different concrete types together, switch implementations at runtime, or hide the concrete type.

Generics Are Not Macros

C macros can simulate generic behavior:

#define MAX(a, b) ((a) > (b) ? (a) : (b))

The problem is that macros replace text before type checking. They do not understand types, scope, or evaluation count. Arguments may be evaluated multiple times.

Rust generics are not text replacement:

fn max<T: Ord>(a: T, b: T) -> T {
    if a >= b { a } else { b }
}

T: Ord states that the type can be ordered. Arguments follow normal Rust expression rules; this is not textual macro expansion.

What to Keep from This Article

The first mapping is:

void * + item_size + callback -> generic T + trait bound
macro-generated generic code  -> generic function / generic struct
duplicated int/string funcs   -> one generic function
caller guarantees same type   -> compiler checks T consistency
caller guarantees operation   -> T: Trait
runtime interface object      -> dyn Trait

Generics answer: which types can this code work with?

Trait bounds answer: what behavior must those types provide?

The next article can cover unsafe: raw pointers, FFI, global state, and the boundaries Rust cannot prove statically.