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.
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.
- 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
qsortybsearch. - 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
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.
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.
- Escribe
intercambiar(void *a, void *b, size_t tam)y úsala para invertir un array dedoubley otro destruct. - Implementa una búsqueda binaria genérica con la firma exacta de
bsearchy compárala con la de la libc. - Ordena un array de
struct Personapor dos criterios distintos pasando dos comparadores aqsort. - Mide con
clockel tiempo deqsortsobre un millón deintfrente a una ordenación escrita solo paraint. Explica la diferencia. - Reescribe una de esas operaciones con
_Genericy razona qué comprobaciones ganas.