Unions são um recurso da linguagem C que permite armazenar diferentes tipos de dados em um mesmo local de memória. Diferentemente das structs, onde cada membro ocupa seu próprio espaço, em uma union todos os membros compartilham o mesmo endereço inicial. Isso significa que o tamanho da union é igual ao tamanho do maior membro. Unions são úteis para economizar memória ou para interpretar um mesmo dado de maneiras diferentes, mas exigem cuidado para evitar acessos inválidos.

Nesta aula, vamos explorar o conceito de unions, como declarar e usar, o compartilhamento de memória envolvido, casos de uso típicos e os principais cuidados que você deve ter ao trabalhar com elas.

O que são

Uma union é um tipo de dado definido pelo usuário que permite armazenar um único valor entre vários membros possíveis, todos compartilhando o mesmo espaço de memória. A declaração é semelhante à de uma struct, mas com a palavra-chave union. Por exemplo:

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

Nesse exemplo, a union Data pode armazenar um inteiro, um float ou uma string de até 20 caracteres, mas apenas um deles por vez. O tamanho da union será o tamanho do maior membro (a string, 20 bytes). Para acessar um membro, usamos o operador ponto (.) ou seta (->) como em structs.

Unions são frequentemente usadas em situações onde você precisa de um tipo que pode assumir diferentes formas, como em interpretadores, manipuladores de protocolos ou sistemas embarcados com restrições de memória.

Compartilhamento de memória

O aspecto fundamental das unions é que todos os membros começam no mesmo endereço de memória. Isso significa que, se você escrever em um membro, os outros membros serão alterados de forma imprevisível, pois estão sobrepostos. Por exemplo:

union Data d;
d.i = 42;
printf("%d\n", d.i);   // 42
printf("%f\n", d.f);   // valor imprevisível (interpretação dos bytes de 42 como float)
printf("%s\n", d.str); // lixo ou comportamento indefinido

O resultado de acessar um membro diferente do último que foi escrito é comportamento indefinido na maioria dos casos (exceto quando se usa unions para type punning em algumas implementações). O programador é responsável por saber qual membro está ativo. Uma técnica comum é usar uma struct que contém uma union e um campo indicador do tipo (tagged union).

O layout de memória de uma union é simples: todos os membros começam no offset 0. O alinhamento segue as regras do maior membro. Por exemplo, se a union contém um int (4 bytes) e um char (1 byte), o tamanho será 4 bytes (o maior), e ambos compartilham o mesmo endereço.

Casos de uso

Unions são úteis em várias situações:

  • Economia de memória: Quando você precisa armazenar um de vários tipos possíveis, mas não todos ao mesmo tempo. Por exemplo, em uma árvore de expressão, um nó pode ser um número, operador ou variável.
  • Interpretação de bytes: Unions permitem visualizar a representação binária de um tipo através de outro. Exemplo: converter um float em seus bytes constituintes.
  • Protocolos de comunicação: Em pacotes de rede que podem ter diferentes formatos, uma union pode representar os diferentes campos.
  • Type punning (com cuidado): Em C, unions são uma forma legal de reinterpretar a representação de um tipo (diferente de usar ponteiros, que viola aliasing).

Exemplo prático: converter um float para seus bytes individuais:

union FloatBytes {
    float f;
    unsigned char bytes[sizeof(float)];
};

union FloatBytes fb;
fb.f = 3.14f;
for (int i = 0; i < sizeof(float); i++) {
    printf("%02x ", fb.bytes[i]);
}
printf("\n");

Isso imprime a representação hexadecimal do float 3.14.

Cuidados

Ao usar unions, é crucial ter atenção aos seguintes pontos:

  • Comportamento indefinido: Acessar um membro que não foi o último escrito é comportamento indefinido (exceto para type punning em algumas implementações). Sempre mantenha controle sobre qual membro está ativo.
  • Alinhamento e padding: Embora unions não tenham padding entre membros, o alinhamento do maior membro se aplica. O tamanho total pode incluir padding no final para alinhamento.
  • Portabilidade: A representação de tipos (endianness, tamanhos) pode variar entre plataformas. Unions que dependem de representação binária não são portáveis.
  • Inicialização: Você pode inicializar uma union com um valor para o primeiro membro: union Data d = { .i = 10 }; ou simplesmente {10} (inicializa o primeiro membro).
  • Unions anônimas: Em C11, você pode usar unions anônimas dentro de structs para membros sobrepostos, mas isso é avançado.

Um erro comum é assumir que todos os membros são válidos simultaneamente. Sempre use um campo discriminador (tag) para saber qual membro está ativo, especialmente em código crítico.

Referências

Exercícios

  1. Crie uma union chamada Number que possa armazenar um int ou um double. Escreva um programa que armazene um valor inteiro e depois o imprima como double (observe o resultado).

    ✓ Resposta:
    #include <stdio.h>
    
    union Number {
        int i;
        double d;
    };
    
    int main() {
        union Number n;
        n.i = 42;
        printf("Como int: %d\n", n.i);
        printf("Como double: %f\n", n.d); // comportamento indefinido
        return 0;
    }
  2. Implemente uma union que permite acessar um inteiro de 32 bits como quatro bytes individuais (little-endian). Escreva um programa que armazene o valor 0x12345678 e imprima cada byte em hexadecimal.

    ✓ Resposta:
    #include <stdio.h>
    
    union IntBytes {
        int i;
        unsigned char bytes[4];
    };
    
    int main() {
        union IntBytes ib;
        ib.i = 0x12345678;
        for (int j = 0; j < 4; j++) {
            printf("byte %d: 0x%02x\n", j, ib.bytes[j]);
        }
        return 0;
    }
  3. Usando uma union, escreva uma função que receba um float e retorne um unsigned int com a mesma representação binária (type punning). Dica: use uma union com float e unsigned int.

    ✓ Resposta:
    #include <stdio.h>
    
    union FloatUint {
        float f;
        unsigned int u;
    };
    
    unsigned int float_to_bits(float f) {
        union FloatUint fu;
        fu.f = f;
        return fu.u;
    }
    
    int main() {
        float f = 3.14f;
        printf("Bits: 0x%08x\n", float_to_bits(f));
        return 0;
    }
  4. Crie uma struct TaggedValue que contenha um campo type (int: 0 para inteiro, 1 para float) e uma union anônima com os membros i e f. Escreva uma função que imprima o valor corretamente baseado no tipo.

    ✓ Resposta:
    #include <stdio.h>
    
    struct TaggedValue {
        int type; // 0 = int, 1 = float
        union {
            int i;
            float f;
        };
    };
    
    void print_value(struct TaggedValue tv) {
        if (tv.type == 0)
            printf("%d\n", tv.i);
        else if (tv.type == 1)
            printf("%f\n", tv.f);
        else
            printf("Unknown type\n");
    }
    
    int main() {
        struct TaggedValue tv1 = {0, .i = 10};
        struct TaggedValue tv2 = {1, .f = 3.14};
        print_value(tv1);
        print_value(tv2);
        return 0;
    }
  5. Explique por que o código abaixo pode produzir comportamento indefinido e como corrigi-lo usando uma union com tag.

    union Data { int i; float f; };
    union Data d;
    d.i = 5;
    printf("%f\n", d.f);

    ✓ Resposta:

    O código acessa o membro f enquanto o último membro escrito foi i. Isso é comportamento indefinido porque a union só garante que o valor do último membro escrito é válido. Para corrigir, deve-se usar um campo discriminador (tag) que indica qual membro está ativo, e só acessar o membro correspondente.

    #include <stdio.h>
    
    struct SafeData {
        int tag; // 0 para int, 1 para float
        union { int i; float f; };
    };
    
    int main() {
        struct SafeData sd;
        sd.tag = 0;
        sd.i = 5;
        if (sd.tag == 0)
            printf("%d\n", sd.i);
        else
            printf("%f\n", sd.f);
        return 0;
    }