Generic enums¶
Level: 201 · working knowledge
One line: An enum takes type parameters exactly as a struct does — every variant shares the list, which is what makes Option<T> and Result<T, E> ordinary library code rather than compiler magic.
One parameter, three variants, and only one of them uses it. Reserved and Empty carry nothing and still belong to Slot<T> — the parameter is on the type, not on the variant.
Every variant shares the parameters¶
There is no way to give one variant a T of its own: <T> is declared once, after the enum's name, and every variant is built from that same list. Two consequences, and the second one surprises people.
A payload-free variant still needs a T. Nothing in Slot::Empty says what kind of slot it is, so this is E0282 unless something else in the function settles it:
// let nothing = Slot::Empty; // error[E0282]: type annotations needed
let nothing = Slot::<u8>::Empty; // or let it come from a later use
The variants are not types. Slot::Filled is a way of building a Slot<T>, not a type you can name in a signature — the same rule as any other enum, and it is why a function returning "either a score or a reason" returns one enum rather than two things.
Two parameters¶
enum Either<L, R> {
Left(L),
Right(R),
}
fn parse_score(text: &str) -> Either<u8, String> { /* … */ }
That is Result<T, E> with the names changed, and Result really is exactly this — two parameters, two variants, one of each, declared in the standard library and no more built-in than your Either. Option<T> is the same idea with one parameter and a payload-free second variant, which is the shape at the top of this page.
Both parameters may be filled with the same type, and they are still two independent parameters: Result<u8, u8> is a legal type, and Ok(3) and Err(3) are different values in it.
What it costs¶
An enum is laid out as its largest variant plus a tag, so T decides the size:
| Type | size_of |
Why |
|---|---|---|
Slot<u8> |
2 | one byte of payload, one of tag |
Slot<u64> |
16 | eight of payload, and the tag padded to the alignment |
Either<u8, String> |
24 | the String variant is the largest, and the tag is free |
The third row is the niche again: a String's pointer is never null, so the discriminant hides in a bit pattern the type could not otherwise hold, and Either<u8, String> is the same 24 bytes as a bare String.
Methods, and where a bound goes¶
impl blocks work as they do for any enum, with the parameter declared on the block:
impl<T> Slot<T> {
fn is_filled(&self) -> bool {
matches!(self, Slot::Filled(_))
}
}
impl<T: Display> Slot<T> {
fn describe(&self) -> String { /* … */ }
}
is_filled works for every T; describe needs to print the payload, so its block asks for Display. The bound belongs on the block that spends it, never on the enum line — the same rule, for the same reasons, as on a struct.
If you are coming from another language¶
Python. The counterpart is a union of dataclasses — Filled(value: T) | Reserved | Empty with typing.Generic[T] — and Optional[T] is literally Union[T, None], which is Option<T> written the other way round. Two things change. The generic is erased at run time in Python, so Slot[int] and Slot[str] are one class and the parameter exists only for the type-checker, while Slot<u8> and Slot<u64> are different types of different sizes. And the match becomes exhaustive: forgetting Reserved is a build error rather than a fall-through to None.
ABAP. There is no sum type at all, which is why this shape is unfamiliar rather than merely differently spelled. The usual ABAP model of "one of these, with different data each" is a flat structure holding a kind field plus every component any kind might need, most of them empty on any given row, and a CASE that nobody checks for completeness. Slot<T> says the same thing with the impossible combinations unwritable — a Reserved slot has no payload field to leave blank — and the compiler lists every CASE that a new variant breaks. The generic half then adds what a structure could never do: the same three states over an integer, a string, or a reference, without three copies of the type.
Java. Optional<T> is the closest single type, and since Java 17 the general shape exists too: a sealed interface with record implementations gives you variants, a shared parameter, and exhaustive switch. The difference left is erasure — Slot<Integer> boxes, Slot<u8> does not — and that Rust's enum is one value with a tag rather than a pointer to one of several classes.
C++. std::variant<A, B> is the same idea with worse ergonomics — std::visit in place of match, and no exhaustiveness guarantee unless you construct one. std::optional<T> and std::expected<T, E> (C++23) are Option and Result arriving separately, as library types, which is what they are in Rust too.
The verified output¶
examples/generic_enums.rs compiled and run:
Verified output of generic_enums.rs — regenerated by tools/run_examples.py, never hand-typed.
filled with Ada filled: true
reserved filled: false
empty filled: false
Slot::<u8>::Empty is Empty, filled: false
4 -> score 4
9 -> rejected: 9 is above the 0-5 range
five -> rejected: "five": invalid digit found in string
size_of::<Slot<u8>>() 2
size_of::<Slot<u64>>() 16
size_of::<Either<u8, String>>() 24
size_of::<String>() 24
Result<u8, u8> is a real type: Err(3)
See also¶
- What an enum is — the non-generic version: four variant shapes, and the exhaustiveness that comes free
- A generic recursive type — a generic enum that contains itself, and the
Boxit needs - When the compiler cannot infer — the
E0282a payload-free variant produces - Variants that carry data — what a payload costs, measured
- Six kinds of zero — writing your own enum when
Option's two variants run out OptionvsResult— the two library enums this page is a general case of
Po polsku¶
Polskie słowo „wyliczenie” (enum) jest tu odrobinę zdradliwe, bo dla czytelnika wychowanego na C czy starszej Javie oznacza listę nazwanych stałych — a Rustowe enum to typ sumaryczny (sum type): każdy wariant może nieść dane. Wersja generyczna dokłada jedną regułę: <T> deklaruje się raz, zaraz po nazwie wyliczenia, i dzielą je wszystkie warianty. Nie da się dać jednemu wariantowi własnego T. Stąd zaskoczenie, po którym większość ludzi trafia tu z wyszukiwarki: wariant bez ładunku i tak potrzebuje T, więc let nothing = Slot::Empty; kończy się na E0282, a naprawia się to turbofishem — Slot::<u8>::Empty — albo pozwalając późniejszemu użyciu rozstrzygnąć typ. Warto też zapamiętać, że Slot::Filled nie jest typem, tylko sposobem zbudowania Slot<T>; w sygnaturze funkcji można napisać tylko całe wyliczenie.
Nagroda za zrozumienie tej jednej reguły jest duża, bo polskie materiały zwykle przedstawiają Option<T> i Result<T, E> tak, jakby były wbudowane w język. Nie są. Either<L, R> — dwa warianty, dwa parametry — to Result ze zmienionymi nazwami, a sam Result jest w bibliotece standardowej zwykłym wyliczeniem, ani odrobinę bardziej wbudowanym niż twoje Either. Oba parametry są od siebie niezależne, także wtedy, gdy wypełni się je tym samym typem: Result<u8, u8> jest poprawnym typem, a Ok(3) i Err(3) to w nim dwie różne wartości.
Koszt liczy się jak dla każdego wyliczenia — rozmiar największego wariantu plus znacznik — więc to T decyduje: Slot<u8> zajmuje 2 bajty, Slot<u64> już 16 (osiem ładunku, znacznik dopchnięty do wyrównania), a Either<u8, String> — 24, czyli tyle samo co goły String, bo wskaźnik w String nigdy nie jest zerowy i znacznik chowa się w niszy. Ograniczenia (bounds) stawia się natomiast na bloku impl, który ich naprawdę potrzebuje, a nie w linii enum: is_filled działa dla każdego T, describe wypisuje ładunek, więc jego blok prosi o Display.
Szukaj po polsku: wyliczenia generyczne w Ruscie · typ sumaryczny · turbofish w Ruscie · rust generic enum E0282 type annotations needed · rust enum niche optimization size_of