Pre-RFC: add methods like 42u32.to_signed() to the standard library

Currently there are three ways to convert between integer types:

  • lossless (to(), from(); requires range inclusion)
  • faulty (try_to(), try_from())
  • unconstrained, error-prone (as)
  • transmute() doesn't count

There is no satisfying way to convert between signed and unsigned integers of the same size. as is error-prone because it doesn't distinguish between injective and information-loosing cases: if the type of x changes from u32 to u64, you'll get no warning from the compiler that x as i32 should be probably updated to x as i64.

I propose to add .to_signed() method to u8, u16, u32, u64, u128, usize; and to add .to_unsigned() method to iwhatever.

I think it's valuable to have it in the standard library to make clippy lints possible ("you wrote x as i32 where x: u32, did you mean x.to_signed()?"). Besides, conversions between signed and unsigned representations of the same size is much more common than conversions between big-endian and little-endian, and that's in the standard library.

Variable design dimensions (bikeshedding points)

Should it be regular methods or traits ToSigned, ToUnsigned? I don't see a use case for parametric polymorphism here, so probably regular methods.

Should there be u32::from_signed(x: i32)? Maybe for symmetry.

Should unsigned types support tautological to_unsigned()? I don't see the point, probably not.

1 Like

The prefix would be as_ instead of to_, since the operation is not expensive. (guidelines).

Since your proposed conversions would technically create overflow (in a controlled manner) these methods may have a better place with the std::num::Wrapping family of types.

2 Likes

I agree that Wrapping(u32) should have .as_signed(). The expression Wrapping(42u32).as_signed().0 states the intent most explicitly. Perhaps it's too verbose and a shortcut 42u32.wrapping_as_signed() is warranted, but that's a minor detail.

It also got me thinking about the cases where wrapping is not intended. Regular arithmetic operations panic on overflow in debug but wrap in release. Maybe .as_signed() on regular unsigned integers should do the same. Both x as i32 and i32::from(x).unwrap() are error-prone when changing x from u32 to u64.

1 Like

Why would regular try_* and as_* methods not apply in this case? After all, u32 to i32 is a fallible conversion, but u32 to i64 is infallible.

(I think we generally prefer to say "fallible" rather than "faulty", as the latter implies some poor design or buggy implementation was involved)

Taking the point even further, what I was thinking: In a context where you want to do wrapping conversions between u32 and i32 you should ask yourself if you types shouldn’t have been Wrapping<u32 and Wrapping<i32> in the first place. Ignoring the fact that the Wrapping API hasn’t stabilized yet. This may not apply though in some cases; perhaps we need some concrete code examples which need to do conversion between signed and unsigned types to evaluate what the proper API would be in those cases. wrapping_as_signed would fit in with wrapping_add etc.

A final idea I’m having is: Shouldn’t we support addition, subtraction and multiplication between Wrapped<u32> and Wrapping<i32>? This might eliminate the need for conversion in some cases. One downside being that the return type would be chosen fairly arbitrarily (probably as the left operand type).

1 Like

Wrapping<uN> is a closed ring for modular arithmetic. Wrapping<iN> is just an isomorphism of the Wrapping<uN> ring. For me it is an almost-useless isomorphism that tends to confuse analysis.

What significant use is there for the concept Wrapping<iN>? Why would it be important to define any special functions for Wrapping<iN>?

2 Likes

In the current status of the API (and I just noticed that the trait implementations and the type itself are already stable), there is a difference in division.

use core::num::Wrapping;
macro_rules! p {
    ($e:expr) => (println!("{}", $e));
}
fn main() {
    let x = Wrapping(-1i32);
    let y = -Wrapping(1u32);
    
    p!(x % Wrapping(2));
    p!(y % Wrapping(2));
}

(playground)


By the way...

usually refers to the map between two isomorphic structures. A better term here would be “isomorphic copy”, and also here:

The actual isomorphism itself is super useful, since it’s existence shows that Wrapping<uN> and Wrapping<iN> are basically the same thing if you only consider addition, subtraction and multiplication.

i.e., a ring.

I think the ownership part is more important -- as_ is for borrowed->borrowed, but to_ for owned->owned (Copy types) fits here.

3 Likes

Good point, I somehow missed that. The cost column in that book should probably be changed though, as it only applies to the borrowed->borrowed case then.

Personally, I don't find the name to_signed() clearly implies uN -> iN (i.e. same size integer). I'd rather see something like safe transmute used here, which makes it clearer (to me, at least) that we're effectively transmuting this integer to another integer of the same size.

1 Like

I don't see how that's a safe transmute. A value in the range [0x00..=0x7f] (Rust notation) can be transmuted between u8 and i8; any other value loses meaning in the conversion. It seems to me that Rust will require range-constrained types to have such same-memory-size transmutes between different numeric domains be compiler-checkable as safe.

Other values are lossy, in that the sign bit changes the interpretation, but they're still safe.

1 Like

Changes meaning would be correct, loses meaning is misleading at best.

Rust guarantees that its number types are two's compliment, so my_u8 as i8 is fully and perfectly defined as a one-to-one conversion. Similarly, my_u8 as i8 as u8 is defined to be an identity conversation. On top of this, because of the defined layout, doing this as transmute instead of as is also defined. (In fact, for integer-integer as, as is defined as a bitwise reinterpretation, i.e. transmute.)

If the trait impls don't already exist, (didn't check), we should add them to make <Wrapping<i8>>::from(Wrapping(my_u8)).0 work, and perhaps even Wrapping<i8>: From<u8>. Personally, I'd expect i8::from_unsigned(my_u8) to debug-panic, release-overflow, just like most primitive calculation, which is a distinct but still useful operation to the always-wrapping version.

4 Likes

This topic was automatically closed 90 days after the last reply. New replies are no longer allowed.