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:
- Fixed mode, written plainly as
Event, stores a tag and enough room for the largest payload. EveryEventis 10 bytes, even aMoult. This is how Rust, C++ and Odin store their tagged unions. - Variable mode, written
Event..with two trailing dots, stores each value at exactly its own size: 1 byte for aMoult, 5 for aHonk, 10 for aMigrate.
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
-
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 amatchand then with case functions. -
Make a
Feed[>..]and aFeed..[>..]holding the same 100 meals, halfGrassand halfGrain, and print how many bytes each takes. -
Using a variable-mode array, write a loop that doubles the
slicesof everyBreadin place.