CS 3 (Spring 2024) Enumerations and switch statements

Enumerating values

Sometimes when programming you have a type which can be a small, finite number of values. For example, you might have noticed that in C, you need to #include <stdbool.h> to have access to the bool type and the true and false values.

However, suppose you wanted to define this type yourself. The syntax for this would look like:

typedef enum bool {
    false,
    true,
} bool_t;

In other words, we’re enumerating the values that bool can take on: false, and true. true and false here are called “bool’s variants.”

It turns out that “enumerate” is exactly the right word: each of these constants is assigned an integer value, starting from 0 and going forward. Correspondingly, this defines false = 0 and true = 1. Note that this means the definition of an enum is order dependent. enum { true, false } would be an incorrect definition for bool.

An important thing to keep in mind, however, with enum types is that they are actually just int. Notably,

bool x = 3;
printf("%d\n", x);

would print 3, even though 3 is not one of the enumerated values. (Note that this is not actually true of the bool type in stdbool.h, which is defined using the _Bool keyword. The actual bool type can only take values 0 and 1 and assigning any other value gets converted to 1.)

Enums, then, are effectively a collection of an alias for the int type and named constants for that type. However, this bundling is good for communicating intent.

If you were implementing a chess engine, you might want to enumerate the chess pieces:

typedef enum piece_type {
    PIECE_NONE,
    PIECE_PAWN,
    PIECE_KNIGHT,
    PIECE_BISHOP,
    PIECE_ROOK,
    PIECE_QUEEN,
    PIECE_KING
} piece_type_t;

(Convention is that enum variants, like constants, should be written in SCREAMING_SNAKE_CASE. You generally also want a common prefix to indicate that they are part of the same collection and prevent namespace issues.)

And perhaps you also want a nice way to refer to the colors:

typedef enum color {
    COLOR_WHITE,
    COLOR_BLACK
} color_t;

Assigning values

In chess, pieces are commonly assigned numerical “material values” and used to evaluate who has the advantage. To help with this, enums do have a couple more tricks up their sleeve. Since they’re just integers, you can do arithmetic on them!

And furthermore, they can be given values.

typedef enum piece_type {
    PIECE_NONE, // the first value is 0 if not given an explicit number
    PIECE_PAWN, // subsequent values increase by 1, so this is 1
    PIECE_KNIGHT = 3,
    PIECE_BISHOP = 3,
    PIECE_ROOK = 5,
    PIECE_QUEEN = 9,
    PIECE_KING, // similarly, this is 10 since it follows 9
} piece_type_t;

typedef enum color {
    COLOR_WHITE = 1,
    COLOR_BLACK = -1
} color_t;

If we represented the board as:

typedef struct piece {
    piece_type_t type;
    color_t color;
} piece_t

piece_t board[64];

We could then tally up the total material as:

int32_t tally_material(piece_t board[64]) {
    int32_t tally = 0;
    for (size_t i = 0; i < 64; i++) {
        tally += board[i].type * board[i].color;
    }
    return tally;
}

In the above code snippet, we simply use type and color as integers.

Working with enums

Internally, we represent our pieces using integers, because computers are very efficient at working with integers. Humans, on the other hand, are much better at working with text. Suppose that, for debugging, we want to be able to print the name and color of a piece.

We might want to define functions which take in a color or piece type and give a string representation. It’s easy enough to write this for the color:

char *color_name(color_t color) {
    if (color == COLOR_WHITE) {
        return "white";
    } else {
        return "black";
    }
}

However, if we start writing this for the type, we end up with something like:

char *piece_type_name(piece_type_t type) {
    if (type == PIECE_NONE) {
        return "none";
    } else if (type == PIECE_PAWN) {
        return "pawn";
    } else if (type == PIECE_KNIGHT) {
        return "knight";
    } else // and so on
}

This would work, but it’s verbose, clunky, feels like there should be a better way.

Enter, switch

A switch-case statement is, in many ways, complementary to an enum. We could use it to write our function as so:

char *piece_type_name(piece_type_t type) {
    switch (type) {
        case PIECE_NONE: {
            return "none";
        }
        case PIECE_PAWN: {
            return "pawn";
        }
        case PIECE_KNIGHT: {
            return "knight";
        }
        case PIECE_BISHOP: {
            return "bishop";
        }
        case PIECE_ROOK: {
            return "rook";
        }
        case PIECE_QUEEN: {
            return "queen";
        }
        case PIECE_KING: {
            return "king";
        }
        default: {
            assert(false && "Invalid piece type!\n");
        }
    }
}

Switch statements take an integer-like value (char, int, an enum, any (u?)int[N]_t, etc.) as their argument (in this case, the argument is type). They then have some number of “cases.” You then make cases with case [CASE]: where [CASE] is an integer-like constant. The cases of a switch-case statement must be constants, literals, or enum variants. They cannot be variables. You cannot operate a switch statement on strings, but you can operate one on a single character and cases can be character literals like case 'a':.

Finally, there is an optional default case which handles any value which doesn’t match the other values—think of it like an else branch in an if-else chain. When you are exhaustively matching on every variant of an enum, it is good practice to add a default case which crashes (e.g., with assert(false), since this will give you a line number if it’s ever hit) and potentially prints the value it received. This will help you debug in the event you mess something up.

It’s worth noting that case statements do not strictly need to be followed by {}. Doing so will help avoid some surprises related to local variables and generally makes your code more readable, but there are cases where it makes sense to avoid it. The most common example when you’d want to avoid them is when intentionally utilizing fallthrough.

void print_it_lazy(uint32_t x) {
    switch (x) {
        case 0:
        case 1:
        case 2: {
            printf("%s\n", "It's less than 3!");
            break;
        }
        case 3: {
            printf("%d\n", 3);
            break;
        }
        default: {
            printf("%s\n", "I don't know numbers that big!");
        }
    }
}

Here, since we didn’t put break after the 0 or 1 cases, each of them will “fallthrough” to the 2 case.

You may also choose to omit curly braces when your statements don’t involve any variable declarations.

void print_it_also_fine(uint32_t x) {
    switch (x) {
        case 0:
        case 1:
        case 2:
            printf("%s\n", "It's less than 3!");
            break;
        case 3:
            printf("%d\n", 3);
            break;
        default:
            printf("%s\n", "I don't know numbers that big!");
    }
}

However, you should be cautious. Consider the following code:

void broken(uint32_t x) {
    switch (x) {
        case 0:
        case 1:
        case 2:
            int y = x;
            printf("It's less than 3! It's %d.\n", y);
            break;
        case 3:
            printf("%d\n", 3);
            break;
        default:
            printf("%s\n", "I don't know numbers that big!");
    }
}

If you tried to compile it, you would get error: expected expression on the int y = x line. This is because a case label, due to quirks of the C language, cannot be immediately followed by a variable declaration. Curly braces will solve this issue.

Finally, there’s a shorthand for a sequence of consecutive cases:

void print_it_also_fine(uint32_t x) {
    switch (x) {
        case 0 ... 2:
            printf("%s\n", "It's less than 3!");
            break;
        case 3:
            printf("%d\n", 3);
            break;
        default:
            printf("%s\n", "I don't know numbers that big!");
    }
}

The syntax case n ... m: is equivalent to

case n:
case n + 1:
...
case m - 1:
case m:

It can also be used for characters—e.g., case 'a' ... 'z': would match any lowercase letter—or enums (though you should make sure that the enums have consecutive numerical values, rather than appearing consecutively in the definition).

Recap

An enum defines a type alias for int and a collection of constants. They are useful when you want a type representing something which takes on a concrete list of values. You can explicitly assign values to enum variants or omit them, in which case they will be 0 if they’re the first variant or the previous variant plus 1 if they are not. Enum types should be named like types, in snake case and, for the purpose of this class, suffixed with a _t when they are typedefed. Enum variants should be named in SCREAMING_SNAKE_CASE like constants and prefixed with a common prefix. The syntax is:

typedef enum the_name {
    VARIANT_1, // = 0
    VARIANT_2 = 5,
    VARIANT_3 // = 6
} the_name_t;

A switch-case statement allows you to select a case based on the value of the switch’s argument (which is a numeric type), jumping to that code. Control flow then continues directly down from the case jumped into, falling through to cases below it. To avoid this, which you usually want to, a break or return statement should be placed at the bottom of each case. Optionally, the switch statement has a default case which handles everything not matching a case, and it is good practice to use it to catch unexpected values. switch syntax is fairly convoluted, but the below demonstrates most of the things you can do:

void example(uint32_t x) {
    switch (x) {
        case 0 ... 2:
        case 3: {
            int y = x;
            printf("It's less than 4! It's %d.\n", y);
            break;
        }
        case 4:
            printf("%d\n", 4);
            break;
        default:
            printf("%s\n", "I don't know numbers that big!");
    }
}

It is good practice to use curly braces unless you have a reason not to, such as being clearer when intentionally falling through to the next case (especially in empty cases like the example above).