Unions y type punning: reinterpretar bytes sin romper el lenguaje
Una union superpone varios miembros sobre la misma memoria. Esta lección delimita con precisión qué garantiza C23 al leer un miembro distinto del que escribiste, por qué el casteo de punteros es comportamiento indefinido aunque parezca equivalente, cómo opera la regla de la secuencia inicial común, y por qué la union etiquetada es la única forma disciplinada de modelar un tipo suma en C.
La union es la construcción más malinterpretada de C. No es un struct barato ni un truco para ahorrar memoria: es una declaración formal de que un mismo almacenamiento albergará, en momentos distintos, objetos de tipos distintos. Esa superposición deliberada es precisamente lo que permite al lenguaje conceder una excepción a sus reglas de aliasing, y de ahí nace la única forma de reinterpretación de bytes que C bendice explícitamente. Entender dónde termina esa bendición separa el código que funciona por casualidad del que funciona por construcción.
- Deducir el tamaño, la alineación y los desplazamientos de una
union. - Delimitar qué garantiza C al leer un miembro distinto del último escrito.
- Explicar por qué el casteo de punteros viola el aliasing estricto y qué usar en su lugar.
- Construir uniones etiquetadas correctas y comprobables con
static_assert.
Superposición: la geometría de la union
Todos los miembros de una union comienzan en el desplazamiento cero. Su tamaño es al menos el del miembro mayor, redondeado hasta un múltiplo de su alineación, que es la más restrictiva de todas las de sus miembros. La consecuencia inmediata es que escribir un miembro deja el resto en un estado que el estándar describe como no especificado.
#include <stddef.h>
#include <stdint.h>
union Palabra {
uint32_t entera;
float real;
uint8_t bytes[4];
};
// sizeof 4, alignof 4, y los tres offsetof valen 0
static_assert(sizeof(union Palabra) == 4, "layout inesperado");
static_assert(offsetof(union Palabra, bytes) == 0, "no superponen");
Un objeto de tipo union tiene en cada instante un miembro activo: el último que se escribió. C no almacena esa información en ningún sitio, no la comprueba y no te la devuelve. Es un dato que existe solo en tu cabeza o en un campo que tú añadas.
Type punning: la excepción que C sí concede
Aquí es donde C y C++ se separan de forma tajante. En C++, leer un miembro que no es el activo es comportamiento indefinido salvo excepciones muy acotadas. En C, en cambio, el estándar lo permite: cuando accedes a un miembro distinto del último escrito, los bytes del objeto se reinterpretan como una representación del nuevo tipo. La nota al pie de la sección sobre el operador de acceso a miembro lo dice sin ambigüedad, y C23 mantiene esa concesión intacta.
La garantía, sin embargo, viene con tres límites que conviene enunciar con precisión.
Bytes no cubiertos
Si el miembro que lees es mayor que el que escribiste, los bytes sobrantes tienen valor no especificado. Leerlos no es un error, pero su contenido no es predecible.
Representaciones trampa
Si los bytes reinterpretados no forman un valor válido del nuevo tipo, el comportamiento sí es indefinido. Los tipos de anchura exacta como uint32_t no tienen representaciones trampa; otros pueden tenerlas.
Orden de bytes
La reinterpretación expone el endianness de la máquina. Un uint32_t leído como arreglo de bytes da resultados opuestos en little-endian y big-endian.
La union debe ser visible
La concesión aplica al acceso a través de un objeto union real. Falsificarla con un casteo de puntero no invoca la misma regla.
Ese último punto es el que más código rompe. El casteo directo parece hacer lo mismo, pero apoya la lectura en un tipo distinto del tipo efectivo del objeto, y eso viola la regla de aliasing estricto que el optimizador usa para reordenar accesos.
// Comportamiento indefinido: viola el aliasing estricto.
float f = 1.0f;
uint32_t mal = *(uint32_t *)&f;
// Correcto y sin coste: el compilador lo reduce a un movimiento de registro.
uint32_t bien;
memcpy(&bien, &f, sizeof bien);
// Correcto en C, aunque no en C++: la union es el objeto real.
union Palabra p = {.real = 1.0f};
uint32_t tambien_bien = p.entera;
Cuesta aceptarlo la primera vez, pero memcpy no es una llamada a biblioteca cuando copia un tamaño constante y pequeño: los compiladores modernos la reconocen como una operación primitiva y la reducen a una sola instrucción, a menudo a ninguna. Es exactamente lo que hacen std::bit_cast en C++20 y transmute en Rust, solo que sin azúcar sintáctico. La lección profunda es que en C la reinterpretación de bytes no se expresa mediante el sistema de tipos de punteros, porque ese sistema existe justamente para prometerle al optimizador que dos punteros de tipos incompatibles no se solapan. Cada vez que casteas un puntero para leer bits estás rompiendo esa promesa a espaldas del compilador, y el precio no se paga en el momento sino tres versiones después, cuando una optimización nueva reordene el acceso y el bug aparezca solo con -O2. Escribe memcpy y deja que el compilador decida cómo se materializa. La union etiquetada es la herramienta para modelar alternativas; memcpy, la herramienta para reinterpretar bytes. Confundirlas es el origen de una fracción sorprendente de los fallos difíciles en C.
La secuencia inicial común
Existe una segunda garantía, más estrecha y muy útil. Si una union contiene varios struct que comparten una secuencia inicial común —una sucesión de miembros iniciales con tipos compatibles y, en C23, con los mismos nombres— entonces puedes inspeccionar esos miembros a través de cualquiera de los structs, sea cual sea el activo, siempre que la declaración completa de la union sea visible en ese punto de traducción.
struct Base { int tipo; };
struct Circulo { int tipo; double radio; };
struct Rect { int tipo; double ancho, alto; };
union Figura {
struct Base base;
struct Circulo circulo;
struct Rect rect;
};
// Legal: 'tipo' pertenece a la secuencia inicial comun.
int etiqueta_de(const union Figura *f) { return f->base.tipo; }
Esta regla es la base formal del subtipado por prefijo compartido que usan muchas bibliotecas en C, desde los objetos de CPython hasta las estructuras de socket con sockaddr. Es también el motivo por el que ese estilo exige que la union esté declarada y visible: sin ella, el compilador está autorizado a asumir que un struct Base * y un struct Circulo * jamás apuntan al mismo objeto.
La union etiquetada: el tipo suma de C
La union desnuda es insegura porque olvida cuál es su miembro activo. La solución canónica es empaquetarla junto a un enum discriminante dentro de un struct, de modo que el invariante deje de ser mental y pase a ser un dato.
typedef enum { VAL_ENTERO, VAL_REAL, VAL_TEXTO } ValorTipo;
typedef struct {
ValorTipo tipo;
union { // union anonima: se accede sin nombrarla
long long entero;
double real;
char *texto;
};
} Valor;
static Valor valor_entero(long long n) {
return (Valor){.tipo = VAL_ENTERO, .entero = n};
}
static void imprimir(const Valor *v) {
switch (v->tipo) {
case VAL_ENTERO: printf("%lld\n", v->entero); break;
case VAL_REAL: printf("%g\n", v->real); break;
case VAL_TEXTO: printf("%s\n", v->texto); break;
}
}
flowchart TB V[Valor con etiqueta y union anonima] --> S[switch sobre la etiqueta] S --> E[caso entero lee el miembro entero] S --> R[caso real lee el miembro real] S --> T[caso texto lee el puntero y es duenio de la memoria] E --> OK[acceso siempre al miembro activo] R --> OK T --> OK style V fill:#89b4fa,color:#11111b style OK fill:#a6e3a1,color:#11111b
Tres disciplinas convierten el patrón en algo realmente seguro. Construir siempre a través de funciones que fijan etiqueta y miembro a la vez, para que nunca puedan divergir. Escribir el switch sin default, de modo que -Wswitch te avise al añadir un caso nuevo al enum. Y cuando un miembro posea memoria, como el char * del ejemplo, centralizar la liberación en una función que también consulte la etiqueta.
El enum con valores asociados de Swift y Rust es exactamente esta construcción, con dos añadidos que C no ofrece: el compilador impide leer el miembro equivocado y verifica la exhaustividad del switch. En C esas dos comprobaciones dependen de tu disciplina y de los avisos que actives. Compilar con -Wswitch-enum y envolver el acceso en funciones pequeñas recupera buena parte de la seguridad perdida.
- Declara
union Palabra, escribe0x3F800000en el miembro entero, lee el miembro real y explica por qué obtienes1.0. - Repite la reinterpretación con
memcpyy compila ambas versiones con-O2 -S: comprueba que el ensamblador generado es idéntico. - Escribe la versión con casteo de puntero, compila con
-O2 -fstrict-aliasing -Wstrict-aliasing=2y documenta el aviso. - Implementa el
Valoretiquetado completo con constructor por caso, función de impresión y función de liberación, y comprueba con-Wswitchque añadir un caso alenumrompe la compilación hasta que lo tratas. - Construye la
union Figuradel ejemplo y verifica constatic_assertyoffsetofque el miembrotipoestá en el desplazamiento cero de los tres structs.