Unions, bit flags, and tagged values
Records, files and recursion13 min readLesson 2 of 6
2.1 One storage area, several readings
A union declares members that all start at the same address. Its size is the size of the largest member, rounded up for alignment.
Only one member holds a meaningful value at a time. Writing value.whole and reading value.real reinterprets the same bytes, and the result depends on the representation of both types.
A union is a layout choice for values that take one of several representations, such as a number that may be an integer or a floating point value. The alternatives never need to be valid at the same time.
#include <stdio.h>
union Number {
int whole;
double real;
char letter;
};
int main(void)
{
union Number value;
value.whole = 65;
printf("whole: %d\n", value.whole);
value.real = 2.5;
printf("real: %.1f\n", value.real);
printf("largest member size: %d\n", sizeof value == sizeof(double));
return 0;
}Output
whole: 65 real: 2.5 largest member size: 1
2.2 Tagged unions
A union alone cannot tell you which member is valid. Pair it with an enum tag in an outer struct, and every read checks the tag first.
struct Value { enum Kind kind; union { int whole; double real; } data; }; holds one number and one label. The enum is small, and the union is as large as its largest member.
This is the shape a simple expression evaluator uses: each node has a kind and one payload. A switch on kind decides which union member to read.
#include <stdio.h>
enum Kind { KIND_INT, KIND_REAL };
struct Value {
enum Kind kind;
union { int whole; double real; } data;
};
static void print_value(const struct Value *v)
{
if (v->kind == KIND_INT) printf("int %d\n", v->data.whole);
else printf("real %.2f\n", v->data.real);
}
int main(void)
{
struct Value a = {KIND_INT, {.whole = 7}};
struct Value b = {KIND_REAL, {.real = 1.25}};
print_value(&a);
print_value(&b);
return 0;
}Output
int 7 real 1.25
2.3 Bitwise operators
The bitwise operators work on the binary representation of integers. & keeps bits set in both operands, | keeps bits set in either, ^ keeps bits that differ, and ~ flips every bit.
Shifts move bits left or right. x << n multiplies by 2 to the n, and x >> n divides, provided x is unsigned. Shifting a signed negative value right is implementation-defined.
Write masks in hexadecimal. 0x0F is easier to read than 15 when the point is the bit pattern, and each hex digit maps to four bits.
#include <stdio.h>
int main(void)
{
unsigned int a = 0x0C;
unsigned int b = 0x0A;
printf("and: %02X\n", a & b);
printf("or: %02X\n", a | b);
printf("xor: %02X\n", a ^ b);
printf("not: %02X\n", (~a) & 0xFF);
printf("shift: %02X\n", a << 1);
return 0;
}Output
and: 08 or: 0E xor: 06 not: F3 shift: 18
2.4 Flags with named bits
Several on or off properties can share one unsigned integer. Each property owns one bit, defined as a constant with a single set bit.
Set a bit with |=, test with &, clear with &= ~mask, and toggle with ^=. The test compares against zero: (flags & MASK) != 0, because the value of the bit itself is not the point.
An enum can hold the same constants when you want them typed. Names such as PERM_READ and PERM_WRITE keep the bit meanings visible at the call site.
#include <stdio.h>
int main(void)
{
unsigned int flags = 0x1u; /* read */
printf("write set: %u\n", flags & 0x2u);
flags |= 0x2u; /* set write */
printf("after set: %u\n", flags);
flags &= ~0x1u; /* clear read */
printf("after clear: %u\n", flags);
printf("write test: %u\n", (flags & 0x2u) != 0);
return 0;
}Output
write set: 0 after set: 3 after clear: 2 write test: 1
2.5 Packing a colour
Three bytes fit in one 32-bit integer: red in bits 16 to 23, green in bits 8 to 15, blue in bits 0 to 7. Packing uses shifts and |; unpacking uses shifts and & 0xFF.
Unpacking needs the mask. colour >> 16 keeps the higher bits if any exist, and & 0xFF keeps only the byte you want.
The same pattern stores protocol headers, pixel formats, and hardware registers. Shifts and masks are cheaper than a library call, and the code reads like the bit layout.
#include <stdio.h>
int main(void)
{
unsigned int red = 0x12;
unsigned int green = 0x34;
unsigned int blue = 0x56;
unsigned int colour = (red << 16) | (green << 8) | blue;
printf("#%06X\n", colour);
printf("r=%u g=%u b=%u\n",
(colour >> 16) & 0xFF,
(colour >> 8) & 0xFF,
colour & 0xFF);
return 0;
}Output
#123456 r=18 g=52 b=86
2.6 Counting bits
A loop that shifts an unsigned value right until it reaches zero visits every bit once. Adding the low bit to a counter gives the population count.
Stopping at zero means the loop runs at most 32 times for a 32-bit value, whatever the number. Negative values must be converted to unsigned first, because a right shift on a negative value is implementation-defined.
Processors have an instruction for this count, and compilers expose it through extensions. The loop is the portable version and is clear enough for study.
#include <stdio.h>
static int count_bits(unsigned int value)
{
int count = 0;
while (value != 0) {
count += (int)(value & 1u);
value >>= 1;
}
return count;
}
int main(void)
{
printf("%d\n", count_bits(0u));
printf("%d\n", count_bits(7u));
printf("%d\n", count_bits(0xFFu));
return 0;
}Output
0 3 8
2.7 When a union is the wrong tool
Reading a different member than the one last written is not portable in general. The standard allows it only in narrow cases, such as a union of structs that share a common initial sequence, and reading the value back through the same member type.
Type punning through pointer casts is worse. *(float *)&bits breaks the strict aliasing rules, and an optimiser can produce code that does not reflect the bytes.
memcpy between two objects copies the representation without an aliasing question. When the bit pattern must be preserved, copy it into the target type and let the compiler handle the rest.
#include <stdio.h>
#include <string.h>
#include <stdint.h>
int main(void)
{
uint32_t bits = 0x3FC00000u;
float number = 0.0f;
memcpy(&number, &bits, sizeof number);
printf("%.1f\n", number);
return 0;
}Output
1.5
