Skip to content

Latest commit

 

History

History
 
 

2_5_exhaustivity

Folders and files

NameName
Last commit message
Last commit date

parent directory

..
 
 
 
 
 
 

Step 2.5: Exhaustivity

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.

Enums

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

Structs

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:

#[non_exhaustive]

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 or enum variant) cannot be constructed with a StructExpression (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 or enum variant), a StructPattern must be used which must include a ... Tuple variant constructor visibility is lowered to min($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:

Task

Estimated time: 1 day

Refactor the code contained in this step's crate, so the bugs introduced there will be uncovered at compile-time, and fix them appropriately.

Questions

After completing everything above, you should be able to answer (and understand why) the following questions:

  • How can exhaustiveness checking be useful in Rust code for enums and structs? When should we use it, when not?
  • How does #[non_exhaustive] attribute work in Rust? What are its use-cases? When should it be used, when not?