C / STRUCTS, UNIONS AND ENUMS
Enums and why they beat magic numbers
Replace bare numeric literals with enum constants, size tables with a trailing COUNT member, and let switch coverage warnings find missing cases.
What you will learn
- Declare an enum and rely on numbering from 0, overriding only the members that are fixed
- Add a trailing COUNT member to size a lookup table and bounds-check an index
- Write a switch over an enum with no default so -Wall names the members you missed
- Prefix member names, because they live in the enclosing scope, not inside the enum
Understanding Enums and why they beat magic numbers
A magic number is a literal whose meaning lives only in the author's head: `if (level >= 2)` says nothing about what 2 is. The declaration `enum LogLevel { LOG_TRACE, LOG_DEBUG, LOG_INFO, LOG_WARN, LOG_ERROR };` creates a group of named integer constants, numbering them from 0, with every member that has no `=` taking the previous value plus one. Nothing is allocated for the names: the compiler substitutes the number at each use exactly as a literal would, so `LOG_INFO` costs no more at run time than 2 does.
One declaration gives you two separate things: a set of `int` constants and a new integer type. The constants are not scoped inside the enum the way a C++ `enum class` or a Java enum scopes them; they land in whatever scope the declaration sits in, so defining `RED` in two different enums in one file is a redeclaration error. That is why C code prefixes members with the enum's name. The type is only loosely checked: `enum LogLevel lv = 42;` compiles, because an enum object is just an integer type wide enough for the listed members and nothing stops you putting other values in it.
The gain over `#define LOG_INFO 2` is that the compiler knows the members belong to one set. A `switch` over an enum with no `default` label makes gcc's -Wswitch, which -Wall turns on, name every member you failed to handle, so adding a state produces a list of places to edit instead of a silent fallthrough. A trailing member such as `LOG_LEVEL_COUNT` stays correct as the enum grows, giving an array size and a range check for free, and the member names reach the debug information, so a debugger shows `LOG_WARN` where a #define could only ever show 3.
<stdio.h>
enum LogLevel {
LOG_TRACE,
LOG_DEBUG,
LOG_INFO,
LOG_WARN,
LOG_ERROR,
LOG_LEVEL_COUNT /* one past the last real level */
};
static const char *level_name(enum LogLevel lv)
{
static const char *const names[LOG_LEVEL_COUNT] = {
"TRACE", "DEBUG", "INFO", "WARN", "ERROR"
};
int i = (int)lv;
if (i < 0 || i >= LOG_LEVEL_COUNT)
return "???";
return names[i];
}
static void emit(enum LogLevel threshold, enum LogLevel lv, const char *msg)
{
if (lv >= threshold)
printf("[%s] %s\n", level_name(lv), msg);
}
int main(void)
{
enum LogLevel threshold = LOG_INFO;
emit(threshold, LOG_DEBUG, "cache warmed");
emit(threshold, LOG_INFO, "listening on port 8080");
emit(threshold, LOG_ERROR, "disk full");
printf("LOG_WARN is %d of %d levels\n", LOG_WARN, LOG_LEVEL_COUNT);
printf("level 9 prints as %s\n", level_name((enum LogLevel)9));
return 0;
}
An enum turns a group of related numbers into named constants plus a type the compiler can reason about, while performing no range checking of its own.
Worked examples
Pinned values, continuation and aliases
Shows how members without an explicit value continue from the last one, and that two names may share a value.
<stdio.h>
enum HttpStatus {
HTTP_OK = 200,
HTTP_CREATED, /* 201 */
HTTP_BAD_REQUEST = 400,
HTTP_NOT_FOUND = 404,
HTTP_METHOD_NOT_ALLOWED, /* 405 */
HTTP_DEFAULT = HTTP_OK
};
int main(void)
{
printf("%d %d %d\n", HTTP_OK, HTTP_CREATED, HTTP_METHOD_NOT_ALLOWED);
printf("HTTP_DEFAULT == HTTP_OK: %s\n",
HTTP_DEFAULT == HTTP_OK ? "yes" : "no");
return 0;
}
Example explained
Line 1HTTP_CREATED has no `=`, so it is the previous value plus one, 201.
Line 2HTTP_METHOD_NOT_ALLOWED continues from 404 the same way and becomes 405, which is exactly why inserting a member above it renumbers it silently.
Line 3HTTP_DEFAULT = HTTP_OK is legal because a member's value may be any integer constant expression, including an earlier member, and duplicate values are allowed.
Line 4Passing these to printf with %d is correct because in C a member of an enum has type int, not the enum type.
Switch coverage and the missing range check
Demonstrates that a default-less switch documents every valid member while the enum type itself still accepts any integer.
<stdio.h>
enum Signal { SIG_RED, SIG_AMBER, SIG_GREEN };
static int seconds_for(enum Signal s)
{
switch (s) {
case SIG_RED: return 30;
case SIG_AMBER: return 4;
case SIG_GREEN: return 25;
}
return -1; /* only reachable for a value outside the enum */
}
int main(void)
{
enum Signal s = SIG_AMBER;
printf("amber lasts %d s\n", seconds_for(s));
s = (enum Signal)7;
printf("bogus value %d -> %d\n", (int)s, seconds_for(s));
return 0;
}
Example explained
Line 1The switch has no default label, so with -Wall gcc reports any member left unhandled; adding SIG_FLASHING to the enum would warn here immediately.
Line 2The `return -1` after the switch is still required, since the switch covers the three named members but the parameter can hold other values.
Line 3`s = (enum Signal)7;` compiles without complaint because an enum object is an integer type and C performs no range check on it.
Line 4That call therefore falls past all three cases and returns -1, which is why validation has to happen where the value enters the program.
Important notes
The size of an enum type is implementation-defined (gcc uses int unless you pass -fshort-enums), while a member of it has type int, so never write an enum value straight into a file or socket; convert it to a fixed-width type such as uint16_t first.
Sizing a table as `names[LOG_LEVEL_COUNT]` ties it to the enum, but a member you forget to name becomes a null pointer rather than a compile error, so keep the table beside the enum and bounds-check before indexing.
Common mistakes
Assuming members are scoped to their enum: `enum Color { RED, GREEN };` and `enum Light { RED, AMBER };` in one file gives a redeclaration error for RED, because both names land in the same scope. Prefix them as COLOR_RED and LIGHT_RED.
Inserting a new member in the middle of an enum whose numbers were already written to a file or sent over a network. Every member after it shifts by one, so old data decodes as the wrong member with no error anywhere.
Writing `enum Perm { PERM_READ = 1, PERM_WRITE, PERM_EXEC };` for bit flags. Auto numbering gives 1, 2, 3, so PERM_EXEC overlaps PERM_READ|PERM_WRITE and the OR-and-test logic quietly reports the wrong permissions; flag values must be spelled out as 1, 2, 4, 8.
Try it yourself
Change, predict, then run
Declare `enum Direction { DIR_NORTH, DIR_EAST, DIR_SOUTH, DIR_WEST, DIR_COUNT };` and write `const char *dir_name(enum Direction d)` that checks the value against DIR_COUNT before indexing a names table. Call it with DIR_SOUTH and with `(enum Direction)9` and confirm the second returns your fallback string instead of garbage.
Open the C workspaceCheck your understanding
A function that took `int state` is changed to take `enum State state`, and the file is compiled with gcc -Wall. What does the compiler now do for you that it did not before?
- Warn when a switch over state omits one of the enum's members
- Reject a call such as f(99) at compile time because 99 is not a member
- Guarantee that the value received at run time is one of the listed members
- Store state in the smallest integer type that fits all of the members
Show answer
Naming the set lets -Wswitch, enabled by -Wall, list the members a default-less switch fails to handle, which is the practical payoff of the change. Rejecting f(99) is the tempting answer, but an enum in C is an integer type with no range checking at compile time or run time, so out-of-range values pass through silently and validating them stays your job. The storage size is implementation-defined, not necessarily the smallest type that fits.