Chapter 12

Enums in Two Sizes

Goose's enum is an algebraic data type: a value that is one of several variants, each of which may carry its own fields. If you know Rust's enum you know most of this chapter already. The new part is that every enum can be stored in two ways, and you choose between them each time you use the type.

Enums are also the only form of dynamic dispatch in Goose. There are no classes, traits, interfaces or vtables. When a program needs "one of several kinds of thing", it uses an enum.

#Declaring enums12.1

An enum lists its variants. A variant can carry fields, declared like a struct's, or nothing at all:

enum Event {
    Honk { times: i32 },
    Lay { grams: f32 },
    Moult,
    Migrate { km: f64, south: bool },
}

fn main() {
    var log: Event[>..] = [];
    log.push(Event.Honk { times: 5 });
    log.push(Event.Lay { grams: 61.5 });
    log.push(Event.Moult);
    log.push(Event.Migrate { km: 800.0, south: true });
    print(log);
}
[Honk { 5 }, Lay { 61.5 }, Event.Moult, Migrate { 800.0, true }]

A variant is written Enum.Variant { … }, or just Enum.Variant when it has no fields. The payload's fields have the same rules as a struct's, including defaults and the .. form.

An enum whose variants carry nothing is a C-style enumeration, and takes one byte:

enum Mood { Calm, Hungry, Angry }

fn main() {
    var moods: Mood[>..] = [Mood.Calm, Mood.Angry];
    print(moods, " in ", bytes_of(moods).len, " bytes, ", moods[1] == Mood.Angry);
}
[Mood.Calm, Mood.Angry] in 2 bytes, true

#Matching12.2

match takes an enum value apart. Each arm names a variant, and may bind its payload to a name:

enum Event { Honk { times: i32 }, Lay { grams: f32 }, Moult, Migrate { km: f64, south: bool } }

fn describe(e: Event) -> u8[:] {
    match e {
        Honk h => if h.times > 3 { "a racket" } else { "a honk" },
        Lay l => "an egg",
        Moult => "feathers everywhere",
        Migrate m => if m.south { "heading south" } else { "heading north" },
    }
}

fn noisy(e: Event) -> bool {
    match e { Honk, Migrate => true, _ => false }
}

fn main() {
    let e = Event.Honk { times: 5 };
    print(describe(e), " ", noisy(e), " ", describe(Event.Moult), " ", noisy(Event.Moult));
}
a racket true feathers everywhere false

An arm can list several variants, as Honk, Migrate does. Such an arm cannot bind a payload, since each variant's payload has a different type. _ matches whatever is left.

A match over an enum must be exhaustive: every variant needs an arm, unless there is a _. When you add a variant to an enum, every match that does not handle it stops compiling:

enum Event { Honk { times: i32 }, Lay { grams: f32 }, Moult }

fn describe(e: Event) -> u8[:] {
    match e { Honk h => "a honk", Lay l => "an egg" }
}

fn main() { print(describe(Event.Moult)); }
error: match does not cover variant Event.Moult

#Fixed mode and variable mode12.3

Here is where Goose's enums differ from every other language's. The Event above has variants of very different sizes. Moult carries nothing, Honk carries four bytes, and Migrate carries nine. Every value can be stored in two ways:

Event[>..] (fixed mode: every value is 10 bytes) M H G Moult, Honk { times: i32 }, Migrate { km: f64, south: bool }: 30 bytes Event..[>..] (variable mode: each value is its own size) M H G the same three events: 16 bytes, but the array can only be walked
The same three events in fixed mode, where each takes the room of the largest variant, and in variable mode, where each takes only its own.
enum Event { Honk { times: i32 }, Lay { grams: f32 }, Moult, Migrate { km: f64, south: bool } }

fn main() {
    var fixed: Event[>..] = [];
    var packed: Event..[>..] = [];
    for i in 2 {
        fixed.push(Event.Moult);
        fixed.push(Event.Honk { times: 1 });
        packed.push(Event.Moult);
        packed.push(Event.Honk { times: 1 });
    }
    print(bytes_of(fixed).len, " bytes fixed, ", bytes_of(packed).len, " bytes packed");
}
40 bytes fixed, 12 bytes packed

Each mode offers a guarantee the other cannot.

Fixed mode values all have the same size, so an array of them can be indexed, and a value can be overwritten with a different variant in place:

enum Event { Honk { times: i32 }, Lay { grams: f32 }, Moult }

fn main() {
    var log: Event[>..] = [Event.Honk { times: 2 }, Event.Moult];
    log[1] = Event.Lay { grams: 70.0 };
    print(log);
}
[Honk { 2 }, Lay { 70.0 }]

The price is that nothing may point into a fixed-mode payload. If you held a reference to the times field of a Honk, and the value was then overwritten with a Lay, the reference would see a float where it expected an integer. So match binds a fixed-mode payload only by value, as a copy:

enum Event { Honk { times: i32 }, Moult }

fn main() {
    var log: Event[>..] = [Event.Honk { times: 1 }];
    for e in log {
        match e { Honk &h => { h.times += 1; }, _ => {} }
    }
}
error: cannot bind the payload of fixed-mode Event by reference (§3.5); bind by value: Honk h

Variable mode is the mirror image. Values are packed against each other, so a value can never change to a larger variant, and an array of them is sequential: it can be walked but not indexed. In exchange, you may take references into a payload. match arms can bind with & and edit the payload in place:

enum Note { Text { body: u8[varint] }, Weight { kg: f32 }, Blank }

fn main() {
    var notes: Note..[>..] = [];
    notes.push(Note.Text { body: "fed at dawn" });
    notes.push(Note.Weight { kg: 4.5 });
    notes.push(Note.Blank);
    for n in notes {
        match n {
            Weight &w => { w.kg += 0.25; },
            _ => {},
        }
    }
    print(notes, " in ", bytes_of(notes).len, " bytes");
}
[Text { "fed at dawn" }, Weight { 4.75 }, Note.Blank] in 19 bytes

Notice the Text variant. Its payload is a variable-size string, so it has no "largest size" that fixed mode could reserve. An enum with a variable-size payload can only be used in variable mode:

enum Note { Text { body: u8[varint] }, Blank }

fn main() {
    var notes: Note[>..] = [];
}
error: enum Note has non-fixed-size payloads and can only be used in variable mode (Note..)

The mode is chosen where the type is used, not where it is declared. The same enum can be stored in fixed mode in one array, so you can index and update it, and in variable mode in another, to save space.

#Case functions12.4

A match puts all the handling of an enum in one place. Sometimes you would rather have one function per variant, the way you would have one method per subclass in an object-oriented language. Goose lets you write exactly that. Each variant is a type of its own, written Event.Honk, and you can overload a function on the variant types:

enum Event { Honk { times: i32 }, Lay { grams: f32 }, Moult, Migrate { km: f64, south: bool } }

fn energy(e: Event.Honk) -> f64 { e.times * 2.0 }
fn energy(e: Event.Lay) -> f64 { 50.0 }
fn energy(e: Event.Moult) -> f64 { 10.0 }
fn energy(e: Event.Migrate) -> f64 { e.km * 0.5 }

fn main() {
    var log: Event[>..] = [];
    log.push(Event.Honk { times: 5 });
    log.push(Event.Lay { grams: 61.5 });
    log.push(Event.Migrate { km: 800.0, south: true });
    var total = 0.0;
    for e in log { total += energy(e); }
    print(total);
}
460.0

When energy is called with a whole Event, no overload takes an Event directly, so the call dispatches on the tag to the overload for that variant, through a jump table. These case functions are checked for completeness just like a match: if a variant has no overload, a call with the whole enum does not compile.

enum Event { Honk { times: i32 }, Lay { grams: f32 }, Moult }

fn energy(e: Event.Honk) -> f64 { 1.0 }
fn energy(e: Event.Lay) -> f64 { 2.0 }

fn main() {
    let e: Event = Event.Moult;
    print(energy(e));
}
error: no matching overload for call to energy

A variant type is an ordinary type, so you can also call energy with a value you already know is a Honk, and it calls that overload directly:

enum Event { Honk { times: i32 }, Moult }

fn energy(e: Event.Honk) -> f64 { e.times * 2.0 }
fn energy(e: Event.Moult) -> f64 { 10.0 }

fn main() {
    let h = Event.Honk { times: 2 };
    print(energy(h), " ", h.times);
}
4.0 2

This is Goose's answer to virtual functions. It is closed rather than open: you cannot add a new variant from another file without editing the enum. In return, every call is checked for completeness and dispatches with one indexed jump.

#Design note: why two modes?12.5

Allowing a value to change variant and allowing references into its payload are each useful, but together they are unsound. A reference into a payload would outlive the payload's type. Most languages pick one and live with the cost. Rust lets you replace the variant and gets references into payloads by enforcing exclusive borrows, so that nothing else can replace the variant while the reference is alive. C allows both, and leaves it to you.

Goose makes it a choice of storage. Fixed mode forbids interior references, so replacement is always safe. Variable mode forbids replacement, so interior references are always safe. Because the choice is made at each use of the type, one program can have both: an indexable, editable array of small events, and a packed log of large ones.

#Exercises12.6

  1. Declare enum Feed { Grain { grams: i32 }, Grass, Bread { slices: i32 } } and write a function that returns how healthy each kind of feed is, as a number from 0 to 10, first with a match and then with case functions.

  2. Make a Feed[>..] and a Feed..[>..] holding the same 100 meals, half Grass and half Grain, and print how many bytes each takes.

  3. Using a variable-mode array, write a loop that doubles the slices of every Bread in place.