Skip to content
academia.sh

Lesson 08 / 12

Enums

A Shape value can be constructed in three separate forms — carrying no data, carrying one number, carrying two numbers — and all three are counted under the same type; constructing a variant is an expression, the enum declaration itself is an item. The limiting measurement shows that variant count must not be confused with state count.

Published Updated

In this lesson

  • Shows that constructing a variant is an expression and an enum declaration is an item
  • Explains that variants carrying data and variants carrying none are counted under the same type
  • Distinguishes, with an example, the difference between the number of variants and the number of states a value can carry
Contents

The Structs lesson measured that a Point always carries both x and y: a struct is a value bound to a fixed set of fields, with a single shape. But the data a program has to model often takes more than one possible shape in an “either this or that” way — a shape that has not been drawn at all, a shape that is a circle, or a shape that is a rectangle. A struct alone cannot meet this; the enum, the subject of this lesson, fills exactly that gap.

An enum sets up a value being able to carry one of several separate variants under the same type. The same question from the previous lesson applies here too: does constructing a variant produce a value when it runs, or does the enum declaration itself produce something? The answer follows the same order as structs, but this time the difference between variant count and the number of states a value can carry is measured as well.

What separates an enum from a set of constants that do not serve the same function is that each variant can optionally carry its own data. Three separately defined constants — say EMPTY, CIRCLE, RECTANGLE — are three independent names; there is no guarantee the compiler gives for the question “which one is selected.” An enum, however, builds a single type, and every value of that type knows within itself which variant it currently carries. The first thing this lesson measures is how this single-typedness looks on the expression/statement/item axis.

The Declaration Is Still an Item, Constructing a Variant Is an Expression

The line enum Shape { Empty, Circle(f64), Rectangle(f64, f64) }, just like the struct declaration in the previous lesson, produces nothing when it runs. This line introduces the compiler to a type with three separate variants; it is itself an item. By contrast, the syntax Shape::Circle(2.0) produces a value when it runs — not an integer, but a Shape value carrying the Circle variant with 2.0 inside it. This is an expression; its result can be bound to a variable, appended to a list, passed to a function.

DM5. — The claim that “an enum declaration by itself produces nothing” is verified here, just as with the struct example in the previous lesson, not by a separate run but by the structure of the program below: the line enum Shape { ... } sits before main, and no print call directly triggers that line; every visible output comes from the lines where variants are constructed and used.

Empty, Circle, and Rectangle are three separate variant names, and all three are listed, separated by commas, inside a single enum Shape { } declaration. The difference between Circle(f64) and Rectangle(f64, f64) is the number of types inside the parentheses: one carries a single f64, the other carries two f64s; Empty does not even have parentheses, because it carries no data at all. These three forms — no parentheses, one field, several fields — cover the full range of variant forms an enum declaration allows.

The run below constructs four values of type Shape and prints which variant each one belongs to and what its area is.

// shape_enum.rs — an enum declaration is an item, constructing a variant is an expression
use std::collections::BTreeSet;

enum Shape {
    Empty,
    Circle(f64),
    Rectangle(f64, f64),
}

fn variant_name(s: &Shape) -> &'static str {
    match s {
        Shape::Empty => "Empty",
        Shape::Circle(_) => "Circle",
        Shape::Rectangle(_, _) => "Rectangle",
    }
}

fn area(s: &Shape) -> f64 {
    match s {
        Shape::Empty => 0.0,
        Shape::Circle(radius) => std::f64::consts::PI * radius * radius,
        Shape::Rectangle(width, height) => width * height,
    }
}

fn main() {
    let shapes = vec![
        Shape::Empty,
        Shape::Circle(2.0),
        Shape::Circle(5.0),
        Shape::Rectangle(3.0, 4.0),
    ];

    println!("{:<20}: {}", "carried value count", shapes.len());
    for (i, s) in shapes.iter().enumerate() {
        println!(
            "{:<20}: {:<11} area={:.2}",
            format!("shapes[{}] variant", i),
            variant_name(s),
            area(s)
        );
    }

    let distinct_variants: BTreeSet<&str> = shapes.iter().map(variant_name).collect();
    println!("{:<20}: {}", "distinct variants", distinct_variants.len());
}
carried value count : 4
shapes[0] variant   : Empty       area=0.00
shapes[1] variant   : Circle      area=12.57
shapes[2] variant   : Circle      area=78.54
shapes[3] variant   : Rectangle   area=12.00
distinct variants   : 3

shapes was built with four separate calls, and each call was an expression: Shape::Empty is a Shape value even though it carries no data at all; Shape::Circle(2.0) reaches the same result carrying one decimal number, Shape::Rectangle(3.0, 4.0) reaches it carrying two decimal numbers. All three could be pushed into a Vec<Shape> — for the compiler they are all the same type, Shape.

The variant_name and area functions are used here as a tool: both use match to read which variant a value carries and produce a result specific to that variant. match’s own rules and the exhaustiveness of its arms are the subject of the next lesson; here it is only visible that reaching the data from a data-carrying variant (Circle, Rectangle) requires a route — the data-free Empty variant has no field to read, so the area function gives it 0.0 directly.

The numbers area gives make this concrete: Circle(2.0)’s area is π×22≈12.57\pi \times 2^2 \approx 12.57, Circle(5.0)’s area is π×52≈78.54\pi \times 5^2 \approx 78.54. Both use the same formula — the square of the radius times pi — but because the carried number differs, so does the result. Rectangle(3.0, 4.0)’s area is 12.00; this variant carries not one number but two, and the area function reads and multiplies both. The Empty variant carries no number at all, so there is no area to compute for it — area covers that case in its own arm with 0.0.

To count the variant names, an ordered set (BTreeSet) was used here rather than a hash set, even though only .len() is read and a set’s element count does not depend on traversal order — for this single line, a hash set would give the same result. The choice here comes not from necessity but from a habit that carries through the course: whenever a structure whose output does not change from run to run is needed, the ordered structure is the default choice. When this choice is required and when it is merely for consistency is the subject of the Ordered and Set Structures lesson.

Variants Carrying Data and Variants Carrying None Are Counted Under the Same Type

One of Shape‘s three variants (Empty) carries no data at all; two (Circle, Rectangle) do. In the run above, shapes.len() counted all four; the Shape type holds all of them with equal weight. This is the fundamental property that separates Rust’s enum from a list (“which option?”): it can carry not just a name but optionally a payload as well. A data-free variant is not a special case of a data-carrying variant whose payload happens to be zero — both are equally valued members of the same enum declaration.

This equality is also why a collection like Vec<Shape> is possible in the first place. A collection carries a single type; if Empty, Circle, and Rectangle were three separate types, holding all three in the same list would not be possible. enum solves this by combining them under a single type: the actual variant a Shape variable carries is stored in memory at runtime, but at compile time Shape is seen as a single type.

DM6. — The claim that “variants carrying data and variants carrying none are counted under the same type” is measured here by all four being able to go into a single Vec<Shape>: if the variants were counted as different types, the compiler would reject the fourth item in shapes (Rectangle) as a different type from the first (Empty). The compile succeeding cleanly is, together with the carried value count : 4 line counting all four, direct proof of this equality.

Variant names are not written alone but together with the type name: Shape::Circle(2.0), not Circle(2.0). What requires this prefix is that the same name can be used as a variant in more than one enum — if another type also had a variant named Circle defined, which type’s Circle was being referred to would be ambiguous without the Shape:: prefix. This is a different point from constructing a struct value (Point { x: 3, y: 4 }): in struct-construction syntax the type name is already at the front and no repeated prefix is needed, but in variant-construction syntax the type name and the variant name are two separate names, so a :: is placed between them.

This single-typedness is made possible by every Shape value knowing within itself which variant it carries: the variant_name function reads exactly this piece of information; the area function both reads which variant it is and, based on that, accesses different fields. There is no such distinction in a struct, because all of a struct’s fields are always present at the same time — a Point value never falls into a “only has x right now” state. enum reverses this: a value has the fields of only one variant at any given time, the fields of the other variants do not exist at all for that value.

This does not mean the value Shape::Rectangle(3.0, 4.0) also carries a radius field at the same time — that value carries only width and height, the Circle variant’s field is entirely separate from it. Each arm of the match expression in the area function can only reach the fields of its own variant; inside the Shape::Circle(radius) => ... arm, reaching a width name is not possible, because that arm only runs when the Circle variant matches, and there is no width at that point.

This does not touch on the question of how many bytes are allocated in memory — the lesson does not print such a number — it only measures which fields are accessible in which state. Accessibility is a source-level rule expressed by match’s arm structure: only the fields of the variant a given arm matches enter that arm.

Limiting Measurement: Variant Count Is Not State Count

Shape carries three variants: Empty, Circle, Rectangle. But “three variants” and “three possible values” are not the same thing. Empty has exactly one state it can carry — itself. Circle, however, carries an f64, and the number of values an f64 can take is uncountably large. The same variant, with different radii, can produce infinitely many different states.

// same_variant_different_state.rs — variant count is not state count
enum Shape {
    Circle(f64),
}

fn main() {
    let small = Shape::Circle(2.0);
    let large = Shape::Circle(5.0);

    let Shape::Circle(r1) = &small;
    let Shape::Circle(r2) = &large;

    println!("{:<24}: {}", "small circle radius", r1);
    println!("{:<24}: {}", "large circle radius", r2);
    println!("{:<24}: {}", "same variant", true);
    println!("{:<24}: {}", "same state", r1 == r2);
}
small circle radius     : 2
large circle radius     : 5
same variant            : true
same state              : false

small and large both carry the Circle variant — the answer to the “same variant” question is true. But they do not carry the same state: one has radius 2.0, the other 5.0 — the answer to the “same state” question is false. The “three separate variants” count measured in the previous run does not say how many different states a data-carrying variant like Circle can appear in.

DM7. — This limiting measurement is done here by constructing two separate Shape::Circle values in a single code block and printing side by side that both come from the same variant (true) but carry a different radius (the result of r1 == r2 is false). “Variant count” can be found by reading the enum declaration from the source (three lines, three variants), while “state count” can only be found by looking at which values are produced while the program runs; the two answer different questions.

This distinction disappears for a variant that carries no data. Empty has exactly one state, because it carries no field at all — variant count and state count coincide there. The gap opens only for data-carrying variants, and from the moment it opens, state count becomes dependent on the type of the data carried: a variant carrying a bool has two states, a variant carrying an f64 has, in practice, an unbounded number of states.

This holds even for a single-field variant; as the field count grows, the state count grows multiplicatively. Rectangle(f64, f64) carries two separate f64s — width and height can vary independently of each other, so this one variant’s state space is not one-dimensional but a two-dimensional infinity. The run above only showed Rectangle(3.0, 4.0), but the declaration Rectangle(f64, f64) written in the source allows for far more than that: Rectangle(1.0, 1.0), Rectangle(0.5, 100.0), and countless other pairs all belong to the same variant, but none of them share the same state.

This is why the answer to “how many states” is given not by looking at the enum declaration, but by looking at the types of its fields. A data-free variant adds exactly 1 to the state count; a variant carrying a type with a fixed number of separate values (for example, another enum that itself has a limited number of variants) contributes as many states as that type has; a variant carrying an unbounded type like f64 or String makes the state count unbounded. The three-line enum Shape { ... } declaration in the source shows none of this anywhere — all three are written in the same form, as a variant name.

This distinction has a practical consequence: the correctness of code that handles a Shape value is measured by the question “were all three variants handled?” — not by the question “were all infinite states tested?” If the area function gives the correct result for Circle(2.0), it gives the correct result for Circle(5.0) too, because both are processed in the same arm; there is no need to test every single radius separately. The compiler’s exhaustiveness check also operates exactly at the variant level, not the state level — this boundary already decides in advance what the “exhaustiveness” concept measured in the next lesson does and does not guarantee.

This lesson measured how many separate forms a Shape value can take and how those forms are constructed in the source. But constructing a value is only one half; reading which variant the constructed value carries is the other half, and this lesson used that reading only in the background, inside two helper functions (variant_name, area). match itself — which pattern forms exist, when one arm comes before another, why the compiler requires every variant to be handled — was never opened.

Summary

  • An enum declaration is an item: it produces no value when it runs, it only defines the variants.
  • Constructing a variant (like Shape::Circle(2.0)) is an expression and produces a value.
  • Variants carrying data and variants carrying none are counted with equal weight under the same enum type; this is what lets a single collection (like Vec<Shape>) hold all of them together.
  • Variant count is a fixed number read from the enum declaration in the source.
  • State count is different from this: a data-carrying variant can produce one, a few, or an uncountably large number of states, depending on the type of the data it carries.

Next Step

This lesson measured how many separate variants a Shape value can carry and how constructing a variant is an expression; the variant_name and area functions used match to tell these variants apart but never entered match’s own rules. The next lesson fills exactly that gap: how is which variant a Shape value carries read from the source, how many separate pattern forms can do this, and why does the compiler require every variant to be handled?

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ ↵ Esc navigate · open · close