Wrapping_rem should allow modulo 0

It occasionally comes up that we want to take a number n (let's say it's a u64) and calculate n % m, where m can be any value from 1 to 2^64. Since 2^64 can't be represented in a u64, it would be convenient to store it as 0 and have that act like 2^64 when using the wrapping operations (0 already acts like 2^64 in wrapping_add, wrapping_sub, wrapping_mul, and others). Thus n.wrapping_rem(0) should be equal to n.

This is an example of what I mean:

fn main() {
    let small_number: u64 = 34;
    let big_number = u64::MAX.wrapping_add(1); // this is supposed to represent 2^64
    println!("{}", small_number.wrapping_rem(big_number)); // expected: 34
    // panic: attempt to calculate the remainder with a divisor of zero
}

I don't think this should be considered a particularly strange result. Wikipedia notes: "Some systems leave a modulo 0 undefined, though others define it as a."

Also, wrapping_rem is completely useless on unsigned types because it's exactly the same as the normal modulo. It would be nice if it could actually be made useful somehow, although I realize that changing its behaviour at this point is probably intractable. Maybe we'd need a new method...

2 Likes

I believe the story of wrapping_ā€¦ methods ā€“ and correspondingly the Wrapping<ā€¦>-wrapped number types ā€“ should be that they always behave the same as the wrapping behavior (e.g. the default choice of the release profile) of the corresponding operator.

Iā€™d be wary to break this correspondence (but Iā€™m also not 100% certain if it isnā€™t broken anywhere yet).


Indeed, such a change could be dangerous. Doing a rem with s.len() is a common/reasonable approach to ensure an index ends up in-bounds, so unsafe code can very reasonably depend on this property for soundness. (The question of whether or not usage of wrapping_rem ā€“ on a usize ā€“ is all that reasonable for this, is perhaps a different one.)

5 Likes

Iā€™d be wary of changing rem to be inconsistent with div, and then Iā€™d be wary of changing wrapping_div(0) to return 0. I wouldnā€™t say itā€™s completely unreasonable, but it does give me pause.

@steffahn I would argue that doing wrapping_anything alongside indexing is somewhat nonsensical because wrapping operations, by their very nature, mean that you're comfortable with your values jumping around near usize::MAX. But it would be interesting to see whether anyone is actually relying on the value of wrapping_rem(0) in unsafe code.

@jrose Yeah, I agree that wrapping_div(0) should return zero as well although I think division by zero is a bit more controversial than modulo zero.

By the way, if we do add new methods, I propose the names total_div and total_rem since the goal is for them to be total functions.

2 Likes

Actually wrapping_div(0) could be everything, since a = (any_legal_number) * 0 + a holds for any_legal_number.

No matter what wrapping_div(0) returns, a.wrapping_rem(0) == a is a consist result.

Fair point. I was still thinking of the idea of explaining that all the ā€œwrappingā€ operators act as-if 0 is the size of the type, in which case weā€™d have x / 2^something, which could only be 0. But that isnā€™t strictly necessary for deciding that wrapping_rem should return 0 and also be consistent with wrapping_div in the quotient&remainder sense.

It's worth noting that n / 0 == 0 has precedent in some other languages including Pony, Lean, and Coq.

IMO that is just a hack for those languages requiring functions to be total, but at the same time not wanting to use the equivalent of Option<i64> as return value for divisions for convenience reasons.

7 Likes

I'm pretty sure this won't happen. If you want to do this, make your own method for it.

The biggest reason is that a very similar conversation happened as part of stabilizing ilog2: What should 0.ilog2() do in release mode?

There was a long conversation about potentially having that be -1 as _, which is a very natural result for it to give under the simple "well u32::ilog2 is just 31 - leading_zeroes" implementation.

But in the end, it was decided that wrapping results should only exist when there's a defined infinite-precision result. Wrapping an infinite-precision 257 to a finite 1_u8 is fine. But there's no coherent way to "wrap" -āˆž into an integer type, thus 0.ilog2() and -2 / 0 both always panic.


And for % specifically, I think returning the LHS is particularly wrong because it's inconsistent with what would happen for a hypothetical mixed-type % that I wish we could provide. If you do uNN % uMM, the obvious return type is uMM, but then you can't just return the LHS if the RHS is zero.

I'm also unwilling to weaken postconditions like this. For example, there's a discussion about whether to just define slice.as_chunks::<0>() as ([], slice). But that's horrible to actually use, because you want to give a postcondition of tail.len() < N as the obvious description of what that method's doing, but that implementation for zero violates it, in exactly the same kind of way that a % 0 => a violates the expected postcondition of %.

11 Likes

I donā€™t have a strong opinion on the ilog2 decision either way, but I this reasoning seems wrong to me. The only reason that arithmetic ops were chosen to wrap in release mode was performance. Thereā€™s nothing ā€œmore correctā€ about wrapping in release compared to any other behaviorā€”itā€™s a logic error no matter what, if you actually want wrapping semantics you should use the dedicated methods or wrapper type. So I donā€™t see the value in consistency.

3 Likes

I think I agree with this. However, even if 0 were allowed, there would still be a postcondition that is not much looser: tail.len() <= N.wrapping_sub(1).

While I agree that if you want wrapping you should use the dedicated APIs for that, it's not true that all the other behaviours are equal.

Most importantly, x + 1 and x - 2 + 3 are equivalent in wrapping -- but not in certain other things, like saturating -- so wrapping is a particularly good choice in that it can often give the correct final answer even if the intermediate operations are outside the supported rage.

That's also a reason for division to differ from other operations. x * 2 - 2 and (x - 1) * 2 work the same in wrapping, but (x + 2) / 2 and x / 2 + 1 don't.

2 Likes

The most important property of modulo is that a = (a / b) * b + a % b. If a / 0 is defined as giving any finite value, than a % 0 should always be a. Regarding ilog2(0), I'd honestly say that it's Ā±āˆž. With Real integers, it approaches -āˆž, however in a 2-adic sense, 2^N gets closer to 0 as N approaches āˆž (if N is larger than the number of stored bits, then the number is indistinguishable from / congruent to 0), so you can argue that the logarithm should also be +āˆž.

Really it's this interpretation of 0 as "a sufficiently large 2^N" that makes a % 0 = a seem more reasonable. The same argument would then imply that a / 0 = 0, since 0 is effectively larger in magnitude than any representable nonzero number (but is still neither positive nor negative) and so the quotient would always be smaller in magnitude than 1.

This is definitely not the Real integer system that most people were taught, but it is still completely mathematically valid and satisfies the rules that integer division should preserve.

I'll also mention that RISC-V defines a / 0 = -1, for both signed and unsigned a, since it's the closest unsigned value to āˆž, and it tends to be the natural output when trying to get a division circuit to compute a / 0.

Note that I don't consider "what the hardware does" to be at all a justification for what Rust should do.

IMHO the behaviour of << is just a mistake, for example. The thing that rust does should be the "right" thing (which I would typically define as "the thing that gets the same result as if it had been computed in infinite precision, and importantly not something where doing it in a wider type gives something very different") and if there's some slightly-strange behaviour that's more efficient or situationally-useful that's what specific other methods are for.

I don't even like leading_zeros, because (my_u32 as u64).leading_zeros() as u32 isn't the same as my_u32.leading_zeros(). Thankfully we have ilog2 now, which avoids that problem.

2 Likes

What behavior would you prefer?

I'd prefer the one that unbounded_sh[rl] uses.

my_u32.unbounded_shl(n) and (my_32 as u64).unbounded_shl(n) as u32 do the same thing.

Then if someone really wants x << (n % BITS) (I don't know why they would, tbh), they can always just write that.

1 Like

Exactly. I'd like to see this changed in a future edition, but it's going to be difficult without subtly breaking code.

The current behaviour is similar to other arithmetic in that shifting by an out-of-range value is considered an overflow, and panics in debug mode. I expect this helps find bugs in the common case where the shift amount should be in 0..BITS. If it was an unbounded shift, would you have negative amounts shift in the other direction, or alternatively not implement the operator for signed types at all?

IMO, the biggest issue is just not having the convenience of unbounded_{shl,shr} sooner (nor yet, on stable).

Many other operations are fundamentally tied to the bit-width of the type: rotate_{left,right},reverse_bits, {leading,trailing,count}_{zeros,ones} (consider 0 or negative inputs). But I can agree that for the cases where ilog2 is semantically appropriate, it's a big improvement over writing it in terms of leading_zeros.

2 Likes

The common case is often 0..=BITS, inclusive. Examples:

  • Implement a function that returns a bitmask with n lowest bits set. You want it to work for 0 and for BITS
  • Implement a function that shifts a multi-word bitmask by n. When n is a multiple of BITS, you'll need a bitshift by BITS or a special case.
2 Likes