Definición y Sintaxis Básica
En el lenguaje C, una union (unión) es un tipo de dato definido por el usuario que permite almacenar diferentes tipos de datos en la misma ubicación de memoria. A diferencia de las estructuras (struct), donde cada miembro tiene su propio espacio asignado, todos los miembros de una unión comparten el mismo bloque de memoria físico.
Declaración y Uso
La sintaxis para definir una unión es similar a la de una estructura. A continuación, se muestra un ejemplo de una unión diseñada para leer datos de un sensor, donde el valor puede interpretarse como un entero crudo, un valor flotante calibrado o un código de estado:
#include <stdio.h>
union SensorReading {
int raw_adc;
float calibrated_value;
char status_code[4];
};
int main() {
union SensorReading reading;
// Asignación a un miembro
reading.raw_adc = 1024;
printf("Raw ADC: %d\n", reading.raw_adc);
// Reasignación a otro miembro sobrescribe la memoria
reading.calibrated_value = 3.14f;
printf("Calibrated: %f\n", reading.calibrated_value);
return 0;
}
Es fundamental comprender que una variable de tipo unión solo puede contener el valor de un miembro a la vez. Asignar un valor a un miembro diferente sorbescribirá los datos almacenados previamente, ya que todos apuntan a la misma dirección base en memoria.
Diferencias Clave: Union vs Struct
La diferencia arquitectónica principal radica en la asignación de memoria:
- Struct (Estructura): Asigna memoria de forma aditiva. El tamaño total es la suma de los tamaños de sus miembros (más el relleno de alineación). Todos los miembros coexisten simultáneamente.
- Union (Unión): Asigna memoria de forma superpuesta. El tamaño total está determinado por el miembro más grande. Los miembros son mutuamente excluyentes en cuanto a la validez de sus datos en un momento dado.
Memoria Compartida y Detección de Endianness
Dado que todos los miembros comparten la misma dirección de memoria base, las uniones son una herramienta excelente para inspeccionar cómo se organizan los bytes en la arquitectura del procesador (Little-Endian vs Big-Endian).
#include <stdio.h>
#include <stdint.h>
union EndianTest {
uint32_t full_value;
uint8_t bytes[4];
};
int main() {
union EndianTest test;
test.full_value = 0x01020304;
printf("Memory layout:\n");
for (int i = 0; i < 4; i++) {
printf("Byte %d: 0x%02X\n", i, test.bytes[i]);
}
if (test.bytes[0] == 0x04) {
printf("Architecture is Little-Endian.\n");
} else {
printf("Architecture is Big-Endian.\n");
}
return 0;
}
En este ejemplo, al asignar un entero de 32 bits, podemos leer los bytes individuales. Si el byte de menor peso (0x04) se encuentra en la dirección de memoria más baja (bytes[0]), el sistema es Little-Endian.
Tamaño en Memoria y Alineación
El tamaño de una unión no es simplemente el tamaño de su miembro más grande; también está sujeto a las reglas de alineación de memoria del compilador y la arquitectura. El tamaño total será el del miembro más grande, redondeado al múltiplo más cercano del requisito de alineación más estricto entre sus miembros.
#include <stdio.h>
union AlignedData {
char text[10]; // 10 bytes
double precision_val; // 8 bytes, pero requiere alineación de 8 bytes
};
int main() {
// El tamaño será 16, no 10, debido a la alineación de 8 bytes requerida por 'double'
printf("Size of AlignedData: %zu bytes\n", sizeof(union AlignedData));
return 0;
}
Acceso a Nivel de Bits y Punteros
La esencia de la unión en C es que simplemente reserva un bloque de memoria contiguo. El compilador no impone restricciones estrictas sobre cómo se interpreta esa memoria. Esto permite utilizar punteros para acceder a los datos de formas no definidas explícitamente en la declaración de la unión.
#include <stdio.h>
#include <stdint.h>
union DataBlock {
uint64_t large_int;
double float_val;
};
int main() {
union DataBlock block;
block.large_int = 0x4142434445464748ULL;
// Acceso mediante puntero de caracteres para inspección de memoria
uint8_t *byte_ptr = (uint8_t *)█
printf("Hex dump: ");
for (int i = 0; i < 8; i++) {
printf("%02X ", byte_ptr[i]);
}
printf("\n");
return 0;
}
Aunque esta flexibilidad es poderosa, requiere un cuidado extremo. Acceder a la memoria más allá del tamaño asignado por la unión o interpretar datos de manera incorrecta resultará en un comportamiento indefinido.
Patrones de Diseño y Casos de Uso Prácticos
1. Interpretación de Registros de Hardware y Redes
Las uniones son ideales para mapear registros de hardware o protocolos de red donde un bloque de bytes puede interpretarse como un valor completo o como campos de bits endividuales.
#include <stdio.h>
#include <stdint.h>
union IPv4Address {
uint32_t numeric_ip;
struct {
uint8_t octet1;
uint8_t octet2;
uint8_t octet3;
uint8_t octet4;
} parts;
};
int main() {
union IPv4Address ip;
ip.numeric_ip = 0x0100A8C0; // 192.168.0.1 en Little-Endian
printf("IP Address: %d.%d.%d.%d\n",
ip.parts.octet1, ip.parts.octet2,
ip.parts.octet3, ip.parts.octet4);
return 0;
}
2. Uniones Etiquetadas (Tagged Unions) para Polimorfismo
En sistemas de procesamiento de mensajes o eventos, las uniones etiquetadas premiten crear estructuras de datos que pueden contener diferentes tipos de cargas útiles (payloads) de manera segura, optimizando el uso de memoria en comparación con el uso de punteros void o estructuras separadas.
#include <stdio.h>
#include <stdint.h>
enum MessageType {
MSG_TEXT,
MSG_TELEMETRY,
MSG_COMMAND
};
struct TextPayload {
char content[64];
};
struct TelemetryPayload {
float temperature;
float humidity;
uint16_t battery_level;
};
struct CommandPayload {
uint8_t command_id;
uint32_t target_device;
};
struct NetworkMessage {
uint32_t message_id;
enum MessageType type;
union {
struct TextPayload text;
struct TelemetryPayload telemetry;
struct CommandPayload command;
} payload;
};
void process_message(const struct NetworkMessage *msg) {
switch (msg->type) {
case MSG_TEXT:
printf("Text: %s\n", msg->payload.text.content);
break;
case MSG_TELEMETRY:
printf("Temp: %.2f, Hum: %.2f\n",
msg->payload.telemetry.temperature,
msg->payload.telemetry.humidity);
break;
case MSG_COMMAND:
printf("Cmd ID: %d to Device: %u\n",
msg->payload.command.command_id,
msg->payload.command.target_device);
break;
}
}
Este patrón es fundamental en sistemas embebidos y programación de redes, ya que garantiza que el tamaño de NetworkMessage sea fijo y predecible, evitando la fragmentación de memoria dinámica y facilitando la serialización de datos para su transmisión.