C

C

Tutorial C: Uniones

Aprende cómo funcionan las uniones en C para compartir memoria entre campos y optimizar el uso de datos en programación.

Aprende C y certifícate

Union comparte memoria entre campos

Las uniones en C representan una estructura de datos especial que permite almacenar diferentes tipos de datos en la misma área de memoria. A diferencia de las estructuras (struct), donde cada miembro tiene su propio espacio de almacenamiento independiente, en una union todos los miembros comparten el mismo espacio de memoria.

Esta característica de compartir memoria es lo que hace a las uniones particularmente útiles en situaciones donde necesitamos optimizar el uso de memoria o cuando queremos interpretar los mismos datos de diferentes maneras.

Declaración de una union

Para declarar una unión en C, utilizamos la palabra clave union seguida del nombre de la unión y sus miembros entre llaves:

union Numero {
    int entero;
    float decimal;
    char caracter;
};

En este ejemplo, hemos creado una unión llamada Numero que puede almacenar un valor como entero, decimal o carácter. Lo importante es entender que estos tres miembros comparten el mismo espacio de memoria.

Cómo funciona la memoria compartida

Cuando declaramos una variable de tipo unión, el compilador reserva suficiente memoria para acomodar al miembro más grande. Todos los miembros de la unión comienzan en la misma dirección de memoria:

union Numero valor;

printf("Tamaño de la unión: %zu bytes\n", sizeof(valor));
printf("Dirección de entero: %p\n", &valor.entero);
printf("Dirección de decimal: %p\n", &valor.decimal);
printf("Dirección de caracter: %p\n", &valor.caracter);

Si ejecutamos este código, notaremos que las direcciones de memoria de todos los miembros son idénticas, lo que confirma que comparten el mismo espacio.

Acceso a los miembros

Podemos acceder y modificar los miembros de una unión de manera similar a como lo hacemos con las estructuras:

union Numero valor;

valor.entero = 42;
printf("Valor como entero: %d\n", valor.entero);

// Al modificar un miembro, afectamos a los demás
valor.decimal = 3.14;
printf("Valor como decimal: %f\n", valor.decimal);
printf("Valor como entero ahora: %d\n", valor.entero);

En este ejemplo, cuando asignamos 3.14 al miembro decimal, estamos sobrescribiendo el valor anterior de 42 en entero, ya que ambos comparten la misma memoria. El valor de entero ahora contendrá la representación en bits de 3.14 interpretada como un entero, lo que probablemente no tenga sentido como número entero.

Ejemplo práctico: Interpretación múltiple de datos

Un caso de uso común para las uniones es cuando necesitamos interpretar los mismos datos de diferentes formas:

#include <stdio.h>

union Datos {
    unsigned int valor;
    unsigned char bytes[4];
};

int main() {
    union Datos d;
    d.valor = 0x12345678;  // Valor hexadecimal
    
    printf("Valor entero: %u\n", d.valor);
    printf("Bytes individuales: %02X %02X %02X %02X\n", 
           d.bytes[0], d.bytes[1], d.bytes[2], d.bytes[3]);
    
    return 0;
}

En este ejemplo, podemos acceder al mismo dato como un entero completo o como una serie de bytes individuales. Esto es útil para operaciones de bajo nivel como manipulación de bits o cuando trabajamos con protocolos de red.

Uso en conservación de memoria

Las uniones son especialmente útiles cuando necesitamos almacenar diferentes tipos de datos, pero sabemos que solo uno estará activo a la vez:

union Valor {
    int i;
    float f;
    char str[20];
};

struct Dato {
    char tipo;  // 'i' para entero, 'f' para float, 's' para string
    union Valor valor;
};

En este ejemplo, la estructura Dato utiliza solo la memoria necesaria para almacenar el tipo y el valor más grande posible (en este caso, el array de caracteres), en lugar de reservar espacio para los tres tipos a la vez.

Consideraciones importantes

Al trabajar con uniones, debemos tener en cuenta algunas consideraciones importantes:

  • Siempre debemos saber qué miembro de la unión contiene datos válidos en un momento dado
  • Acceder a un miembro diferente al último que se modificó puede dar resultados inesperados
  • Las uniones son útiles para ahorrar memoria, pero pueden hacer el código menos claro si no se documentan adecuadamente

Las uniones son una herramienta poderosa en C que nos permite optimizar el uso de memoria y manipular datos de formas flexibles, siempre que entendamos claramente cómo funciona la memoria compartida entre sus miembros.

Solo un campo activo a la vez

Cuando trabajamos con uniones en C, una característica fundamental que debemos entender es que solo un miembro puede contener un valor válido en un momento determinado. Esta limitación se deriva directamente de cómo las uniones comparten el mismo espacio de memoria para todos sus miembros.

A diferencia de las estructuras, donde cada campo mantiene su valor independientemente, en una unión al modificar un campo estamos alterando la misma área de memoria que comparten todos los demás campos. Esto significa que escribir en un miembro invalida automáticamente el valor de los demás.

Seguimiento del miembro activo

El lenguaje C no proporciona ningún mecanismo automático para rastrear qué miembro de la unión contiene datos válidos en un momento dado. Es responsabilidad del programador mantener esta información:

#include <stdio.h>

union Valor {
    int i;
    float f;
    char c;
};

int main() {
    union Valor v;
    
    v.i = 10;
    printf("v contiene un entero: %d\n", v.i);
    
    // Al asignar un nuevo valor a otro miembro, invalidamos el anterior
    v.f = 3.14;
    printf("v ahora contiene un float: %.2f\n", v.f);
    
    // El valor entero ya no es válido
    printf("Intentar leer v como entero: %d\n", v.i);
    
    return 0;
}

En este ejemplo, después de asignar 3.14 al campo f, el valor de i ya no es 10. Si intentamos acceder a v.i, obtendremos la representación en bits del valor 3.14 interpretada como un entero, lo que producirá un resultado sin sentido.

Uso de una variable de control

Una práctica común para manejar este comportamiento es utilizar una variable adicional que indique qué tipo de dato está almacenado actualmente en la unión:

#include <stdio.h>

typedef enum {
    TIPO_INT,
    TIPO_FLOAT,
    TIPO_CHAR
} TipoDato;

typedef struct {
    TipoDato tipo;
    union {
        int i;
        float f;
        char c;
    } valor;
} Variante;

void mostrar_variante(Variante v) {
    switch (v.tipo) {
        case TIPO_INT:
            printf("Entero: %d\n", v.valor.i);
            break;
        case TIPO_FLOAT:
            printf("Float: %.2f\n", v.valor.f);
            break;
        case TIPO_CHAR:
            printf("Carácter: %c\n", v.valor.c);
            break;
    }
}

int main() {
    Variante v;
    
    // Almacenar un entero
    v.tipo = TIPO_INT;
    v.valor.i = 42;
    mostrar_variante(v);
    
    // Cambiar a float
    v.tipo = TIPO_FLOAT;
    v.valor.f = 3.14;
    mostrar_variante(v);
    
    return 0;
}

En este ejemplo, la estructura Variante combina una unión con un campo tipo que nos permite saber qué miembro contiene datos válidos en cada momento. La función mostrar_variante utiliza esta información para acceder solo al miembro apropiado.

Interpretación de bits

Un caso de uso interesante de las uniones es cuando queremos interpretar los mismos bits de diferentes maneras:

#include <stdio.h>

union Interprete {
    int valor;
    unsigned char bytes[4];
};

int main() {
    union Interprete x;
    
    x.valor = 258;  // En binario: 00000000 00000000 00000001 00000010
    
    // Accedemos a los bytes individuales (depende del endianness del sistema)
    printf("Valor como entero: %d\n", x.valor);
    printf("Primer byte: %d\n", x.bytes[0]);
    printf("Segundo byte: %d\n", x.bytes[1]);
    
    return 0;
}

En este ejemplo, aunque modificamos x.valor, podemos examinar su representación a nivel de bytes a través de x.bytes. Dependiendo de la arquitectura del sistema (little-endian o big-endian), veremos diferentes valores para los bytes individuales.

Errores comunes al trabajar con uniones

Uno de los errores más frecuentes al trabajar con uniones es olvidar que solo un campo está activo a la vez:

union Datos {
    int contador;
    float promedio;
};

void funcion_incorrecta() {
    union Datos datos;
    
    datos.contador = 10;
    datos.promedio = 7.5;
    
    // ¡Error lógico! El siguiente código asume que ambos valores son válidos
    printf("Contador: %d, Promedio: %.1f\n", datos.contador, datos.promedio);
}

En este código, después de asignar 7.5 a datos.promedio, el valor de datos.contador ya no es 10. El programa compilará sin errores, pero el resultado será incorrecto.

Verificación de tipo en tiempo de ejecución

Para aplicaciones más complejas, podemos implementar un sistema de verificación de tipo que lance advertencias o errores cuando se intente acceder a un miembro inactivo:

#include <stdio.h>
#include <stdlib.h>

typedef enum {
    TIPO_NINGUNO,
    TIPO_INT,
    TIPO_FLOAT
} TipoDato;

typedef struct {
    TipoDato tipo_activo;
    union {
        int i;
        float f;
    } dato;
} ValorSeguro;

int obtener_int(ValorSeguro* v) {
    if (v->tipo_activo != TIPO_INT) {
        fprintf(stderr, "Error: Intentando acceder a un entero cuando el tipo activo es diferente\n");
        exit(1);
    }
    return v->dato.i;
}

float obtener_float(ValorSeguro* v) {
    if (v->tipo_activo != TIPO_FLOAT) {
        fprintf(stderr, "Error: Intentando acceder a un float cuando el tipo activo es diferente\n");
        exit(1);
    }
    return v->dato.f;
}

int main() {
    ValorSeguro v = {TIPO_NINGUNO};
    
    v.tipo_activo = TIPO_INT;
    v.dato.i = 42;
    
    printf("Valor entero: %d\n", obtener_int(&v));
    
    // Esto generaría un error en tiempo de ejecución
    // printf("Valor float: %f\n", obtener_float(&v));
    
    return 0;
}

Este enfoque proporciona una capa adicional de seguridad al trabajar con uniones, evitando errores difíciles de detectar causados por acceder a miembros inactivos.

La naturaleza de "un solo campo activo a la vez" de las uniones puede parecer una limitación, pero cuando se utiliza correctamente, se convierte en una herramienta valiosa para optimizar el uso de memoria y trabajar con diferentes interpretaciones de los mismos datos.

Tamaño es del campo más grande

Cuando trabajamos con uniones en C, una característica fundamental es que el tamaño total de la unión viene determinado por el tamaño de su miembro más grande. Esto es una consecuencia directa de cómo las uniones gestionan la memoria compartida entre todos sus campos.

El compilador de C asigna suficiente espacio para que la unión pueda contener su miembro más grande, garantizando así que cualquier miembro pueda almacenarse correctamente. Esta característica es esencial para entender cómo las uniones optimizan el uso de memoria.

Cálculo del tamaño de una unión

Podemos verificar fácilmente esta propiedad utilizando el operador sizeof:

#include <stdio.h>

union Ejemplo {
    char c;           // 1 byte
    int i;            // 4 bytes (típicamente)
    double d;         // 8 bytes (típicamente)
};

int main() {
    union Ejemplo u;
    
    printf("Tamaño de char: %zu bytes\n", sizeof(char));
    printf("Tamaño de int: %zu bytes\n", sizeof(int));
    printf("Tamaño de double: %zu bytes\n", sizeof(double));
    printf("Tamaño de la unión: %zu bytes\n", sizeof(union Ejemplo));
    
    return 0;
}

Al ejecutar este código, veremos que el tamaño de la unión Ejemplo coincide con el tamaño de double, que es su miembro más grande. Esto demuestra que la unión reserva memoria basándose en el miembro que requiere más espacio.

Comparación con estructuras

Para entender mejor esta característica, es útil comparar el comportamiento de las uniones con el de las estructuras:

#include <stdio.h>

union UnionEjemplo {
    char c;           // 1 byte
    int i;            // 4 bytes
    double d;         // 8 bytes
};

struct StructEjemplo {
    char c;           // 1 byte
    int i;            // 4 bytes
    double d;         // 8 bytes
};

int main() {
    printf("Tamaño de la unión: %zu bytes\n", sizeof(union UnionEjemplo));
    printf("Tamaño de la estructura: %zu bytes\n", sizeof(struct StructEjemplo));
    
    return 0;
}

La diferencia es notable: mientras que la unión ocupa solo el tamaño de su miembro más grande (8 bytes para double), la estructura suma el tamaño de todos sus miembros (considerando también el posible relleno para alineación de memoria).

Alineación de memoria y relleno

Es importante mencionar que el tamaño exacto de una unión también puede verse afectado por la alineación de memoria. El compilador puede añadir bytes de relleno para asegurar que los miembros estén alineados correctamente:

#include <stdio.h>

union AlineacionEjemplo {
    char c;
    double d;
};

int main() {
    printf("Tamaño de char: %zu bytes\n", sizeof(char));
    printf("Tamaño de double: %zu bytes\n", sizeof(double));
    printf("Tamaño de la unión: %zu bytes\n", sizeof(union AlineacionEjemplo));
    
    return 0;
}

En algunas arquitecturas, el tamaño de esta unión podría ser mayor que 8 bytes debido a requisitos de alineación, aunque el miembro más grande (double) sea de 8 bytes.

Optimización de memoria con uniones

Esta característica de las uniones las hace ideales para optimizar el uso de memoria en situaciones donde necesitamos almacenar diferentes tipos de datos, pero nunca simultáneamente:

#include <stdio.h>
#include <string.h>

// Definimos diferentes tipos de datos para un empleado
union DatosEmpleado {
    struct {
        int id;
        float salario;
    } tiempo_completo;
    
    struct {
        int id;
        float tarifa_hora;
        int horas_trabajadas;
    } tiempo_parcial;
    
    struct {
        int id;
        char departamento[50];
        float bono;
    } contratista;
};

int main() {
    union DatosEmpleado empleado;
    
    // Asignamos datos para un empleado a tiempo completo
    empleado.tiempo_completo.id = 1001;
    empleado.tiempo_completo.salario = 3500.50;
    
    printf("Empleado a tiempo completo:\n");
    printf("ID: %d, Salario: %.2f\n", 
           empleado.tiempo_completo.id, 
           empleado.tiempo_completo.salario);
    
    // Ahora cambiamos a un contratista
    empleado.contratista.id = 2002;
    strcpy(empleado.contratista.departamento, "Desarrollo");
    empleado.contratista.bono = 1000.0;
    
    printf("\nContratista:\n");
    printf("ID: %d, Departamento: %s, Bono: %.2f\n", 
           empleado.contratista.id, 
           empleado.contratista.departamento, 
           empleado.contratista.bono);
    
    printf("\nTamaño total de la unión: %zu bytes\n", 
           sizeof(union DatosEmpleado));
    
    return 0;
}

En este ejemplo, la unión DatosEmpleado ocupa solo el espacio necesario para su miembro más grande (la estructura contratista), en lugar de sumar el espacio de todas las estructuras. Esto representa un ahorro significativo de memoria, especialmente si manejamos muchos registros.

Implicaciones prácticas

El hecho de que una unión tenga el tamaño de su miembro más grande tiene varias implicaciones prácticas:

  • Eficiencia de memoria: Las uniones son útiles cuando tenemos datos mutuamente excluyentes (solo uno está activo a la vez).
  • Arrays de uniones: Al crear arrays de uniones, cada elemento tendrá el tamaño del miembro más grande.
  • Anidación: Podemos anidar uniones dentro de estructuras para crear tipos de datos complejos y eficientes.
#include <stdio.h>

typedef enum {
    TIPO_INT,
    TIPO_FLOAT,
    TIPO_STRING
} TipoDato;

typedef struct {
    TipoDato tipo;
    union {
        int i;
        float f;
        char s[20];  // Este es el miembro más grande
    } valor;
} Dato;

int main() {
    Dato datos[10];  // Array de 10 elementos tipo Dato
    
    datos[0].tipo = TIPO_INT;
    datos[0].valor.i = 42;
    
    datos[1].tipo = TIPO_STRING;
    sprintf(datos[1].valor.s, "Hola mundo");
    
    printf("Tamaño de cada elemento Dato: %zu bytes\n", sizeof(Dato));
    printf("Tamaño total del array: %zu bytes\n", sizeof(datos));
    
    return 0;
}

En este ejemplo, aunque algunos elementos del array solo almacenan un entero o un float, cada elemento ocupa el mismo espacio (determinado por el tamaño del miembro s[20]).

Consideraciones de diseño

Al diseñar uniones, es importante considerar el equilibrio entre ahorro de memoria y claridad del código:

  • Si los miembros tienen tamaños muy diferentes, podríamos desperdiciar espacio cuando usamos el miembro más pequeño.
  • Es recomendable agrupar miembros de tamaños similares cuando sea posible.
  • Para uniones muy grandes, considerar si realmente necesitamos una unión o si sería mejor usar asignación dinámica de memoria.

Las uniones en C nos ofrecen una forma elegante de optimizar el uso de memoria cuando trabajamos con datos de diferentes tipos que no necesitan coexistir. Entender que el tamaño de la unión viene determinado por su miembro más grande es fundamental para aprovechar al máximo esta característica del lenguaje.

CONSTRUYE TU CARRERA EN IA Y PROGRAMACIÓN SOFTWARE

Accede a +1000 lecciones y cursos con certificado. Mejora tu portfolio con certificados de superación para tu CV.

30 % DE DESCUENTO

Plan mensual

19.00 /mes

13.30 € /mes

Precio normal mensual: 19 €
63 % DE DESCUENTO

Plan anual

10.00 /mes

7.00 € /mes

Ahorras 144 € al año
Precio normal anual: 120 €
Aprende C online

Todas las lecciones de C

Accede a todas las lecciones de C y aprende con ejemplos prácticos de código y ejercicios de programación con IDE web sin instalar nada.

Accede GRATIS a C y certifícate

En esta lección

Objetivos de aprendizaje de esta lección

  • Comprender qué es una unión y cómo comparte memoria entre sus miembros.
  • Aprender a declarar y utilizar uniones en C.
  • Entender que solo un miembro de la unión puede contener un valor válido en un momento dado.
  • Conocer cómo el tamaño de una unión está determinado por su miembro más grande.
  • Aplicar uniones para optimizar el uso de memoria y manejar diferentes interpretaciones de los mismos datos.