Why there is no `saturating_sub()` method on `NonZeroU[SIZE]` types

Mmmmmmmmmm..... Got your point

Well, the original purpose of NonZeroU32 is to be "u32 but with a niche at zero". It didn't even have any of these math-related functions back when it was stabilized (see https://doc.rust-lang.org/1.35.0/std/num/struct.NonZeroU32.html for example), because it wasn't added to be an integer-like type.

Making its methods and trait impls only exist if they still do the same thing as on u32 is helpful for things like https://github.com/bevyengine/bevy/pull/9907 -- it means you can change the type and look at the compiler errors to see what you have to think about. If you were only using things like checked_add and | that still exist, you're fine.

EDIT later: said otherwise, the reason that NonZero::checked_add and such is to reduce unsafe (compared to .get().checked_add), not to make it act like a modular integer.


In (reductively) short, std is generally aiming at providing functionally with "obviously correct" semantics. Every single Rust program uses std[1], so it should avoid being overly opinionated. NonZeroU32 could reasonably be intuited as "u32 but 0 is an illegal value" or "integer in the ring 1..232", so std has so far avoided adding any functionality which would be correct for one interpretation but incorrect for the other.

History means that established Rust users are more likely to see NonZeroU32 as the former rather than the latter. Plus it's not that much effort to write NonZeroU32::new(nz.get().saturating_sub(other))[.unwrap_or(NonZeroU32::MIN)] and be clear about what behavior you're asking for.

If the type were solely the library API, then yes. But it's not; Rust is a low level language that enables the developer to worry about layout, and the primary purpose of NonZeroU32 is the fact that Option<NonZeroU32> is the same size and representation as u32, and that this niche is extended to compound types using NonZero_ types internally. A library cannot implement NonZeroU32[2] from and provide the layout or optimization properties that std is able to.

There are correctness and optimization benefits to using NonZero_ and ptr::NonNull, but their primary purpose continues to be optimizing the "at rest" representation. Those correctness benefits "in motion" fundamentally come at the cost of some usage ergonomics, and restoring many of those ergonomics would mean sacrificing some of those correctness benefits.

I personally agree that {saturating|checked}_sub, if provided, should have the definition in integer ring 1..2N. But I fail to see a meaningful benefit in breaking the current dual applicability of NonZeroUnn that allows it to be a direct substitution for uNN in a fearless compiler error driven refactor.

This case is imo similar to differentiating in physics between an absolute or relative position (𝑤 is 1 or 0, respectively). Keeping explicit track in the type system of what coordinate space your units are in can be of huge benefit to consistency and correctness, but is also an outsized mental overhead (and potentially code duplication overhead) for the minor bookkeeping benefits. Even formal algebra can sometimes be similarly loose with types[3].

  1. At least partially, via core (and optionally alloc) in #![no_std] cases. (Therapist: #![no_core] isn't real and can't hurt you. #![no_core]: :eyes:) ↩ī¸Ž

  2. Practically, anyway. You could create a giant #[repr(u32)] enum with 232-1 variants if you really hate yourself and non-slow compile times that much. ↩ī¸Ž

  3. What's the result of a cross product between two 3D vectors? It's structurally like a vector, and can generally be manipulated like a vector, except for when it can't. Geometric algebra calls this quantity a "bivector," which is a kind of pseudovector and represents an area oriented within a plane. Look it up if you want a (generally poorly explained due to being very abstract) rabbit hole. ↩ī¸Ž


This topic was automatically closed 90 days after the last reply. We invite you to open a new topic if you have further questions or comments.