[Pre-RFC] Expressive integer conversions (`.truncate()` and `.widen()`)

Summary

Introduce two new prelude traits, TruncateTo<T> and WidenTo<T>, that provide a .truncate() and .widen() method, to make integer-to-integer casts more expressive and reduce the number of as casts required.

Motivation

Casting between differently sized integers isn’t optimal. It requires the use of as, which is a type conversion grab bag: It also allows casting integers to floats, unsigned integers to signed integers, and even a no-op cast to the same integer type.

The recently added From impls somewhat alleviate the problem since there is now a different way to widen integers. This isn’t as explicit as .widen() would be, and, like as, overloaded, since From and Into are very generic traits. If this kind of widening conversion is deemed sufficient, only the .truncate() part of this RFC can be accepted as well (see the alternative listed below).

Introducing two new (mainly internal) traits and putting them into the prelude makes truncation and widening casts, two of the most common uses of as, more expressive and harder to get wrong.

Detailed design

Add these two traits to libcore and its prelude:

trait TruncateTo<T> {
    fn truncate(&self) -> T;
}

trait WidenTo<T> {
    fn widen(&self) -> T;
}

Provide implementations to convert between differently sized unsigned and signed integers:

macro_rules! impl_trunc_widen {
    ( $big:ty > $small:ty ) => {
        impl TruncateTo<$small> for $big {
            fn truncate(&self) -> $small {
                *self as $small
            }
        }
        
        impl WidenTo<$big> for $small {
            fn widen(&self) -> $big {
                *self as $big
            }
        }
    };
}

impl_trunc_widen!(u16 > u8);
impl_trunc_widen!(u32 > u8);
impl_trunc_widen!(u32 > u16);
impl_trunc_widen!(u64 > u8);
impl_trunc_widen!(u64 > u16);
impl_trunc_widen!(u64 > u32);

impl_trunc_widen!(i16 > i8);
impl_trunc_widen!(i32 > i8);
impl_trunc_widen!(i32 > i16);
impl_trunc_widen!(i64 > i8);
impl_trunc_widen!(i64 > i16);
impl_trunc_widen!(i64 > i32);

// Omitted: `i128` and `u128` impls

Note that this intentionally does not allow performing a few casts that can be performed using as:

  • Converting an unsigned to a signed integer or the other way around
  • No-op conversion from T to T
  • Int-to-float and float-to-int conversions

Conversions to smaller/larger integer types can now be performed by calling .truncate() and .widen():

let lo: u16 = 0xabcdef99u32.truncate();
assert_eq!(lo, 0xef99);

let lo: u8 = 0xabcdef99u32.truncate();
assert_eq!(lo, 0x99);

let lo: u8 = 0x99u32.truncate();
assert_eq!(lo, 0x99);

let wide: u32 = 0x99u8.widen();
assert_eq!(wide, 0x00000099);

Also note that .truncate() and .widen() can be stabilized without stabilizing any of the traits. This might be a viable strategy to make this proposal useful to users of stable Rust, even when there are still unresolved questions regarding the traits. This is already used today for the SliceConcatExt prelude trait.

Drawbacks

  • Unlike as, a few casts can be performed without specifying the target type. {u16,i16}::truncate and {u64,i64}::widen only have a single applicable impl, so they can be called without specifying the target type (resulting in a u8,i8,u128,i128, respectively). Other cases need a type annotation somewhere. This also somewhat precludes adding more impls for these types in the future, since type inference would fail (imagine widening u128 to a hypothetical u256).
  • Logically, the traits want to use an associated type instead of a type parameter, but the traits need to be implemented multiple times with different resulting types, which isn’t possible if an assoc. type was used.

Alternatives

  • A solution to the second drawback would be to only support truncating to the next smallest type (and widening to the next largest), so there’s only one implementation per integer type. The drawback is that casting (for example) a u32 to a u8 requires doing value.truncate().truncate() instead of value.truncate() with a type annotation.
  • Just continue using as and .into(). This is what all Rust code uses until now. Introducing a second way to do things makes Rust more complex and harder to learn, and the existing as casts won’t go away magically, so there will always be two ways to do things (although linting as casts where the new methods could be used might be a good idea).
  • Implement only the .truncate() part of this RFC and continue using as or preferably .into() for widening (since .into() already works for widening today). If Rust gains implicit widening of integers, .widen() would become obsolete, which can be prevented by not implementing it in the first place.

Unresolved questions

  • Should f32 and f64 implement the traits? What about usize and isize?
  • Are the traits useful outside the standard library or do the orphan rules prevent that? Do they make sense for bignum implementations?

For experimentation, here’s an implementation in the playpen: https://is.gd/DfuLIw

This can already be done (not as concisely, mind) using conv.

Also, I’d argue truncation is the one you rarely want. Truncation is also really hard to get wrong since it’s just throwing away bits.

Previous attempts: Implicit widening, polymorphic indexing, and similar ideas https://github.com/rust-lang/rfcs/pull/1218

As a result of these attempts widen() now exists in the standard library as into() and checked_cast() exists as try_into(). Truncating/wrapping conversions still have to use as.

Sure. The point is that .truncate() is more explicit and less overloaded than as. The thing you might get wrong is the meaning of the code when reading it: Does this as widen, truncate or convert in a different way? (especially when the types involved aren't obvious, which is often the case due to inference)

Updated with an alternative to just implement .truncate(), since .into() is basically equivalent to .widen().

Since .widen() is more explicit and less overloaded than .into(), and for symmetry with .truncate(), I’d still argue in favor of including .widen().

Both .widen() and .into() have the drawback that it’s not easy to specify the to-type, so they are less useful in non-generic code that way. (In generic code the priming from the trait bound will lead inference right).

I mostly use Type::from(x) for this reason, it’s easy to state the type, and it’s easy to read and understand.

... it's not easy to specify the to-type, ...

This is actually why conv grew convenience traits so you could do expr.value_as::<To>() or expr.into_as::<To>().

1 Like

It’s a solution I would go to too.

They are supposed to be used with type ascription: a.into(): T.

As another alternative: I often have the need for some kind of high() or hi(), low() or lo() and hi_lo() -> (T, U) which splits the value's bits equally into two half-width values without bit fiddling.

let ax = eax.lo();
let ah = ax.lo().hi();
let (ah, al) = ax.hi_lo();

(a method or operator to join some smaller values to form a broader one whould complete this idea)

I’ve often wanted something like .lo() and .hi(), too. These seem very easy to implement, don’t need a trait and don’t have type inference problems. Though it seems like these are a bit orthogonal to what this Pre-RFC proposes, and they, like one of the alternatives listed, can only go to the next smallest integer type, not to an arbitrary smaller integer type. I think I’ll open another thread for .lo() and .hi().

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