wandres.dev
EXPERTO · Punteros II: avanzado

El puntero genérico: void * y el arte de borrar el tipo

void * como dirección sin tipo: conversiones implícitas, funciones genéricas al estilo de qsort con tamaño de elemento y comparador, y el precio real de renunciar al tipo estático.

⏱ 16 min

C no tiene plantillas ni genéricos en el lenguaje, y sin embargo qsort ordena cualquier cosa. El truco cabe en un solo tipo: void *, la dirección desnuda, sin información de qué vive en ella. Escribir código genérico con él —y medir exactamente lo que cuesta— es el paso que separa a quien usa la librería estándar de quien podría haberla escrito.

🎯 Al terminar esta lección sabrás
  • Entender void * como dirección sin tipo y sus reglas de conversión.
  • Escribir funciones genéricas con tamaño de elemento y comparador.
  • Reconstruir desde cero la interfaz de qsort y bsearch.
  • Medir el coste de borrar el tipo y saber cuándo usar _Generic.

El tipo sin tipo

Como tipo de retorno, void significa “nada”. Pero void * no es un puntero a nada: es una dirección de memoria sin tipo asociado. Guarda el dónde y olvida deliberadamente el qué.

C le concede un privilegio que ningún otro puntero tiene: cualquier puntero a objeto se convierte a void * y de vuelta sin cast explícito, y la norma garantiza que el viaje de ida y vuelta preserva el valor exacto.

int   n  = 42;
int  *pi = &n;
void *pv = pi;         // conversion implicita: legal y sin cast
int  *otra = pv;       // y de vuelta: otra == pi, garantizado

Esta conversión bidireccional implícita es exclusiva de C. En C++ el camino de vuelta exige un cast, y de ahí viene la costumbre —importada y equivocada— de castear el resultado de malloc.

Hay tres cosas que void * no puede hacer, y las tres por el mismo motivo: el compilador no conoce ni el tamaño ni la representación de lo apuntado.

void *p = malloc(16);
*p;              // ERROR: no se puede desreferenciar un tipo incompleto
p + 1;           // ERROR en C estandar: no hay aritmetica sobre void
sizeof(void);    // ERROR: void no tiene tamano

GCC y Clang admiten como extensión la aritmética sobre void * tratándolo como char *. Compila con -std=c23 -pedantic y desaparecerá el permiso: para recorrer bytes, el tipo correcto y portable es unsigned char *.

flowchart LR
I[int asterisco] -->|implicita| V[void asterisco]
D[double asterisco] -->|implicita| V
S[struct Nodo asterisco] -->|implicita| V
V -->|implicita de vuelta| I
V --> X[el compilador ya no sabe el tipo ni el tamano]
style V fill:#89b4fa,color:#11111b
style X fill:#f38ba8,color:#11111b
💡
No castees el resultado de malloc

malloc devuelve void * y se convierte solo al tipo destino. Escribir int *p = (int *)malloc(n) no aporta nada y sí resta: si olvidas incluir stdlib.h, el cast silencia el diagnóstico y el compilador asume un retorno int, con corrupción garantizada en LP64. El idioma correcto es int *p = malloc(n * sizeof *p); — sin cast y con sizeof sobre la expresión, no sobre el tipo, para que el tamaño siga al puntero si algún día cambias su declaración.

La receta genérica: base, tamaño y comparador

Mira la firma de qsort y verás toda la técnica condensada:

void qsort(void *base, size_t nmemb, size_t size,
           int (*compar)(const void *, const void *));

El array llega como dirección sin tipo (base), la cardinalidad como nmemb, y el tamaño de cada elemento viaja como dato en tiempo de ejecución (size). Esa es la pieza clave: size es la información de tipo que el sistema estático perdió, reinyectada como un número. Lo que el compilador ya no sabe, lo sabe el programador y lo pasa a mano.

El comparador reconstruye el tipo en su interior, en el único punto donde vuelve a conocerse:

static int cmp_int(const void *a, const void *b) {
    int x = *(const int *)a;
    int y = *(const int *)b;
    return (x > y) - (x < y);   // nunca restes: x - y puede desbordar
}

int v[] = { 5, 2, 9, 1 };
qsort(v, 4, sizeof v[0], cmp_int);

Con esa receta escribes tus propios genéricos. Aquí una búsqueda lineal con la misma forma que bsearch:

void *buscar(const void *clave, const void *base, size_t n, size_t tam,
             int (*cmp)(const void *, const void *)) {
    const unsigned char *p = base;          // unico tipo legal para avanzar
    for (size_t i = 0; i < n; i++) {
        const void *elem = p + i * tam;     // aritmetica en bytes
        if (cmp(clave, elem) == 0) return (void *)elem;
    }
    return nullptr;
}

Y un intercambio que sirve para cualquier tipo, moviendo bytes en bruto:

void intercambiar(void *a, void *b, size_t tam) {
    unsigned char *x = a, *y = b;
    for (size_t i = 0; i < tam; i++) {
        unsigned char t = x[i]; x[i] = y[i]; y[i] = t;
    }
}
🎯

Puntero base

Un void * al primer elemento. Para recorrerlo, conviértelo a unsigned char *: es el único tipo con aritmética de byte garantizada.

📏

Tamaño de elemento

Un size_t que sustituye al tipo perdido. Pásalo siempre como sizeof v[0], nunca como una constante escrita a mano.

🔁

Puntero a función

El comparador o el callback es donde el tipo resucita. Recibe const void * y hace el cast de vuelta al tipo real.

Lo que cuesta perder el tipo

El genérico de C no es gratis, y un ingeniero serio conoce la factura. Son cinco cargos distintos.

Verificación cero. Nadie comprueba que el comparador que pasas corresponda al tipo del array. qsort(v, 4, sizeof(long), cmp_int) compila sin una queja y produce basura silenciosa. El contrato existe solo en tu cabeza y en la documentación.

Llamada indirecta por comparación. El comparador se invoca a través de un puntero, así que no hay inlining: cada comparación es un salto indirecto, un marco de pila y dos desreferencias. Ordenar un millón de int con qsort suele ser dos o tres veces más lento que una ordenación monomórfica que el compilador puede especializar.

Movimiento byte a byte. El intercambio genérico copia con un bucle de bytes; la versión tipada mueve una palabra en una instrucción.

Alineación. Convertir un void * a un tipo con requisito de alineación más estricto es comportamiento indefinido si la dirección no está alineada, aunque leerla parezca funcionar en x86.

Aliasing. Reinterpretar bytes a través de un puntero de otro tipo choca con la regla de aliasing estricto —la lección que viene a continuación.

Cuando el conjunto de tipos es cerrado y conocido en compilación, C23 ofrece la alternativa sin coste: _Generic despacha por tipo en tiempo de compilación, con verificación y sin indirección.

#define maximo(a, b) _Generic((a),  \
    int:    max_int,                \
    long:   max_long,               \
    double: max_double              \
)((a), (b))

Aquí no se borra nada: el compilador elige la función correcta, la puede inlinear y el error de tipo se detecta al compilar. void * para colecciones heterogéneas y librerías; _Generic y typeof para operaciones sobre un conjunto finito de tipos.

void * es borrado de tipo, no ausencia de tipo

Aquí está la idea profunda: void * no elimina el tipo, lo traslada del sistema estático al contrato humano. Los datos siguen teniendo un tipo real e inmutable en memoria; lo único que desaparece es la capacidad del compilador de verificar que lo respetas. Por eso toda API genérica de C viene acompañada de piezas que reponen esa información a mano: el size de qsort, el comparador que sabe hacer el cast correcto, el nmemb que delimita el recorrido. Es exactamente el mismo mecanismo que llamamos type erasure en lenguajes con genéricos, solo que en C la reposición es explícita y la responsabilidad es tuya. Cuando entiendas esto dejarás de ver void * como un agujero en el sistema de tipos y empezarás a verlo como una frontera de abstracción deliberada: por debajo, bytes; por encima, un tipo que tú te comprometes a mantener coherente. Todo el diseño de C —desde malloc hasta los subsistemas del kernel— vive sobre esa frontera, y la disciplina para no cruzarla mal es lo que distingue el código que sobrevive a un -O3 del que se rompe en cuanto el optimizador se pone serio.

⚔️ Construye tu propia librería genérica
  1. Escribe intercambiar(void *a, void *b, size_t tam) y úsala para invertir un array de double y otro de struct.
  2. Implementa una búsqueda binaria genérica con la firma exacta de bsearch y compárala con la de la libc.
  3. Ordena un array de struct Persona por dos criterios distintos pasando dos comparadores a qsort.
  4. Mide con clock el tiempo de qsort sobre un millón de int frente a una ordenación escrita solo para int. Explica la diferencia.
  5. Reescribe una de esas operaciones con _Generic y razona qué comprobaciones ganas.