Implicit widening, polymorphic indexing, and similar ideas

So, I made some experiment to revive this discussion.

My goal was to replace one of the most popular numeric conversions in the Rust codebase - as usize - with three semantically different actions: widening (lossless conversion), truncation (potentially lossy conversion) and sign conversion (potentially lossy) and to analyse the result.
This was done with three simple traits - Widen, Truncate and ConvertSign:

trait Widen<Target>: Sized {
    // Lossless numeric conversion (equivalent to operator `as`)
    fn widen(self) -> Target;
}
trait Truncate<Target>: Sized {
    // Numeric truncation (equivalent to operator `as`)
    fn truncate(self) -> Target;
}
trait ConvertSign: Sized {
    type TargetSigned;
    type TargetUnsigned;
    // 2's complement sign conversion or no-op for signed numbers (equivalent to operator `as`)
    fn as_signed(self) -> Self::TargetSigned;
    // 2's complement sign conversion or no-op for unsigned numbers (equivalent to operator `as`)
    fn as_unsigned(self) -> Self::TargetUnsigned;
}

My branch with results can be found here:


and the diff between the branch and the upstream is

Some notes:

  1. Widen and Truncate really have to represent "weak" widening and truncation an not "strict", i.e. both Widen<T> and Truncate<T> are implemented for the type T itself. This is for portability reasons, both between 64-bit and 32-bit machines and different operating systems. (The first couple of commits in my branch show how it looks when WidenStrict and WidenWeak are separate traits.)
    For the same reasons ConvertSign is a single trait and not two - AsSigned and AsUnsigned - as_unsigned()(as_signed()) should be supported for unsigned(signed) types too (but be a no-op, obviously).

  2. Widen and Truncate are identical to std::convert::Into in their form (see below).

Conclusions:

  1. In general I like the result - "harmless" conversions like widening, that you can pretty much throw anywhere without much thinking (but I'm still against making them implicit), are clearly separated from "suspicious" conversions, that require some thinking and analysis.
    Lossless conversions are much more common than lossy - the statistics in my experiment is definitely skewed here, because usize is a relatively wide type, but remember that as usize/isize/u64/i64 are the most popular conversions and they tend to be widening, and conversions to narrower types are much rarer.
    It means that programmer's attention can be re-targeted from conversions with operator as in general to only their small but potentially more problematic portion.
    Moreover, with dedicated semantically loaded methods for certain conversions the all-purpose operator as itself would be used rarer and could be considered "raw and low level" and requiring more attention.

However, there are some (solvable) problems, diminishing the usefulness of the traits today:

  1. Default type parameters don't drive type inference. It means that you can't do the next thing:

    let a: u16 = 10; let b = c[a.widen()]; and you have to give a type hint to widen() somehow.
    (In my branch the type hint is given with an additional trait method widen_ which is clearly a hack and shouldn't be there in the final design).
    With improved type inference based on default type parameters type hints for widen() would almost never be needed.

  2. Type ascription is not implemented. Even without improved type inference you could supply a target type with type ascription, but there's no easy and short way to do it without it. (Into has the same problem currently.)

    let a: u16 = 10; let b = c[a.widen(): usize];

  3. widen() is better and safer way to perform conversions, but to compete with core language facilities like as it should be really convenient to use.
    As a minimum the Widen trait should live in the prelude, besides that the method call .widen() is quite long to type (although not as long as as usize) and may be shortened somehow.

  4. While widen is almost unquestionable, the other methods can raise some questions - for example, how lossy conversions should be treated, with silent truncation (like operator as), with panic (like arithmetic operations) or the methods should return Option.

All these notes, conclusions and problems led me to one alternative: don't use a separate trait Widen for lossless numeric conversions, but use Into instead.

Pros:

  1. Into is already in the prelude and .into() is shorter than .widen()
  2. Into will probably be implemented for integer conversions anyway, because they are perfectly valid safe conversions, and Into is idiomatic for such conversions, and they can possibly benefit from generics using Into.
  3. There's a non-zero chance, that Into and its friends from std::convert being such basic traits will get some short and convenient language sugar some day in the future.

Cons:

  1. .into() is not as semantically clear as .widen(), but it may be treated just as "safe and lossless type adjustment" without the widening aspect

So, here's some practical actions that I propose:

  1. Implement Into/From for lossless numeric conversions
  2. Postpone implementing the other mentioned traits (Truncate, ConvertSign) for some time, the "raw and low level" operator as still can be used for them.
2 Likes