8 ms·
Rust has the "non_exhaustive" attribute that lets you declare that an enum might get more fields in the future. In practice that means that when you match on an
by remram 2y ago
Rust has the "non_exhaustive" attribute that lets you declare that an enum might get more fields in the future. In practice that means that when you match on an enum value, you have to add a default case. It's like a "other" field in the enum except you can't reference it directly, you use a default case.
IIRC a secret 'other' field (or '__non_exhaustive' or something) is actually how we did thing before non_exhaustive was introduced.
- airstrike 2y agoTIL https://doc.rust-lang.org/reference/attributes/type_system.html https://doc.rust-lang.org/reference/attributes/type_system.h...
- kibwen 2y agoNote that the stance of the OP here is broadly in agreement with what Rust does. His main objection is this: > The word “other” means “not mentioned elsewhere”, so the presence of an Other logically implies that the enumeration is exhaustive. In Rust, because all enums are exhaustive by default and exhaustive matching is enforced by the compiler, there is no risk of this sort of confusion. And then the fact that his proposed solution is: > Just document that the enumeration is open-ended The non_exhaustive attribute is effectively compiler-enforced documentation; users now cannot forget to treat the enum as open-ended. Of course, adding non_exhaustive to Rust was not without its own detractors; it usage for any given enum fundamentally means shifting power away from library consumers (who lose the ability to guarantee exhaustive matching) and towards library authors (who gain the ability to evolve their API without causing guaranteed compilation errors in all of their users (which some users desire!)). As such, the guidance is that it should be used sparingly, mostly for things like error types. But that's an argument against open-ended enums in general, not against the mechanisms we use to achieve those (which, as you say, was already possible in Rust via hacks).
- tyre 2y agoMaybe there should be a compiler option or function to assert that a match is exhaustive. If the match does not handle a defined case, it blows up.
- aecsocket 2y agoRust already asserts that a match is exhaustive at compile time - if you don't include a branch for each option, it will fail to compile. This extends to integer range matching and string matching as well. It's just that with #[non_exhaustive], you must specify a default branch (`_ => { .. }`), even if you've already explicitly matched on all the values. The idea being that you've written code which matches on all the values which exist right now, but the library author is free to add new variants without breaking your code - since it's now your responsibility as a user of the library to handle the default case.
- ffminus 2y agoLibrary users can force a compile error when new variants get added, using a lint from rustc. It's "allow" by default, so it's opt-in. https://doc.rust-lang.org/rustc/lints/listing/allowed-by-default.html#non-exhaustive-omitted-patterns https://doc.rust-lang.org/rustc/lints/listing/allowed-by-def...
- WiSaGaN 2y agoDoes this require nightly? If so, #[warn(clippy::wildcard_enum_match_arm)] will do the samething but no need for nightly, and from clippy instead of rustc natively.
- codetrotter 2y agoThat's pretty neat. I still don't completely understand why #[non_exhaustive] is so desirable in the first place though. Let's say I am using a crate called zoo-bar. Let's say this crate is not using non-exhaustive. In my code where I use this crate I do: let my_workplace = zoo_bar::ZooBar::new(); let mut animal_pens_iter = my_workplace.hungry_animals.iter(); while let Some(ap) = animal_pens_iter.next() { match ap { zoo_bar::AnimalPen::Tigers => { me.go_feed_tigers(&mut raw_meat_that_tigers_like_stock).await?; } zoo_bar::AnimalPen::Elephants => { me.go_feed_elephants(&mut peanut_stock).await?; } } } I update or upgrade the zoo-bar dependency and there's a new enum variant of AnimalPens called Monkeys. Great! I get a compile error and I update my code to feed the monkeys. diff --git a/src/main.rs b/src/main.rs index 202c10c..425d649 100644 --- a/src/main.rs +++ b/src/main.rs @@ -10,5 +10,8 @@ zoo_bar::AnimalPen::Elephants => { me.go_feed_elephants(&mut peanut_stock).await?; } + zoo_bar::AnimalPen::Monkeys => { + me.go_feed_monkeys(&mut banana_stock).await?; + } } } Now let's say instead that the AnimalPen enum was marked non-exhaustive. So I'm forced to have a default match arm. In this alternate universe I start off with: let my_workplace = zoo_bar::ZooBar::new(); let mut animal_pens_iter = my_workplace.hungry_animals.iter(); while let Some(ap) = animal_pens_iter.next() { match ap { zoo_bar::AnimalPen::Tigers => { me.go_feed_tigers(&mut raw_meat_that_tigers_like_stock).await?; } zoo_bar::AnimalPen::Elephants => { me.go_feed_elephants(&mut peanut_stock).await?; } _ => { eprintln!("Whoops! I sure hope someone notices this default match in the logs and goes and updates the code."); } } } When the monkeys are added, and I update or upgrade the dependency on zoo-bar, I don't notice the warning in the logs right away after we deploy to prod. Because the logs contain too many things no one can go and read everything. One week passes and then we have a monkey starving incident at work. After careful review we realize that it was due to the default match arm and we forgot to update our program. So we learn from the terrible catastrophe with the monkeys and I update my code using the attributes from your link. diff --git a/src/main.rs b/src/main.rs index e01fcd1..aab0112 100644 --- a/wp/src/main.rs +++ b/wp/src/main.rs @@ -1,3 +1,5 @@ +#![feature(non_exhaustive_omitted_patterns_lint)] + use std::error::Error; #[tokio::main] @@ -11,6 +13,7 @@ async fn main() -> anyhow::Result<()> { let mut animal_pens_iter = my_workplace.hungry_animals.iter(); while let Some(ap) = animal_pens_iter.next() { + #[warn(non_exhaustive_omitted_patterns)] match ap { zoo_bar::AnimalPen::Tigers => { me.go_feed_tigers(&mut raw_meat_that_tigers_like_stock).await?; @@ -18,8 +21,12 @@ async fn main() -> anyhow::Result<()> { zoo_bar::AnimalPen::Elephants => { me.go_feed_elephants(&mut peanut_stock).await?; } + zoo_bar::AnimalPen::Monkeys => { + // Our monkeys died before we started using proper attributes. If they are hungry it means they have turned into zombies :O + me.alert_authorities_about_potential_outbreak_of_zombie_monkeys().await?; + } _ => { - eprintln!("Whoops! I sure hope someone notices this default match in the logs and goes and updates the code."); + unreachable!("We have an attribute that is supposed to tell us if there were any unmatched new variants."); } } } And next time we update or upgrade the crate version to latest, another new variant exists, but thanks to your tip we get a lint warning and we happily update our code so that we won't have more starving animals. diff --git a/wp/src/main.rs b/wp/src/main.rs index aab0112..4fc4041 100644 --- a/wp/src/main.rs +++ b/wp/src/main.rs @@ -25,6 +25,9 @@ async fn main() -> anyhow::Result<()> { // Our monkeys died before we started using proper attributes. If they are hungry it means they have turned into zombies :O me.alert_authorities_about_potential_outbreak_of_zombie_monkeys().await?; } + zoo_bar::AnimalPen::Capybaras => { + me.go_feed_capybaras(&mut whatever_the_heck_capybaras_eat_stock).await?; + } _ => { unreachable!("We have an attribute that is supposed to tell us if there were any unmatched new variants."); } But what was the advantage of marking the enum as #[non_exhaustive] in the first place?
- hchja 2y agoThis is why language syntax is so important. Swift allows a ‘default’ enum case which is similar to other but you should use it with caution. It’s better to not use it unless you’re 110% sure that there will not be additional enums added in the future. Otherwise, in Swift when you add an additional enum case, the code where you use the enum will not work unless you handle each enum occurrence at it’s respective call site.
- layer8 2y agoThe better solution is to have two different “default” cases in the language, one that expresses handling “future” values (values that aren’t currently defined), and one that expresses “the rest of the currently defined values”. The “future” case wouldn’t be considered for exhaustiveness checks.
- SkiFire13 2y agoWhat would the "future" default case actually do though? When you're in the past there's no value for it, and the moment you get to the future the values will become part of the "present" and will still not fall under the "future" case. You would need some kind of versioning support in the enum itself, but that's a much bigger change.
- layer8 2y ago“Future” values only become defined (“present” in your sense) at compile-time, but may occur before that at runtime. Note that this mostly presumes a language with separate compilation, or situations like coding against a remote-API spec, where the server may deploy a newer version but your client remains unchanged. Once you compile against the new spec, you’d get errors/warnings about the new, not explicitly handled values, but your existing binary would nevertheless handle those values under the “future” case. The issue with traditional “default” cases is that they shadow warnings/errors about unhandled cases, but you’d still want to have some form of default case for forward compatibility.
- 2y ago
- sunshowers 2y agoThere is currently a missing middle ground in stable Rust, which is to lint on a missing variant rather than fail compilation. There's an unstable option for it, but it would be very useful for non-exhaustive enums where consumers care about matching against every known variant. You can practically use it today by gating on a nightly-only cfg flag. See https://github.com/guppy-rs/guppy/blob/fa61210b67bea233de52c10395045bc19c62b068/.cargo/nightly-config.toml#L11 https://github.com/guppy-rs/guppy/blob/fa61210b67bea233de52c... and https://github.com/guppy-rs/guppy/blob/fa61210b67bea233de52c10395045bc19c62b068/tools/hakari/src/cli_ops/initialize.rs#L63 https://github.com/guppy-rs/guppy/blob/fa61210b67bea233de52c...
- eru 2y agoCouldn't clippy do that for you?
- sunshowers 2y agoNot at the moment. The unstable lint is implemented in rustc directly, not in clippy, though I guess it could move to clippy in the future.
- rendaw 2y agoI absolutely _hate_ this. Since you're forced to add a default case, if a new field is added in the future that you need to actively handle it won't turn into a compile error _or_ surface as a runtime error. I think half of it is developers presuming to know users' needs and making decisions for them (users can make that decision by themselves, using the default case!) but also a logic-defying fear of build breakage, to the point that I've seen developers turn other compile errors into runtime errors in order to avoid "breaking changes".
- michaeljsmith 2y agoNot sure about Rust, but Typescript allows you to have the default handling but still flag a compile error if a new field is added (the first is useful e.g. if a separate component is updated and starts sending new values). https://stackoverflow.com/a/39419171/974188 https://stackoverflow.com/a/39419171/974188
- Spivak 2y agohttps://news.ycombinator.com/item?id=43237013 https://news.ycombinator.com/item?id=43237013 You have to opt into it but it's nice that it's available.
- rendaw 2y agoOh nice! That seems so backwards, but hey, if it works...
- bobbylarrybobby 2y agoI agree, this is the one place where upstream crates should be allowed to make breaking changes for downstream users. As a consumer of another crate’s enum, it's easy to enough opt into “never break my code” by just adding default cases, but I'd like to have to opt into that so that I'm notified when new variants are added upstream. Maybe this should even be a Cargo.toml setting — when an upstream crate is marked non-exhaustive, the downstream consumer gets to choose: require me to add default cases (and don't mark them as dead code), or let me exhaustively match anyway, knowing my match statement might break in the future.
- kpcyrd 2y agoIt's still a gotcha in Rust, I've seen code like: #[non_exhaustive] pub enum Protocol { Tcp, Udp, Other(u16), } It allows you to still match on the unrecognized case (like `Protocol::Other(1)`, which is nice), but an additional enum variant may eliminate that case, if our enum gets extended to: #[non_exhaustive] pub enum Protocol { Tcp, Udp, Icmp, Other(u16), } Even though we can add additional variants in a semver-nonbreaking way due to `#[non_exhaustive]`, other people's code may now be broken until they've changed `Protocol::Other(1)` to `Protocol::Icmp`. Having had this in the back of my head for quite some time, I think instead of an `Other` case there should be two methods, one returns an `Option<Protocol>` and the other one returns the `u16` representation. Unless there's a match on one of your expected cases your default branch would inspect the raw numeric type, which would keep working even if that case is added to the enum.
- remram 2y agoYou can't use non_exhaustive and Other. If you have an Other then it's exhaustive. This design is wrong.
- ghfhghg 2y agoLanguages like haxe simply won't compile if you don't cover every enum value in a switch case. Would that not be preferable? I quite like that feature. F# I believe is similar wrt discriminated unions and pattern matching
- nindalf 2y agoI think you misunderstood. By default Rust expects you to handle every enum variant. Not doing so would be a compile error. An example - my library exposes enum Colour with 3 variants - Red, Blue Green. In your application code you `match` on all 3. So far so good. But now if I add a 4th colour to my enum, your code will no longer compile because you are no longer handling every enum variant. This is a crappy experience for the user of the library. Instead, the library writer can make their intent clear - with the #[non_exhaustive] attribute. On such an enum it's not enough to handle the 3 colours of the enum, you must add a wildcard matcher that matches any variants added in future. This gives the library writer flexibility to make changes, while protecting the application developer from breakage.
- ghfhghg 2y agoOh I see. I did indeed misunderstand. Thanks for taking the time to explain!