An enum instead of a bool¶
Level: 201 · working knowledge
One line: A bool parameter is two unnamed states, so the compiler cannot tell a correct call from a backwards one — two named variants cost the same byte and turn the backwards call into a build error.
enum Sides { Both, Single }
enum Output { BlackAndWhite, Color }
fn print_page(sides: Sides, output: Output) { /* ... */ }
print_page(Sides::Both, Output::BlackAndWhite);
The call says what it does without anyone opening the function, and the arguments cannot be swapped — two separate jobs, both now the compiler's.
The example is David Drysdale's, from Effective Rust Item 1 ↗ (pp. 4–5).
What two bools cost¶
The signature everybody writes first:
fn print_page(both_sides: bool, color: bool) { /* ... */ }
print_page(/* both_sides= */ true, /* color= */ false); // both sides / black and white
print_page(false, true); // single side / colour -- also fine
Both calls compile. Only one of them is the job you meant, and nothing in the program knows which. The /* both_sides= */ comment is the giveaway: it is documentation standing exactly where a type should be, and it is checked by nobody — delete it, or move it, or leave it behind when the parameters are reordered, and the compiler is equally content.
| two bools | two enums | |
|---|---|---|
| reading the call site | true, false — go and look at the signature |
Sides::Both, Output::BlackAndWhite |
| arguments swapped | compiles, prints the wrong job | E0308, with a fix-it |
| a third alternative arrives | a second bool, or a rewrite | a third variant, and every match reports in |
| what it costs | 1 byte | 1 byte |
The refusal¶
The whole file, with the backwards call left as a comment so the page stays paste-safe:
pub enum Sides {
Both,
Single,
}
pub enum Output {
BlackAndWhite,
Color,
}
pub fn print_page(sides: Sides, output: Output) {
let _ = (sides, output);
}
fn main() {
// print_page(Output::BlackAndWhite, Sides::Single); // <- E0308
}
error[E0308]: arguments to this function are incorrect
--> print_page.rs:16:5
|
16 | print_page(Output::BlackAndWhite, Sides::Single);
| ^^^^^^^^^^ --------------------- ------------- expected `Output`, found `Sides`
| |
| expected `Sides`, found `Output`
|
note: function defined here
--> print_page.rs:11:8
|
11 | pub fn print_page(sides: Sides, output: Output) {
| ^^^^^^^^^^
help: swap these arguments
|
16 - print_page(Output::BlackAndWhite, Sides::Single);
16 + print_page(Sides::Single, Output::BlackAndWhite);
|
help: swap these arguments is the compiler naming the mistake and writing the fix. Against bool, bool it has nothing to say, because there is no mistake to see: two bools in either order are two bools.
Newtype or enum?¶
A newtype — a one-field struct wrapping the bool — buys the same refusal, verified the same way:
struct DoubleSided(pub bool);
struct ColorOutput(pub bool);
fn print_page(sides: DoubleSided, color: ColorOutput) { /* ... */ }
print_page(DoubleSided(true), ColorOutput(false));
// print_page(ColorOutput(false), DoubleSided(true)); // <- E0308, "swap these arguments" again
What it does not buy is a name for either state. DoubleSided(true) still asks the reader to know which way round true runs, and !flag still flips it silently. The enum has no true in it to get backwards.
struct DoubleSided(bool) |
enum Sides { Both, Single } |
|
|---|---|---|
| rejects the swapped call | yes | yes |
| names the two states | no — still true / false |
yes |
| can grow a third state | no, bool has two |
yes, and every match becomes a build error |
#[repr(transparent)] for FFI |
yes | not the same thing |
Drysdale's rule, and it is a good one: newtype if the semantics will always be Boolean; enum if a third alternative could arise — Sides::BothAlternateOrientation is his example, and it is exactly the change that is free in one column and a rewrite in the other. Adding it makes every match on Sides fail to compile until it is handled, which is the whole payoff of the enum.
The same mistake inside a struct¶
A comment explaining when a field is valid is the same giveaway as /* both_sides= */, one level down:
struct DisplayProps {
x: u32,
y: u32,
monochrome: bool,
// `fg_color` must be (0, 0, 0) if `monochrome` is true.
fg_color: RgbColor,
}
The comment is describing states the type permits and the program must not produce. Put the bool and the field it governs into one enum and there is nothing left to describe:
enum Color {
Monochrome,
Foreground(RgbColor),
}
struct DisplayProps {
x: u32,
y: u32,
color: Color,
}
The example program counts both, with a three-value stand-in for RgbColor so the states can be enumerated rather than asserted:
| shape | states | that break the comment |
|---|---|---|
monochrome: bool + fg: Shade |
3 × 2 = 6 | 2 |
Color::Monochrome | Color::Foreground(Shade) |
1 + 3 = 4 | none can be written |
With a real 24-bit RgbColor the same arithmetic gives 33,554,432 states of which 16,777,215 contradict the comment, against 16,777,217 that cannot. This is make invalid states inexpressible (Item 1, p. 7), and it is the product-versus-sum choice with a bool as the product's second factor.
It is also smaller. Measured on this target: the struct holding a bool and a Shade is 2 bytes; the one holding the Color enum is 1. A three-variant Shade uses 3 of the 256 values in its byte, so the extra Monochrome tag moves into one of the 253 that are spare — the same niche that makes Option<Box<T>> free.
The lints that sound like they cover this¶
Two clippy lints name this exact refactor. Neither will mention the code above.
warning: more than 3 bools in function parameters
= help: consider refactoring bools into two-variant enums
Read from the toolchain rather than from memory (clippy-driver -Whelp):
| lint | group | default | fires at |
|---|---|---|---|
fn_params_excessive_bools ↗ |
pedantic |
allow | more than 3 bools in a signature |
struct_excessive_bools ↗ |
pedantic |
allow | more than 3 bools in a struct |
So two things have to be true before a lint says anything: you turned on pedantic (as the strict-clippy config does), and you already have four. print_page(bool, bool) is under the threshold, and DisplayProps has one bool in it. The help text is right about the fix and will never be shown to the person who needs it most, which is the argument for treating this as a design habit rather than something tooling catches — the same shape as a typo becoming a binding, where the two obvious lints are also silent.
If you are coming from another language¶
Python. The bool parameter is the same trap, and Python fixes half of it with a keyword-only marker: def print_page(*, both_sides: bool, color: bool) makes print_page(True, False) a TypeError and forces every caller to write the names. That is the readability half and the swap half, at the call site, at runtime. It does not name the states — both_sides=True still leaves True meaning something the reader has to look up — and it is not enforced anywhere at all if the parameters are positional. enum.Enum names the states, Literal["both", "single"] names them for a type checker, and neither is checked by the interpreter. What Rust adds is that all three checks are the same check, and it runs before the program does.
ABAP. Named arguments are compulsory the moment a method takes two parameters — print_page( both_sides = abap_true color = abap_false ) — so ABAP fixes the swap by syntax rather than by types, and fixes it better than an enum does at the call site. The gap is what abap_bool is: c LENGTH 1, so 'Y' and '0' and '?' all fit, and every one of them behaves as false against the standard IF flag = abap_true test. A wrong value passes silently where a wrong order cannot. Since 7.51 an enumerated type closes that half — TYPES: BEGIN OF ENUM t_sides, both, single, END OF ENUM t_sides. — and the variable then holds only declared values. What still does not follow is the exhaustiveness: a CASE over the enumerated type compiles with a WHEN missing, so the third alternative arriving is a grep, not a build error.
C. A C enum is an int in a hat, and the swap survives it. print_page(BW, SINGLE) with enum Sides and enum Output parameters compiles — Apple clang 21 with -Wall -Wextra emits two -Wimplicit-enum-enum-cast warnings and produces the object file. C++'s enum class is the version that behaves like Rust's: the same call is a hard error, no known conversion from Output to Sides.
Java, C#, Go. All three have real enum types and all three let you pass true positionally, so the trap and the fix are both available; the difference is only that Rust's enum can carry the payload the bool was gating, which is what collapses DisplayProps from six states to four.
Practice¶
The call that cannot be checked. Write clean(s, lowercase: bool, trim: bool), then call it with the two arguments swapped. Say what tells you it is wrong — the answer is nothing.
Now replace both bools with two-variant enums and swap the arguments again. Say what changed, what it cost in bytes, and — the other half — name three places a bool is still the right choice.
Solution
an_enum_instead_of_a_bool_kata.rs in full — pasted here by tools/run_examples.py from the file CI compiles and runs.
//! Kata solution: the call the compiler cannot check, and the one it can.
//!
//! rustc --edition 2024 an_enum_instead_of_a_bool_kata.rs -o /tmp/eib && /tmp/eib
use std::mem::size_of;
#[derive(Debug, Clone, Copy)]
enum Casing { Lower, Preserve }
#[derive(Debug, Clone, Copy)]
enum Trimming { Trim, Keep }
fn clean_bools(s: &str, lowercase: bool, trim: bool) -> String {
let t = if trim { s.trim() } else { s };
if lowercase { t.to_lowercase() } else { t.to_string() }
}
fn clean(s: &str, casing: Casing, trimming: Trimming) -> String {
let t = match trimming { Trimming::Trim => s.trim(), Trimming::Keep => s };
match casing { Casing::Lower => t.to_lowercase(), Casing::Preserve => t.to_string() }
}
fn main() {
let input = " Hello World ";
println!("THE BOOL VERSION, CALLED TWO WAYS");
println!(" clean_bools(input, true, false) {:?}", clean_bools(input, true, false));
println!(" clean_bools(input, false, true) {:?}", clean_bools(input, false, true));
println!(" Both compile. One of them is the call the author meant and the");
println!(" other is the arguments swapped, and nothing anywhere can tell");
println!(" you which -- the types are identical, so the compiler has no");
println!(" question to ask.");
println!();
println!("AT THE CALL SITE, WHICH IS WHERE IT IS READ");
println!(" clean_bools(s, true, false) <- true what? false what?");
println!(" clean(s, Casing::Lower, Trimming::Keep)");
println!(" The second one is readable without opening the function. That");
println!(" is most of the value, and it costs nothing.");
println!();
println!("THE ENUM VERSION, WITH THE ARGUMENTS SWAPPED");
println!(" clean(input, Trimming::Trim, Casing::Lower)");
println!(" does not compile: expected `Casing`, found `Trimming`. The");
println!(" backwards call became a build error rather than a wrong answer");
println!(" in production.");
println!(" clean(input, Casing::Lower, Trimming::Trim) = {:?}",
clean(input, Casing::Lower, Trimming::Trim));
println!(" clean(input, Casing::Preserve, Trimming::Keep) = {:?}",
clean(input, Casing::Preserve, Trimming::Keep));
println!();
println!("AND IT IS FREE");
println!(" size_of::<bool>() {} byte", size_of::<bool>());
println!(" size_of::<Casing>() {} byte", size_of::<Casing>());
println!(" Two named variants cost exactly what two unnamed states cost.");
println!(" There is no runtime price for the check, because the check");
println!(" happened at compile time.");
println!();
println!("WHEN A bool IS STILL RIGHT");
println!(" When the name at the call site already says everything:");
println!(" `v.is_empty()`, `s.contains(c)`, `if found {{ ... }}`. The rule");
println!(" is about PARAMETERS -- a bool you pass in is two unnamed states");
println!(" arriving somewhere the name is gone.");
assert_eq!(size_of::<Casing>(), 1);
assert_eq!(clean(input, Casing::Lower, Trimming::Trim), "hello world");
}
Verified output of an_enum_instead_of_a_bool_kata.rs — regenerated by tools/run_examples.py, never hand-typed.
THE BOOL VERSION, CALLED TWO WAYS
clean_bools(input, true, false) " hello world "
clean_bools(input, false, true) "Hello World"
Both compile. One of them is the call the author meant and the
other is the arguments swapped, and nothing anywhere can tell
you which -- the types are identical, so the compiler has no
question to ask.
AT THE CALL SITE, WHICH IS WHERE IT IS READ
clean_bools(s, true, false) <- true what? false what?
clean(s, Casing::Lower, Trimming::Keep)
The second one is readable without opening the function. That
is most of the value, and it costs nothing.
THE ENUM VERSION, WITH THE ARGUMENTS SWAPPED
clean(input, Trimming::Trim, Casing::Lower)
does not compile: expected `Casing`, found `Trimming`. The
backwards call became a build error rather than a wrong answer
in production.
clean(input, Casing::Lower, Trimming::Trim) = "hello world"
clean(input, Casing::Preserve, Trimming::Keep) = " Hello World "
AND IT IS FREE
size_of::<bool>() 1 byte
size_of::<Casing>() 1 byte
Two named variants cost exactly what two unnamed states cost.
There is no runtime price for the check, because the check
happened at compile time.
WHEN A bool IS STILL RIGHT
When the name at the call site already says everything:
`v.is_empty()`, `s.contains(c)`, `if found { ... }`. The rule
is about PARAMETERS -- a bool you pass in is two unnamed states
arriving somewhere the name is gone.
The verified output¶
examples/an_enum_instead_of_a_bool.rs compiled and run:
Verified output of an_enum_instead_of_a_bool.rs — regenerated by tools/run_examples.py, never hand-typed.
two bools, called as intended:
print_page(true, false) -> both sides / black and white
two bools, arguments swapped -- compiles, prints the wrong job:
print_page(false, true) -> single side / colour
two enums, called as intended:
print_page(Sides::Both, Output::BlackAndWhite)
-> both sides / black and white
two enums, arguments swapped -- does not compile:
print_page(Output::BlackAndWhite, Sides::Single)
-> error[E0308], with a fix-it
states the type permits, with 3 shades:
bool + shade (AND) 6 states, 2 of them break the comment
Color (OR ) 4 states, every one meaningful
monochrome: true fg: Black ok
monochrome: true fg: Red contradicts the comment
monochrome: true fg: White contradicts the comment
monochrome: false fg: Black ok
monochrome: false fg: Red ok
monochrome: false fg: White ok
and with a real RgbColor (16777216 colours):
bool + RgbColor 33554432 states, 16777215 of them break the comment
Color 16777217 states
sizes:
bool 1
Sides 1
Output 1
PropsBool 2
PropsEnum 1
See also¶
- What an enum is — the declaration, and the
E0004that arrives with the third variant - Variants that carry data — product versus sum, and the niche that made the enum a byte smaller
- A score is not a number — the newtype, in full, where the wrapped value has more than two states
- An enum as a state machine — the same argument with a
match (state, event)table behind it - A typo becomes a binding — the other enum trap the obvious lints do not catch
- Strict clippy — what turning on
pedanticactually costs
Po polsku¶
Pułapka wartości logicznej (boolean trap) wygląda w miejscu wywołania tak: print_page(true, false) — i nie ma w tej linijce niczego, co pozwoliłoby rozstrzygnąć, czy chodzi o wydruk dwustronny w czerni, czy jednostronny w kolorze. Obie kolejności się kompilują, a tylko jedna jest zadaniem, o które chodziło. Dla czytelnika przychodzącego z Pythona albo z ABAP-a najważniejsza jest tu jedna informacja o samym Ruście: nie ma w nim argumentów nazwanych. Nie napiszesz both_sides=True jak w Pythonie ani both_sides = abap_true, czego ABAP przy dwóch parametrach wręcz wymaga — jedynym miejscem, w którym może zamieszkać nazwa, jest typ. Dwa wyliczenia zamiast dwóch booli nie są więc ozdobnikiem stylistycznym, tylko jedynym dostępnym mechanizmem nazywania argumentów w wywołaniu; przy okazji zamieniają odwróconą kolejność w E0308 z gotową poprawką (help: swap these arguments). Komentarz /* both_sides= */ robi to samo zadanie, stoi dokładnie tam, gdzie powinien stać typ, i nie sprawdza go nikt.
Struktura jednopolowa (newtype) opakowująca bool kupuje tę samą odmowę — zamienione argumenty to znowu E0308 — i nie kupuje nic ponadto. DoubleSided(true) wciąż wymaga od czytelnika wiedzy, w którą stronę biegnie true, a !flag wciąż po cichu odwraca sens. W wyliczeniu nie ma żadnego true, które dałoby się przestawić. Reguła Drysdale'a jest dobra i warta zapamiętania w tej postaci: newtype, jeśli sens zawsze pozostanie dwuwartościowy; wyliczenie, jeśli może pojawić się trzecia możliwość. Ta trzecia możliwość jest w jednej kolumnie darmowa — dochodzi wariant i każde match przestaje się kompilować, dopóki go nie obsłużysz — a w drugiej oznacza przepisywanie, bo bool ma dwie wartości i nie da się z tym negocjować.
Ten sam błąd piętro niżej ma postać komentarza nad polem: „fg_color musi być (0, 0, 0), jeśli monochrome jest prawdą”. Taki komentarz opisuje stany, na które typ pozwala, a których program nie ma prawa wyprodukować — czyli jest specyfikacją bez kontroli. Wciągnij flagę i pole, którym ona rządzi, do jednego wyliczenia (Monochrome albo Foreground(RgbColor)), a opisywać nie ma już czego: sześć stanów robi się czterema, a te dwa sprzeczne z komentarzem przestają być zapisywalne. Przy prawdziwym RgbColor ta sama arytmetyka daje 33 554 432 stany, z czego 16 777 215 przeczy komentarzowi, wobec 16 777 217 stanów, w których skłamać się nie da. Drysdale pisze o tym make invalid states inexpressible, a szerzej znane sformułowanie tej samej zasady brzmi make invalid states unrepresentable — po polsku najbliżej „stanów nieprawidłowych nie da się w ogóle zapisać” — i wychodzi przy tym mniej, a nie więcej pamięci: na tej platformie struktura z boolem i Shade zajmuje 2 bajty, ta z wyliczeniem 1, bo trzywartościowy Shade zużywa 3 z 256 układów bitów swojego bajtu i znacznik ma się gdzie schować.
Na koniec rzecz, którą trzeba wiedzieć, zanim się w tej sprawie policzy na narzędzia: clippy tego nie złapie. Dwa linty nazywają dokładnie ten refaktor — fn_params_excessive_bools i struct_excessive_bools — ale oba siedzą w grupie pedantic, domyślnie wyłączonej, i odzywają się dopiero powyżej trzech wartości logicznych. print_page(bool, bool) jest pod progiem, a DisplayProps ma jednego boola, więc trafna podpowiedź „consider refactoring bools into two-variant enums” nigdy nie trafi do osoby, która najbardziej jej potrzebuje. To jest więc nawyk projektowy, a nie coś, co wyłapie narzędzie — dokładnie tak samo jak przy literówce, która staje się wiązaniem.
Szukaj po polsku: pułapka wartości logicznej · argumenty nazwane · wzorzec newtype · rust boolean trap enum · rust E0308 swap these arguments · rust make invalid states unrepresentable