Skip to content

Instantly share code, notes, and snippets.

@SimonSapin
Created March 11, 2022 18:15
Show Gist options
  • Save SimonSapin/550ada9ebedcd21c41cf4e95891e03d7 to your computer and use it in GitHub Desktop.
Save SimonSapin/550ada9ebedcd21c41cf4e95891e03d7 to your computer and use it in GitHub Desktop.
Rust pre-RFC: Add explicitly-named numeric conversion APIs

Add explicitly-named numeric conversion APIs

Summary

Add explicitly-named standard library APIs for conversion between primitive number types with various semantics: truncating, saturating, rounding, etc.

This RFC does not attempt to define general-purpose traits that are intended to be implemented by non-primitive types, or to support code that wants to be generic over number types.

Motivation

Status quo as of Rust 1.39

The as keyword allows converting between any two of Rust’s primitive number types:

  • u8
  • u16
  • u32
  • u64
  • u128
  • i8
  • i16
  • i32
  • i64
  • i128
  • usize
  • isize
  • f32
  • f64

However the semantics of that conversion varies based on the combination of input and output type. The Rustonomicon documents:

  • casting between two integers of the same size (e.g. i32 -> u32) is a no-op
  • casting from a larger integer to a smaller integer (e.g. u32 -> u8) will truncate
  • casting from a smaller integer to a larger integer (e.g. u8 -> u32) will
    • zero-extend if the source is unsigned
    • sign-extend if the source is signed
  • casting from a float to an integer will round the float towards zero
  • casting from an integer to float will produce the floating point representation of the integer, rounded if necessary (rounding to nearest, ties to even)
  • casting from an f32 to an f64 is perfect and lossless
  • casting from an f64 to an f32 will produce the closest possible value (rounding to nearest, ties to even)

(Note: the proposed fix for the float to integer case is to make the conversion saturating.)

Additionally, the general-purpose From trait (and therefore TryFrom through the blanket impl<T, U> TryFrom<U> for T where U: Into<T>) is implemented in cases where the conversion is exact: when every value of the input type is converted to a distinct value of the output type that represents exactly the same real number.

The TryFrom trait is also implemented for the remaining combinations of integer types, returning an error when the input value is outside of the MIN..=MAX range supported by the output type. For this purpose usize and isize are conservatively considered to be potentially any size of at least 16 bits, to avoid having non-portable From impls that only exist on some platforms.

The table below exhaustively lists those impls, with F indicating a From impl and TF indicating (only) TryFrom. Rows are input types, columns outputs.

u8 u16 u32 u64 u128 i8 i16 i32 i64 i128 usize isize f32 f64
u8 F F F F F TF F F F F F F F F
u16 TF F F F F TF TF F F F F TF F F
u32 TF TF F F F TF TF TF F F TF TF F
u64 TF TF TF F F TF TF TF TF F TF TF
u128 TF TF TF TF F TF TF TF TF TF TF TF
i8 TF TF TF TF TF F F F F F TF F F F
i16 TF TF TF TF TF TF F F F F TF F F F
i32 TF TF TF TF TF TF TF F F F TF TF F
i64 TF TF TF TF TF TF TF TF F F TF TF
i128 TF TF TF TF TF TF TF TF TF F TF TF
usize TF TF TF TF TF TF TF TF TF TF F TF
isize TF TF TF TF TF TF TF TF TF TF TF F
f32 F F
f64 F

Preferring explicit semantics

When looking at code with a $expr as $ty cast expression, the semantics of the conversion are often not obvious to human readers. Deducing the type of the input expression usually requires looking at other parts of the code, possibly distant ones. In some cases it’s even possible to make the compiler infer the output type, with syntax like foo() as _.

It’s also possible for those types to change when a possibly-distant part of the code is modified. A cast that was previously exact could suddenly have truncation semantics, which might be incorrect for a given algorithm.

To avoid this, it’s preferable to use for example an explicit u32::from(foo) call instead of casting with as. In fact Clippy has a lint for exactly this (though a silent by default one).

In some other cases however, truncation or some other conversion semantics might be the desired behavior. Communicating that intent to human readers is just as useful then as it would be with a from call.

(Not yet) deprecating the as keyword

Because of the ambiguity described above, deprecating as casts entirely has been discussed before.

Providing an alternative with something like this RFC would be a prerequisite, but this RFC is not proposing such a deprecation.

Guide-level explanation

For the purpose of conversion semantics, Rust has two kinds of primitive number types: floating point and integer. This makes four combinations of input and output kind.

For a given conversion let’s call:

  • I the input type
  • i the input value
  • O the output type
  • o the output value, the result of the conversion: let o: O = convert(i: I);

Exact conversions

For combinations of primitive number types where they are implemented, the general-purpose convert::Into and convert::From traits offer exact conversion: o always represents the exact same real number as i.

The I::into(self) -> O method and I::from(O) -> Self constructor are available without importing the corresponding trait explicitly, since the traits are in the prelude.

Integer to integer

For all combinations of primitive integer types I and O, the standard library additionally provides:

  • The I::try_into<O>(self) -> Result<O, E> method and O::try_from<I>(I) -> Result<Self, E> constructor for fallible conversion.

    These are inherent methods of primitive integers that delegate to the general-purpose convert::Into and convert::From traits. Although these traits are not in the prelude, they do not need to be in scope for the inherent methods to be called.

    This returns an error when i is outside of the range that O can represent. The error type E is either convert::Infallible (where a From is also implemented) or num::TryFromIntError.

  • The I::wrapping_to<O>(self) -> O method for wrapping conversion, also known as bit-truncating conversion.

    In terms of arithmetic, o is the only value that O can represent such that o = i + k×2ⁿ where k is an integer and n is the number of bits of O.

    In terms of memory representation, this takes the n lower bits of the input value. The most significant upper bits are truncated off. (This is an a sense opposite of float-to-integer truncation where the less significant fractional part is truncated off.)

    For example, 0xCAFE_u16 maps to 0xFE_u8, and 130_u32 to -126_i8.

    Note: This is the behavior of the as operator.

  • The I::saturating_to<O>(self) -> O method for saturating conversion. o is the value arithmetically closest to i that O can represent. This is O::MIN or O::MAX for underflow or overflow respectively.

Float to float, integer to float

For all combinations of primitive number types I (floating point or integer) and primitive floating point type O, the standard library additionally provides:

  • I::approx_to<O>(self) -> O for approximate conversion.

    o is the value arithmetically closest to i that O can represent. Overflow produces infinity of the same sign as i.

    For floating point I, rounding may happen due to precision loss through fewer mantissa bits. For integer I, rounding may happen for large values (positive or negative).

    Rounding is according to roundTiesToEven mode as defined in IEEE 754-2008 §4.3.1: pick the nearest floating point number, preferring the one with an even least significant digit if exactly halfway between two floating point numbers.

    Note: This is the behavior of the as operator.

Float to integer

For all combinations of primitive floating point type I and primitive integer type O, the standard library additionally provides:

  • I::saturating_to<O>(self) -> O for saturating truncating conversion.

    The fractional part of i is truncated off in order to keep the integral part. That is, the value is rounded towards zero.

    Underflow maps to O::MIN. Overflow maps to O::MAX. NaN maps zero.

    Note: this may become the behavior of the as operator in a future Rust version.

  • I::unchecked_to<O>(self) -> O for unsafe truncating conversion.

    The fractional part of i is truncated off in order to keep the integral part. That is, the value is rounded towards zero.

    This method is an unsafe fn. It has Undefined Behavior if i is infinite, is NaN, or cannot be represented exactly in O after truncation.

    Note: This is the behavior of the as operator as of Rust 1.39, even though it can be used outside of any unsafe block or function.

Reference-level explanation

Everything discussed in this RFC is defined in the core crate and reexported in the std crate.

Exact, fallible, and unsafe truncating conversion conversions described above already exist in the standard library. FIXME: this assumes PR #66852 and PR #66841 are accepted and have landed.

Inherent methods are added that delegate calls to the corresponding trait method. They are generic to support multiple return types. Some of these impls are macro-generated, to reduce source code duplication:

impl $Int {
    // Added in https://github.com/rust-lang/rust/pull/66852
    pub fn try_from<T>(value: T) -> Result<Self, Self::Error>
        where Self: TryFrom<T> { /* … */}
    pub fn try_into<T>(self) -> Result<T, Self::Error>
        where Self: TryInto<T> { /* … */}

    pub fn wrapping_to<T>(self) -> T where Self: IntToInt<T> { /* … */}
    pub fn saturating_to<T>(self) -> T where Self: IntToInt<T> { /* … */}

    pub fn approx_to<T>(self) -> T where Self: IntToFloat<T> { /* … */}
}

impl $Float {
    pub fn approx_to<T>(self) -> T where Self: FloatToFloat<T> { /* … */}

    pub fn saturating_to<T>(self) -> T where Self: FloatToInt<T> { /* … */}

    // Added in https://github.com/rust-lang/rust/pull/66841
    pub unsafe fn unchecked_to<T>(self) -> T where Self: FloatToInt<T> { /* … */}
}

Four supporting traits are added to the convert module:

mod private {
    pub trait Sealed {}
}

pub trait IntToInt<T>: self::private::Sealed {
    // Supporting methods…
}

pub trait IntToFloat<T>: self::private::Sealed {
    // Supporting methods…
}

pub trait FloatToFloat<T>: self::private::Sealed {
    // Supporting methods…
}

pub trait FloatToInt<T>: self::private::Sealed {
    // Supporting methods…
}

Each trait has methods with the same signatures as inherent methods that delegate calls to them.

The sealed trait pattern is used to prevent impls outside of the standard library. This will allow adding more methods after the traits are stabilized. See Future possibilities below.

The traits are implemented for all relevant combinations of types. Again, some of these impls are macro-generated:

impl IntToInt<$OutputInt> for $InputInt { /* … */ }

impl IntToFloat<$OutputFloat> for $InputInt { /* … */ }

impl FloatToFloat<$OutputFloat> for $InputFloat { /* … */ }

impl FloatToInt<$OutputInt> for $InputFloat { /* … */ }

Drawbacks

This adds a significant number of items to libcore. However primitive number types already have numerous inherent methods and trait methods, so this isn’t unprecedented.

If the as keyword is never deprecated or until it is, we would in many cases have two ways of doing the same thing.

Rationale and alternatives

The “shape” of the API could be different. Namely, instead of inherent methods that delegate to supporting traits we could have:

  • Plain trait methods, with traits that need to be imported into scope. This less convenient to users.

  • Plain trait methods, with traits in the prelude. The bar is generally high to add anything to the prelude.

  • Non-generic inherent methods that include the name name of the return type in their name: wrapping_to_u8, wrapping_to_i8, wrapping_to_u16, … This causes multiplicative explosion of the number of new items.

This RFC however makes no active attempt at supporting callers who are themselves generic to support multiple number types. Traits are only used as a way to avoid multiplicative explosion.

This RFC proposes adding multiple conversions methods with various semantics even for combinations of types where they are “useless” because the conversion is always exact. For example, u8::wrapping_to<i32> and u8::saturating_to<i32> both behave the same as <u8 as Into<i32>>::into. This avoids the question of what to do about the portability of impls for usize and isize.

In the case of float to float conversion specifically, I = f64 and O = f32 is the only combination that is really useful. We could have only f64::approx_to(self) -> f32 instead of generic methods with a trait. Keeping a trait anyway makes this more consistent with the other kinds of conversions, and is compatible with a future addition of new primitives floating point types (f16, f80, …) in case those are ever desired.

Prior art

FromLossy and TryFromLossy traits rust-lang/rfcs#2484

Checked integer conversion rust-lang/rfcs#1218 Same approach of generic inherent methods with supporting trait(s). Postponed

FIXME<!--

Discuss prior art, both the good and the bad, in relation to this proposal. A few examples of what this can include are:

  • For language, library, cargo, tools, and compiler proposals: Does this feature exist in other programming languages and what experience have their community had?
  • For community proposals: Is this done by some other community and what were their experiences with it?
  • For other teams: What lessons can we learn from what other communities have done here?
  • Papers: Are there any published papers or great posts that discuss this? If you have some relevant papers to refer to, this can serve as a more detailed theoretical background.

This section is intended to encourage you as an author to think about the lessons from other languages, provide readers of your RFC with a fuller picture. If there is no prior art, that is fine - your ideas are interesting to us whether they are brand new or if it is an adaptation from other languages.

Note that while precedent set by other languages is some motivation, it does not on its own motivate an RFC. Please also take into consideration that rust sometimes intentionally diverges from common language features.

-->

Unresolved questions

FIXME<!--

  • What parts of the design do you expect to resolve through the RFC process before this gets merged?
  • What parts of the design do you expect to resolve through the implementation of this feature before stabilization?
  • What related issues do you consider out of scope for this RFC that could be addressed in the future independently of the solution that comes out of this RFC?

-->

Future possibilities

This pattern of API is extensible and supports adding more methods with different conversion semantics. For example:

  • Panicky exact integer to integer conversion. Basically .try_into().unwrap(). Could be worth dedicated methods with a short name (maybe cast?) if it is deemed common/useful/important enough.

  • Wrapping approximate floating point to integer conversion that wraps around instead of saturating. (But what to do about infinities and NaN?)

  • Fallible approximate floating point to floating point conversion that returns an error instead of mapping a finite value to infinity

  • Fallible approximate floating point to integer conversion that returns an error for NaN and instead of saturating to MAX or MIN.

  • Fallible exact conversion that never rounds and returns an error if the input value doesn’t have an exact representation in the output type, for some subset or all of:

    • Integer to floating point
    • Floating point to integer
    • Floating point to floating point

This RFC doesn’t explore which of these (or others) are useful enough to merit adding to the standard library.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment