Estimated time: 1 day
Exhaustiveness checking in pattern matching is a very useful tool, allowing to spot certain bugs at compile-time by cheking whether all the combinations of some values where covered and considered in a source code. Being applied correctly, it increases the fearless refactoring quality of a source code, eliminating possibilities for "forgot to change" bugs to subtly sneak into the codebase whenever it's extended.
The most canonical and iconic example of exhaustiveness checking is using an enum in a match
expression. The point here is to omit using _
(wildcard pattern) or match-anything bindings, as such match
expressions won't break in compile-time when something new is added.
For example, this is a very bad code:
fn grant_permissions(role: &Role) -> Permissions {
match role {
Role::Reporter => Permissions::Read,
Role::Developer => Permissions::Read & Permissions::Edit,
_ => Permissions::All, // anybody else is administrator
}
}
If, for some reason, a new Role::Guest
is added, with very high probability this code won't be changed accordingly, introducing a security bug, by granting Permissions::All
to any guest. This mainly happens, because the code itself doesn't signal back in any way that it should be reconsidered.
By leveraging exhaustivity, the code can be altered in the way it breaks at compile-time whenever a new Role
variant is added:
fn grant_permissions(role: &Role) -> Permissions {
match role {
Role::Reporter => Permissions::Read,
Role::Developer => Permissions::Read & Permissions::Edit,
Role::Admin => Permissions::All,
}
}
error[E0004]: non-exhaustive patterns: `&Role::Guest` not covered
--> src/lib.rs:16:11
|
16 | match role {
| ^^^^ pattern `&Role::Guest` not covered
|
note: `Role` defined here
--> src/lib.rs:2:5
|
1 | enum Role {
| ----
2 | Guest,
| ^^^^^ not covered
While enums exhaustiveness is quite an obvious idea, due to extensive usage of match
expressions in a regular code, the structs exhaustiveness, on the other hand, is not, while being as much useful. Exhaustivity for structs is achieved by using destructuring without ..
syntax (multiple fields ignoring).
For example, having the following code:
struct Address {
country: Country,
city: City,
street: Street,
zip: Zip,
}
impl fmt::Display for Address {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
writeln!(f, "{}", self.country)?;
writeln!(f, "{}", self.city)?;
writeln!(f, "{}", self.street)?;
write!(f, "{}", self.zip)
}
}
It's super easy to forget changing the Display
implementation when a new state
field is added.
So, altering the code with exhaustive destructuring allows to omit such a subtle bug, by breaking in compile-time:
impl fmt::Display for Address {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let Self {
country,
city,
street,
zip,
} = self;
writeln!(f, "{country}")?;
writeln!(f, "{city}")?;
writeln!(f, "{street}")?;
write!(f, "{zip}")
}
}
error[E0027]: pattern does not mention field `state`
--> src/lib.rs:19:13
|
19 | let Self {
| _____________^
20 | | country,
21 | | city,
22 | | street,
23 | | zip,
24 | | } = self;
| |_________^ missing field `state`
|
help: include the missing field in the pattern
|
23 | zip, state } = self;
| ~~~~~~~~~
help: if you don't care about this missing field, you can explicitly ignore it
|
23 | zip, .. } = self;
|
Another real-world use-cases of maintaining invariants covering all struct fields via exhaustiveness checking are illustrated in the following articles:
Until now, it has been illustrated how exhaustiveness checking can future-proof a user code (the one which uses API of some type, not declares), by making it to break whenever the used API is extended and should be reconsidered.
#[non_exhaustive]
attribute, interestedly, serves the very same purpose of future-proofing a source code, but in a totally opposite manner: it's used in a library code (the one which declares API of some type for usage) to preserve backwards compatibility for omitting breaking any user code whenever the used API is extended.
Within the defining crate,
non_exhaustive
has no effect.
Outside of the defining crate, types annotated with
non_exhaustive
have limitations that preserve backwards compatibility when new fields or variants are added.Non-exhaustive types cannot be constructed outside of the defining crate:
- Non-exhaustive variants (
struct
orenum
variant) cannot be constructed with aStructExpression
(including with functional update syntax).enum
instances can be constructed.
There are limitations when matching on non-exhaustive types outside of the defining crate:
- When pattern matching on a non-exhaustive variant (
struct
orenum
variant), aStructPattern
must be used which must include a...
Tuple variant constructor visibility is lowered tomin($vis, pub(crate))
.- When pattern matching on a non-exhaustive
enum
, matching on a variant does not contribute towards the exhaustiveness of the arms.
It's also not allowed to cast non-exhaustive types from foreign crates.
Non-exhaustive types are always considered inhabited in downstream crates.
Despite being opposite qualities, both exhaustivity and non-exhaustivity are intended for future-proofing a codebase, thus cannot be applied blindly everywhere, but rather wisely, where it may really has sense. That's why it's very important to understand their use-cases and implicability very well.
For better understanding #[non_exhaustive]
attribute purpose, design, limitations and use cases, read through the following articles:
- Rust Reference: 7.6. The
non_exhaustive
attribute - Rust RFC 2008:
non_exhaustive
- Turreta: Using
#[non_exhaustive]
for Non-exhaustive Rust Structs
Refactor the code contained in this step's crate, so the bugs introduced there will be uncovered at compile-time, and fix them appropriately.
After completing everything above, you should be able to answer (and understand why) the following questions: