Skip to content
academia.sh

Lesson 12 / 13

Structs, Unions and Enumerations

A struct's fields being laid out at compile time, a union sharing the same memory, and enumeration constants being compile-time constants; showing with a tag field that the language does not track which member of a union is valid.

Published Updated

In this lesson

  • Measures that a struct's fields are laid out at compile time and that assignment copies every field
  • Safely shows with a tagged read that a union shares the same memory
  • Confirms with an example that enumeration constants are compile-time constants
Contents

The Arrays and Strings lesson measured how values of the same type are ordered within an array. This lesson measures how values of different types are gathered under a single name: a struct places different types side by side, a union has the same memory shared by different interpretations, an enumeration produces named constants — all three mechanisms decided at compile time from the declaration written in the source.

The Programming Fundamentals course (M01/K02) already established record-like data grouping with Python; there, fields were names inside a dictionary or object, addable and removable at runtime. C’s counterpart is measured here: which fields a struct will carry, in what order those fields sit in memory, and an enumeration constant’s value — all fixed at compile time from the declaration written in the source, without the program ever running. This is the last stop on the course’s measurement axis: the question opened in The Place of C lesson, that a source turns into a program by passing through four stages, closes here by asking at which stage three new mechanisms are decided.

Struct: Fields Laid Out at Compile Time

A struct declaration fixes every field’s name and type in the source; after this declaration, every variable of that struct type carries the same field layout. While an array, as measured in the Arrays and Strings lesson, orders values of the same type, a struct gathers values of different types under a single name — like a point’s x and y coordinates, values that carry meaning together but can be of different types. Assigning one struct variable to another copies every field at once.

// struct.c — a struct's fields are laid out at compile time, assignment copies every field
#include <stdio.h>

struct Point {
    int x;
    int y;
};

int main(void) {
    struct Point a = {3, 4};
    struct Point b = a;
    b.x = 99;

    printf("%s %d\n", "a.x", a.x);
    printf("%s %d\n", "a.y", a.y);
    printf("%s %d\n", "b.x", b.x);
    printf("%s %d\n", "b.y", b.y);
    return 0;
}
a.x 3
a.y 4
b.x 99
b.y 4

BS12. The struct Point declaration fixes at compile time that every Point variable will carry an x and a y field, and these fields’ names and types — this struct decides which fields it will carry not at runtime, but in the source. The assignment b = a copies both of a’s fields into b; the subsequent change to b.x then affects only b’s own copy, a.x is still 3. This shows how the parameter-copying rule measured in the previous lesson works for a struct: when a struct variable is assigned to another or passed to a function, what is transferred is a copy of every field — unlike an array, a struct does not decay into the address of its first element when passed to a function, it is copied whole, with all its fields.

The order of fields in memory is also determined by the declaration order in the source: struct Point‘s x field is always laid out before y, because the field written first in the declaration comes first. A struct can also hold another struct as a field — like a struct Line { struct Point start; struct Point end; }; carrying struct Point fields — and this nesting carries the same rule in layers: each inner struct’s own fields are laid out, according to declaration order again, inside the outer struct’s own field. This layout decision, just like an array’s element count, is fixed by the compiler without the program ever running — which field sits at which address is determined purely from the declaration itself, without ever looking at data at runtime. No real address or byte offset is printed in this course, but that it is a consequence of declaration order shows why a struct definition determines both the data’s meaning and its layout in memory at the same time.

Union: Sharing the Same Memory

A union defines several fields in the same memory area — each field corresponds to the same address, under a different type interpretation. The union itself does not track which field is “valid”; this information must be carried separately, with a tag field.

// union.c — a union sharing the same memory, used safely with a tagged read
#include <stdio.h>

enum Kind { INTEGER, DECIMAL };

struct TaggedValue {
    enum Kind kind;
    union {
        int whole;
        float decimal;
    } value;
};

static struct TaggedValue make_integer(int v) {
    struct TaggedValue t;
    t.kind = INTEGER;
    t.value.whole = v;
    return t;
}

static struct TaggedValue make_decimal(float v) {
    struct TaggedValue t;
    t.kind = DECIMAL;
    t.value.decimal = v;
    return t;
}

static void print_value(struct TaggedValue t) {
    if (t.kind == INTEGER) {
        printf("%s %d\n", "read as an integer", t.value.whole);
    } else {
        printf("%s %.2f\n", "read as a decimal", t.value.decimal);
    }
}

int main(void) {
    struct TaggedValue first = make_integer(42);
    struct TaggedValue second = make_decimal(3.5f);
    print_value(first);
    print_value(second);
    return 0;
}
read as an integer 42
read as a decimal 3.50

BS13. whole and decimal inside the union share the same memory area — once one is written, the other’s old value becomes meaningless, because both read the same bytes through a different type lens. make_integer writes only value.whole, make_decimal only value.decimal; print_value always looks at the kind field to learn which interpretation is valid and reads only that field. Thanks to this discipline, the lesson never reads the field that was not written — first always reads whole, second always reads decimal. The union inside TaggedValue (the value field) does not know on its own which member was last written; carrying this information is the kind field’s job, and keeping that field correct is the source writer’s job.

How much space a union will occupy is also fixed at compile time: the measure the standard sets is that enough space is allocated to carry the union’s largest field — because any field must be writable at any moment. Since no real size is printed in this course, this lesson does not measure this, but the difference between how the two declarations grow must be noted: a struct lays its fields one after another, a union overlaps them. This is why adding a field smaller than the largest to a union generally does not grow the total space; adding one to a struct grows it unless the field fits into existing padding. In both cases, alignment has the final word: the standard states that unnamed padding may appear at the end of both a struct and a union, so the total space cannot be read directly from the fields’ sizes.

Enumeration: A Compile-Time Constant

An enumeration declaration produces named integer constants; these constants can be used everywhere a constant expression written in the source can be used — for instance, to determine an array’s length.

// stage.c — enumeration constant (excerpt), used as a compile-time constant
#include <stdio.h>

enum Color { RED = 1, GREEN = 2, BLUE = 4 };

int main(void) {
    printf("%s %d\n", "enumeration constant", (int)BLUE);

    int colors[BLUE];
    printf("%s %d\n", "element count of the array built with the constant",
           (int)(sizeof colors / sizeof colors[0]));
    return 0;
}
enumeration constant 4
element count of the array built with the constant 4

The value of BLUE comes from the constant 4 written in the enum Color declaration, and this value is known without the program ever running. The declaration int colors[BLUE]; confirms this: BLUE is an expression of the kind constant folding, as measured in the Arrays and Strings lesson, accepts — had it been a variable or a function call, the array’s size could not have been determined at compile time. The colors array’s element count, without any real size being printed, is again measured with the sizeof ratio and comes out 4 — the exact same number as BLUE, because both derive from the same compile-time constant.

Explicit values like RED = 1, GREEN = 2, BLUE = 4 in the enum Color declaration are not mandatory; an enumeration can also be declared with no value written at all, in which case the first name starts from 0 and each subsequent name takes a value one greater than the one before. The declaration here writes its values explicitly, because RED, GREEN and BLUE represent separate bit positions (1, 2, 4), which lets colors be combined with a bitmask — an expression like RED | BLUE can produce the value 5, representing both colors at once. This usage, too, is a property fixed at compile time: which constant corresponds to which bit position comes from the = sign written in the source, with no data at runtime looked at.

The Bounding Measurement

A union does not track on its own which of its fields was last written — carrying this information is not the language’s job, but the source writer’s. The TaggedValue struct above resolves this with a tag field: kind tells which member of value is valid, and print_value always looks at kind first.

If this discipline were abandoned — if print_value never looked at kind and, say, always read value.decimal — then for first (which make_integer had written only value.whole into), the byte pattern read would have been written as an int and read as a float. This shows that the only thing a union guarantees is sharing the same address, not which type interpretation is meaningful — the result of a mismatched interpretation is an unspecified bit pattern, and this course’s general rule does not try it in a code block. The defined counterpart set alongside it is the tagged read above itself: every read learns which member was last written to that union from an independent field (kind) and reads only that member, and this measurement produces the same two lines every time it is run.

For the tagged read to work, the tag must always be updated together with the corresponding write — both make_integer and make_decimal set kind before writing their own field. If a third function wrote value.whole and forgot to update kind, print_value reading that variable would still look at the stale kind value and read the wrong member — a union can neither produce nor prevent this inconsistency on its own, because the union itself only provides memory sharing, not staying in sync with a tag. This reflects a deliberate design decision in C’s union mechanism: the language does not take on the cost of tracking field selection — it does not separately record which field is active on every write — and leaves this responsibility, just as with array length information, entirely to the source writer.

This also shows that a struct and a union are two complementary mechanisms: a struct allocates separate memory for each field and makes all of them valid at once, while a union shares a single memory area and makes only one valid. TaggedValue combines the two — the outer struct carries both the tag and the shared field, and thanks to the tag field, the information a union does not track ends up kept at the struct level.

This combination forms the basis of a widespread pattern in C called a “tagged union”: a source writer who wants to model a value carrying only one of several possible shapes uses these three mechanisms — struct, union and enumeration — together. The enumeration expresses which shape is valid with named constants; the union has those shapes share a common memory area; the struct carries the two together as a single value. The three mechanisms measured in this lesson are therefore not three independent subjects, but three components of the same pattern.

Summary

  • A struct’s fields, together with their names and types, are fixed at compile time; when a struct variable is assigned to another, every field is copied at once.
  • A union defines several fields at the same memory address; the language does not track which field was last written, and the source writer must carry this information separately (with a tag field).
  • Enumeration constants (like BLUE inside enum Color) are compile-time constants; they can be used everywhere a constant expression is required, such as determining an array’s length — class compiler.
  • Reading a member not written into a union (untagged, under a wrong type assumption) reinterprets the bytes as another type; C does not forbid this, but the value of bytes not corresponding to the last-written member is unspecified, and if the pattern read is a trap representation, the behavior is undefined. This is not run in this course; the defined counterpart is the tagged read.
  • A struct keeps every field in separate memory and makes all of them valid at once, while a union shares a single memory area and makes only one valid — two complementary mechanisms.
  • When used together, enumeration, union and struct form the “tagged union” pattern: the enumeration names which shape is valid, the union shares the shapes’ memory, the struct combines the two into a single value.

Next Step

Every decision measured in this topic stayed within a single run of a program: a type’s range, a name’s scope, an operator’s choice, a branch’s chosen path, a call’s bound body, an array’s length, a union’s valid field — all of it could be read either from the source or from that run’s own data. The Input and Output lesson adds one last question to this ground: at which stage is the data a program takes in from outside, or writes out to the outside, interpreted? Every placeholder in a format string must match the type of its corresponding argument, but the compiler can only catch this match under certain conditions — the language itself does not guarantee it. In the TaggedValue example measured in this lesson, keeping the tag correct was the source writer’s job; in the next lesson, keeping the format string consistent with its arguments will likewise be the source writer’s responsibility.

The seven lessons measured in this topic each touched every one of the four classes (preprocessor, compiler, linker, runtime) at least once: Data Types and Operators at the compiler, Control Flow at runtime, Variables and Functions at the linker, Arrays and Strings at both, and this lesson landed at the compiler once again. The closing lesson gathers the decisions these seven lessons measured into a single table, and adds input and output’s own share to that table — format strings being interpreted at runtime.

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ ↵ Esc navigate · open · close