Aliasing estricto: la regla que rompe optimizaciones
La regla de aliasing estricto que casi nadie conoce y que el optimizador aplica sin piedad: qué promete, cómo restrict la refuerza, y cuál es la forma correcta de reinterpretar bits con memcpy y uniones.
Hay una regla de la norma de C que casi nadie ha leído y que sin embargo decide si tu código funciona en -O2. Se llama aliasing estricto, y dice qué punteros puede asumir el compilador que nunca apuntan al mismo objeto. Violarla no produce un error: produce un programa que pasa las pruebas en modo depuración y falla en producción.
- Enunciar la regla de aliasing estricto y el papel del tipo efectivo.
- Ver una miscompilación real y entender por qué el optimizador tiene razón.
- Usar
restrictcomo promesa explícita de no solapamiento. - Reinterpretar bits correctamente con
memcpyy uniones.
Qué asume el compilador cuando no puede saberlo
Dos punteros son alias cuando apuntan al mismo objeto. Escribir a través de uno cambia lo que se lee por el otro, y el compilador está obligado a respetarlo: si dos punteros pudieran solaparse, no puede cachear una lectura en un registro a través de una escritura.
Eso destruiría cualquier optimización, así que la norma le regala una hipótesis. La regla, en el apartado del acceso a objetos, dice en esencia: un objeto solo puede accederse a través de un lvalue de un tipo compatible con su tipo efectivo, con excepciones tasadas —tipos que difieren solo en const o en signo, y siempre los tipos carácter. Traducido a la práctica: el compilador asume que un int * y un float * jamás designan la misma memoria.
// el compilador asume que ip y fp nunca se solapan
int escalar(int *ip, float *fp) {
*ip = 1;
*fp = 2.0f; // no puede haber tocado el int que acabamos de escribir
return *ip; // por tanto, puede devolver 1 sin releer memoria
}
Si de verdad le pasas la misma dirección con dos tipos, la función devolverá 1 aunque en memoria haya otra cosa. No es un fallo del compilador: es tu promesa incumplida.
flowchart TD A[El compilador ve int asterisco p y float asterisco q] --> B[Regla de aliasing estricto] B --> C[Asume que p y q nunca designan el mismo objeto] C --> D[Reordena escrituras y cachea lecturas en registros] D --> E[Si la promesa era falsa el comportamiento es indefinido] style B fill:#89b4fa,color:#11111b style E fill:#f38ba8,color:#11111b
El tipo efectivo es la pieza que suele faltar en las explicaciones. Un objeto declarado tiene el tipo de su declaración, para siempre. La memoria devuelta por malloc no tiene tipo declarado: adquiere como tipo efectivo el de la primera escritura, y a partir de ahí debe accederse de forma coherente.
void *bloque = malloc(1024);
int *ai = bloque;
ai[0] = 7; // el tipo efectivo de esos bytes pasa a ser int
float *af = bloque;
float x = af[0]; // INDEFINIDO: se lee como float lo escrito como int
af[0] = 1.5f; // legal: reescribir cambia el tipo efectivo
float y = af[0]; // y ahora leerlo como float es correcto
Reutilizar un bloque para otro tipo es legítimo mientras escribas antes de leer. Lo que la norma prohíbe es interpretar con un tipo los bits que dejó otro. Esta asimetría explica por qué los allocators de propósito general no la violan al reciclar memoria, y por qué tu código sí la viola en cuanto casteas para mirar lo que ya había.
La miscompilación clásica
Este es el ejemplo que ha roto más código real que ningún otro: inspeccionar los bits de un float casteando su dirección.
// INCORRECTO: viola aliasing estricto, aunque "funcione" en -O0
uint32_t bits_mal(float f) {
return *(uint32_t *)&f; // se lee un float como si fuera un uint32_t
}
En -O0 produce lo esperado. Compílalo con -O2 dentro de un bucle que además escriba el float, y el optimizador —convencido de que un uint32_t * no puede alias de un float— reordenará las operaciones y devolverá el valor anterior, o el de otra iteración, o el que hubiera en el registro. Activa -Wstrict-aliasing=2 y verás la advertencia; actívala siempre.
La forma correcta y sin coste es copiar los bytes:
// CORRECTO: memcpy no viola nada y el compilador lo reduce a un movimiento de registro
uint32_t bits(float f) {
uint32_t u;
memcpy(&u, &f, sizeof u);
return u;
}
No temas la llamada: con tamaño constante, GCC y Clang la convierten en una sola instrucción. Es literalmente el mismo código máquina que la versión rota, pero definido.
La segunda vía legal es la unión, y aquí C y C++ divergen: en C, leer un miembro de una unión distinto del último escrito está explícitamente permitido y reinterpreta la representación (con la salvedad de los bits de relleno y las representaciones trampa). En C++ es comportamiento indefinido.
// CORRECTO en C: type punning por union, legal desde C99
union Puente { float f; uint32_t u; };
uint32_t bits_union(float f) {
union Puente p = { .f = f };
return p.u; // legal en C, UB en C++
}
La tercera excepción es la de los tipos carácter: puedes examinar cualquier objeto byte a byte a través de unsigned char *. La regla es asimétrica y esto se olvida mucho: mirar cualquier cosa como bytes es legal; mirar bytes como cualquier cosa, no.
memcpy con tamaño constante
La opción portable y de coste cero. Funciona igual en C y en C++ y no depende de extensiones.
Unión
Legal en C desde C99 para reinterpretar. Cuidado con el relleno y con la portabilidad a C++.
unsigned char *
Siempre permitido para inspeccionar la representación de cualquier objeto byte a byte.
restrict: prometer lo que el tipo no puede
El aliasing estricto solo ayuda cuando los tipos difieren. Con dos int * el compilador debe asumir lo peor. restrict es la promesa explícita que rompe ese empate: declara que, mientras el puntero viva, el objeto al que accede no se alcanzará por ninguna otra vía.
void sumar(int *restrict dst, const int *restrict a,
const int *restrict b, size_t n) {
for (size_t i = 0; i < n; i++) dst[i] = a[i] + b[i];
}
Sin restrict, el compilador debe releer a[i] y b[i] en cada vuelta por si la escritura en dst[i] los modificó, y no puede vectorizar. Con restrict, carga en bloques, usa SIMD y desenrolla. En bucles numéricos la diferencia se mide en factores, no en porcentajes.
Es una promesa tuya: si los rangos se solapan, el resultado es indefinido y no habrá diagnóstico. Ese contrato es exactamente la diferencia entre memcpy —cuyos parámetros son restrict y por eso puede ir a máxima velocidad— y memmove, que tolera el solapamiento y paga por ello. En C23 restrict sigue siendo el único calificador cuyo incumplimiento no puede detectar ningún sanitizer de forma general: úsalo solo cuando puedas demostrar la disyunción.
Dos precisiones que se escapan a mucha gente. La primera: restrict califica al puntero, no al objeto, y su alcance es el bloque donde se declara; poner restrict en un miembro de una struct o en una variable global rara vez significa lo que crees. La segunda: el calificador solo afecta a la definición de la función, no a la llamada, así que el compilador no comprueba en el punto de llamada si los argumentos se solapan. La promesa se hace en un sitio y se rompe en otro, muy lejos, sin que nadie avise.
int datos[4] = { 1, 2, 3, 4 };
sumar(datos, datos, datos, 4); // compila sin una queja; resultado indefinido
Cuándo desactivar la regla
Existe -fno-strict-aliasing, que le ordena al compilador olvidar la hipótesis y asumir que cualquier puntero puede alias de cualquier otro. Es una opción legítima, no una rendición: el kernel de Linux se compila con ella desde hace décadas, porque su código manipula estructuras a través de tipos distintos de forma sistemática y auditar cada punto sería inviable.
Úsala como red de seguridad para código heredado que no puedes reescribir, nunca como permiso para escribir código nuevo violando la regla. Cuesta rendimiento en todo el módulo, no solo en las funciones problemáticas, y no es portable a compiladores que no la ofrezcan. Para código nuevo la respuesta es memcpy.
Esta es la lección que reordena por completo cómo entiendes C. Escribes lo que parece una secuencia de instrucciones, pero el compilador no traduce instrucciones: razona sobre un contrato. La norma le concede un conjunto de hipótesis —los tipos distintos no se solapan, la aritmética con signo no desborda, un puntero desreferenciado no es nulo, un bucle sin efectos observables termina— y sobre ellas construye un modelo de tu programa. Si alguna hipótesis es falsa, el modelo es falso, y el código generado no tiene por qué parecerse en nada a lo que escribiste. Por eso el comportamiento indefinido no significa “resultado impredecible en esa línea”, sino que todo el razonamiento del optimizador sobre esa función queda envenenado: verás desaparecer comprobaciones que sí escribiste, bucles que se vuelven infinitos, ramas muertas que se ejecutan. Y por eso los bugs de aliasing son los más crueles del oficio: aparecen solo con optimización, solo en un compilador, solo en una versión, y desaparecen en cuanto añades un printf. La disciplina que salva es una sola: no busques qué te deja pasar el compilador de hoy, escribe programas cuyas promesas sean ciertas. -Wall -Wextra -Wstrict-aliasing=2 -fsanitize=undefined no son adornos; son el precio de entrada para poder confiar en -O2.
- Escribe
bits_malcon el cast defloatauint32_ty compárala con-O0y con-O2 -Wstrict-aliasing=2. - Reescríbela con
memcpyy comprueba en el ensamblador que no hay ninguna llamada. - Implementa la misma conversión con una unión y explica por qué es legal en C y no en C++.
- Escribe la suma de vectores con y sin
restricty compara el ensamblador: busca las instrucciones SIMD. - Explica qué es el tipo efectivo de un bloque devuelto por
mallocy cuándo puedes reutilizarlo con otro tipo.