C · lesson 14 of 25
Unions and Enums in C
C unions and enums in practice: shared memory layout, tagged unions, reading float bits, enum values, and how -Wswitch catches missing cases.
By SkillByExample · Updated
Union Basics and Memory Layout
A union is big enough for its largest member, and every member starts at the same address. Writing one member overwrites the others:
#include <stdio.h>
union Data {
int i;
float f;
double d;
unsigned char bytes[8];
};
int main(void) {
union Data u;
printf("sizeof(union Data) = %zu\n", sizeof u);
printf("&u.i = %p\n&u.d = %p\n", (void *)&u.i, (void *)&u.d);
u.i = 42;
printf("wrote i = 42 -> i = %d\n", u.i);
u.f = 1.0f; /* overwrites the same bytes */
printf("wrote f = 1.0f -> f = %.1f, i = %d\n", u.f, u.i);
printf("bytes of 1.0f: ");
for (size_t k = 0; k < sizeof(float); k++) printf("%02X ", u.bytes[k]);
printf("\n");
return 0;
}
$ gcc -Wall -Wextra -std=c17 -o union union.c
$ ./union
sizeof(union Data) = 8
&u.i = 0x7ffcf6ce4860
&u.d = 0x7ffcf6ce4860
wrote i = 42 -> i = 42
wrote f = 1.0f -> f = 1.0, i = 1065353216
bytes of 1.0f: 00 00 80 3F
i and d have the same address, so there’s one set of 8 bytes shared four ways. After u.f = 1.0f, reading u.i doesn’t give 42 or 1. It gives 1065353216, which is 0x3F800000, the IEEE 754 bit pattern of 1.0f read as an integer. The bytes print “backwards” (00 00 80 3F) because x86 is little-endian, so the lowest-order byte comes first in memory.
Tagged Unions
On its own, a union doesn’t know which member is live. The standard pattern pairs it with an enum tag that records it:
#include <stdio.h>
#include <string.h>
#include <stdlib.h>
typedef enum { VAL_INT, VAL_FLOAT, VAL_STRING, VAL_BOOL } ValueType;
typedef struct {
ValueType type; /* the tag: which member is live */
union { /* anonymous union (C11) */
int i;
double f;
char *s;
int b;
};
} Value;
Value make_int(int i) { return (Value){.type = VAL_INT, .i = i}; }
Value make_float(double f) { return (Value){.type = VAL_FLOAT, .f = f}; }
Value make_bool(int b) { return (Value){.type = VAL_BOOL, .b = !!b}; }
Value make_string(const char *s) {
Value v = {.type = VAL_STRING};
v.s = malloc(strlen(s) + 1);
if (v.s) strcpy(v.s, s);
return v;
}
void print_value(const Value *v) {
switch (v->type) {
case VAL_INT: printf("int(%d)\n", v->i); break;
case VAL_FLOAT: printf("float(%.4g)\n", v->f); break;
case VAL_STRING: printf("string(\"%s\")\n", v->s); break;
case VAL_BOOL: printf("bool(%s)\n", v->b ? "true" : "false"); break;
}
}
int main(void) {
Value vals[] = { make_int(42), make_float(3.14159), make_string("hello"), make_bool(7) };
printf("sizeof(Value) = %zu\n", sizeof(Value));
for (size_t k = 0; k < sizeof vals / sizeof vals[0]; k++) {
print_value(&vals[k]);
if (vals[k].type == VAL_STRING) free(vals[k].s);
}
return 0;
}
$ gcc -Wall -Wextra -std=c17 -o tagged tagged.c
$ ./tagged
sizeof(Value) = 16
int(42)
float(3.142)
string("hello")
bool(true)
Four kinds of value fit in 16 bytes: a 4-byte tag, 4 bytes of padding, and an 8-byte union. As a struct holding all four members, it would take 32. Every read goes through switch (v->type), so no code reads a member that isn’t live. The anonymous union lets you write v->i instead of v->u.i. make_bool(7) stores !!7, which is 1. The string copy uses malloc + strcpy rather than strdup, because strdup is POSIX, and only standard C from C23. Under strict -std=c17 it isn’t even declared:
$ gcc -Wall -Wextra -std=c17 -o tagged_strdup tagged_strdup.c
tagged_strdup.c: In function 'main':
tagged_strdup.c:6:15: error: implicit declaration of function 'strdup'; did you mean 'strcmp'? [-Wimplicit-function-declaration]
6 | char *s = strdup("hello");
| ^~~~~~
| strcmp
tagged_strdup.c:6:15: error: initialization of 'char *' from 'int' makes pointer from integer without a cast [-Wint-conversion]
Compiling with -std=gnu17 (GCC’s default) or -D_POSIX_C_SOURCE=200809L makes strdup available.
Looking at the Bits of a Float
Writing a float and reading a uint32_t from the same union shows the raw IEEE 754 encoding. C allows this “type punning” through a union:
#include <stdio.h>
#include <stdint.h>
union FloatBits {
float f;
uint32_t bits;
};
void print_float_bits(float f) {
union FloatBits u = {.f = f};
/* IEEE 754 single: 1 sign bit, 8 exponent bits, 23 mantissa bits */
printf("%-6g = 0x%08X sign=%u exp=%3u mant=0x%06X\n", f, u.bits,
(unsigned)(u.bits >> 31), (unsigned)((u.bits >> 23) & 0xFF),
(unsigned)(u.bits & 0x7FFFFF));
}
int main(void) {
print_float_bits(1.0f);
print_float_bits(-1.0f);
print_float_bits(0.5f);
print_float_bits(3.14f);
return 0;
}
$ gcc -Wall -Wextra -std=c17 -o floatbits floatbits.c
$ ./floatbits
1 = 0x3F800000 sign=0 exp=127 mant=0x000000
-1 = 0xBF800000 sign=1 exp=127 mant=0x000000
0.5 = 0x3F000000 sign=0 exp=126 mant=0x000000
3.14 = 0x4048F5C3 sign=0 exp=128 mant=0x48F5C3
1.0 and -1.0 differ in exactly one bit, the sign. The exponent is stored with a bias of 127: 127 means 2⁰, so 0.5 = 2⁻¹ is stored as 126. 3.14 is 1.57 × 2¹, so its exponent is 128, and its mantissa is a long non-repeating pattern because 3.14 has no exact binary form. In C++, this union trick is undefined behavior and you’d use memcpy or std::bit_cast. In C it’s allowed.
Enums
An enum defines named integer constants. By default they count up from 0, and any value can be set explicitly:
#include <stdio.h>
enum Direction { NORTH, EAST, SOUTH, WEST };
enum HttpStatus { HTTP_OK = 200, HTTP_CREATED, HTTP_NOT_FOUND = 404, HTTP_GONE = 410 };
enum Permission {
PERM_READ = 1 << 0,
PERM_WRITE = 1 << 1,
PERM_EXECUTE = 1 << 2,
};
int main(void) {
printf("NORTH=%d EAST=%d SOUTH=%d WEST=%d\n", NORTH, EAST, SOUTH, WEST);
printf("HTTP_OK=%d HTTP_CREATED=%d HTTP_GONE=%d\n", HTTP_OK, HTTP_CREATED, HTTP_GONE);
unsigned perms = PERM_READ | PERM_WRITE;
printf("perms=%u write? %s execute? %s\n", perms,
(perms & PERM_WRITE) ? "yes" : "no", (perms & PERM_EXECUTE) ? "yes" : "no");
perms |= PERM_EXECUTE; /* grant */
perms &= ~PERM_WRITE; /* revoke */
printf("perms=%u after grant/revoke\n", perms);
enum Direction d = 42; /* no error: enums are just ints */
printf("d = %d, sizeof(enum Direction) = %zu\n", d, sizeof d);
return 0;
}
$ gcc -Wall -Wextra -std=c17 -o enums enums.c
$ ./enums
NORTH=0 EAST=1 SOUTH=2 WEST=3
HTTP_OK=200 HTTP_CREATED=201 HTTP_GONE=410
perms=3 write? yes execute? no
perms=5 after grant/revoke
d = 42, sizeof(enum Direction) = 4
HTTP_CREATED was never given a value and became 201: an unset constant is always one more than the one before it. Power-of-two constants can be combined as flags. 5 is READ | EXECUTE after write was revoked. The last line shows C’s weak spot: enum Direction d = 42 compiled without a warning, because an enum variable is just an int and C doesn’t check that the value matches a named constant.
Letting the Compiler Check Your switch
C doesn’t restrict enum values, but GCC does check a switch over an enum for missing cases:
#include <stdio.h>
enum Direction { NORTH, EAST, SOUTH, WEST };
const char *direction_name(enum Direction d) {
switch (d) {
case NORTH: return "North";
case EAST: return "East";
case SOUTH: return "South";
}
return "Unknown";
}
int main(void) {
for (int d = NORTH; d <= WEST; d++) printf("%d: %s\n", d, direction_name(d));
return 0;
}
$ gcc -Wall -Wextra -std=c17 -o wswitch wswitch.c
wswitch.c: In function 'direction_name':
wswitch.c:6:5: warning: enumeration value 'WEST' not handled in switch [-Wswitch]
6 | switch (d) {
| ^~~~~~
$ ./wswitch
0: North
1: East
2: South
3: Unknown
The warning names the exact missing value. This check is the main reason to leave the default: label out of a switch over an enum. A default quietly swallows any value you forgot, and the warning goes away. With no default, adding a new enumerator later makes the compiler point at every switch that needs updating.
typedef enum and the Count Sentinel
A final _COUNT member equals the number of real values. That makes it useful for sizing lookup tables and validating input:
#include <stdio.h>
typedef enum {
LOG_DEBUG,
LOG_INFO,
LOG_WARNING,
LOG_ERROR,
LOG_LEVEL_COUNT /* sentinel: equals the number of real levels */
} LogLevel;
static const char *const LEVEL_NAMES[LOG_LEVEL_COUNT] = {
[LOG_DEBUG] = "DEBUG", [LOG_INFO] = "INFO",
[LOG_WARNING] = "WARNING", [LOG_ERROR] = "ERROR",
};
void log_message(LogLevel level, const char *msg) {
if ((unsigned)level >= LOG_LEVEL_COUNT) {
printf("[?] (bad level %d) %s\n", (int)level, msg);
return;
}
printf("[%s] %s\n", LEVEL_NAMES[level], msg);
}
int main(void) {
log_message(LOG_INFO, "server started");
log_message(LOG_WARNING, "high memory usage");
log_message(LOG_ERROR, "connection refused");
log_message((LogLevel)9, "corrupted level");
printf("LOG_LEVEL_COUNT = %d\n", LOG_LEVEL_COUNT);
return 0;
}
$ gcc -Wall -Wextra -std=c17 -o loglevel loglevel.c
$ ./loglevel
[INFO] server started
[WARNING] high memory usage
[ERROR] connection refused
[?] (bad level 9) corrupted level
LOG_LEVEL_COUNT = 4
Since enums don’t restrict values, the bounds check is what stops (LogLevel)9 from reading past the end of LEVEL_NAMES. Casting to unsigned makes a negative level into a huge number, so one comparison rejects both too-small and too-large values. Designated initializers ([LOG_INFO] = "INFO") tie each name to its constant, so reordering the enum can’t silently shift the names out of step.
Practice
- Build a tagged union
Shapethat holds either a circle (radius) or a rectangle (width, height), and writearea()for it.
Solution
#include <stdio.h>
typedef enum { SHAPE_CIRCLE, SHAPE_RECT } ShapeKind;
typedef struct {
ShapeKind kind;
union {
struct { double r; } circle;
struct { double w, h; } rect;
};
} Shape;
double area(const Shape *s) {
switch (s->kind) {
case SHAPE_CIRCLE: return 3.14159265 * s->circle.r * s->circle.r;
case SHAPE_RECT: return s->rect.w * s->rect.h;
}
return 0.0;
}
int main(void) {
Shape shapes[] = {
{.kind = SHAPE_CIRCLE, .circle = {2.0}},
{.kind = SHAPE_RECT, .rect = {3.0, 4.5}},
};
for (int i = 0; i < 2; i++) printf("area = %.2f\n", area(&shapes[i]));
printf("sizeof(Shape) = %zu\n", sizeof(Shape));
return 0;
}
$ gcc -Wall -Wextra -std=c17 -o p1 p1.c
$ ./p1
area = 12.57
area = 13.50
sizeof(Shape) = 24
Each variant is its own small struct inside the union, so their field names can’t clash. Shape is 24 bytes: the tag plus padding (8), plus the larger variant, rect (16). The circle’s radius reuses the first 8 of those bytes.
- Use a union of
uint32_tanduint8_t[4]to detect whether your machine is little- or big-endian.
Solution
#include <stdio.h>
#include <stdint.h>
int main(void) {
union { uint32_t word; uint8_t bytes[4]; } u = {.word = 0x01020304};
printf("first byte in memory: 0x%02X -> %s-endian\n",
u.bytes[0], u.bytes[0] == 0x04 ? "little" : "big");
return 0;
}
$ gcc -Wall -Wextra -std=c17 -o p2 p2.c
$ ./p2
first byte in memory: 0x04 -> little-endian
The least significant byte, 0x04, is stored first, which is little-endian (x86, and ARM as normally configured). Network protocols use big-endian, which is why htonl/ntohl exist to convert between the two.
- Model a traffic light as an enum with a
LIGHT_COUNTsentinel, and writenext()so it cycles RED → YELLOW → GREEN → RED.
Solution
#include <stdio.h>
typedef enum { RED, YELLOW, GREEN, LIGHT_COUNT } Light;
Light next(Light l) {
return (Light)((l + 1) % LIGHT_COUNT);
}
int main(void) {
static const char *names[LIGHT_COUNT] = {"RED", "YELLOW", "GREEN"};
Light l = GREEN;
for (int i = 0; i < 4; i++) {
printf("%s -> ", names[l]);
l = next(l);
}
printf("%s\n", names[l]);
return 0;
}
$ gcc -Wall -Wextra -std=c17 -o p3 p3.c
$ ./p3
GREEN -> RED -> YELLOW -> GREEN -> RED
% LIGHT_COUNT wraps GREEN + 1 (3) back to RED (0). Because the sentinel updates automatically, adding a fourth light between YELLOW and GREEN needs no change to next().
- Add
WESTtodirection_namein the-Wswitchexample so it compiles with no warnings.
Solution
Add case WEST: return "West"; to the switch, then rebuild:
$ gcc -Wall -Wextra -std=c17 -o wswitch wswitch.c
$ ./wswitch
0: North
1: East
2: South
3: West
The warning is gone and all four directions have names. The trailing return "Unknown"; is still needed, since d could hold a value like 42 that matches no case.