Pre-RFC: Add explicitly-named numeric conversion APIs

I find the behavior of NaN being silently turned into 0 on float-to-int conversion very surprising. It feels like a major footgun. I suggest making this method failible, just like try_into(), and returning error on NaN and possibly +/-inf.

Looking through the original issue, it seems this has been already proposed and discussed in detail. It seems that the CPUs allow efficient detection of inf and NaN conversions to integer, too. It was not an option for as keyword since that must be infallible, but sounds like a great option for this API.

Bikeshedding ahead: It is also surprising to have modulo_to produce a negative value - anything called "modulo" should return only non-negative numbers in my mind. It is also inconsistent with try_into(). I suggest calling it truncate_into(). Also, use of the word "round" in float-to-float conversions is surprising as it's already reserved for turning a float into an integer - see e.g. the built-in round() function.

Other than that, looks very good to me! I would be glad to see this in the language.

8 Likes

It's in the types submodule: Link.

Thanks for the link! I've found the commit that made it pedantic, I'll go dispute it.

I really like this change, however I wouldn't be happy with the implementation proposed. I would love to be able to use these functions in a generic context just like I can use From/Into and TryFrom/TryInto. I think it would be better to have a generic like SaturatingTo<usize> rather than having to have two different methods for when it's IntToInt and FloatToInt. It would help reduce a lot of boilerplate that currently is only solved by writing macros, and personally I would like to be able to move away from writing macros for writing numeric generic code and towards using traits.

I also don't think these methods should be behind sealed traits as there are plenty of numeric types in the library space such as bignum that should also be able to implement these traits so they can feel and be used more interchangeably with the numeric types in the language.

4 Likes

I can get behind the NaN and +/-inf returning an error. Even quite explicit ones,

enum RoundError {
    Nan,
    InfinityPositive,
    InfinityNegative
}

impl f64 {
    type Output = u32;

    fn try_round_to<Output>(self) -> Result<Output, RoundError>;
}

Or something similar (since this RFC doesn't recommend traits, though I think that it should)

I agree that as should be replaced by more explicit methods. Below are just nits.

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.

Floating-point types are not subsets of the real number +/- Infinity and NaN. Also, there is -0. Is converting -0f32 to f64 preserve -0? Is it can be 0? Is a bit pattern of NaN preserved?

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

I find this naming confusing as modulo_to seems like a binary operation. I propose it I::bit_truncate_to.

Write that this method is only implemented if and only if the number of bits of O is less than the number of bits of I.

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.

The condition of the method is the same as above.

"Arithmetically close" is not a usual term and just "close" is enough I think. Also, the term "underflow" has a different meaning when used for floating-point numbers. Alternative terms are "negative overflow" and "positive overflow".

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.

In my opinion, the rounding mode should be more explicit. I propose to name it I::round_ties_to_even and preferably provide other rounding modes.

I’d rather not go into the merits of mapping NaN to zero. Please consider that part as a placeholder for “whatever as will do after https://github.com/rust-lang/rust/issues/10184 is fixed”. If you’d prefer as to have some other behavior, please propose it in that thread.

It’s quite possible that I’m using the word modulo incorrectly. I see that we already have APIs like wrapping_add, so wrapping_to is probably a better name.

Regarding to v.s. into I’ll refer to API naming guidelines. The Into trait has this name because it is general-purpose and also used with some input types that are !Copy. Primitive number types however are Copy, and taking self by value does not transfer ownership.

Would approximate_to or approx_to sound better than round_to? “Truncate” seems incorrect in that case since the approximation is to the nearest, not towards zero.

The standard library already makes it a non-goal to support code generic over number types. Before 1.0 it had Int and Float traits that were moved to the num crate. I feel changing that should come before supporting conversions in generic-number code. And it’s stuff for another RFC.

I also (and this is for the unwritten Prior Art section) wanted to depart from the FromLossy / TryFromLossy RFC which proposes general-purpose traits. IMO a big problem of that RFC is that it’s hard to give a single useful definition of “lossy” where there can be such variaety of conversion semantics for conversion between two given types.

Not supporting something like bignum is also deliberate: details of those precise semantics might not apply, and general-purpose is hard. A library it free to define its own APIs (including extension traits) for interacting with primitive number types.

3 Likes

That sounds like fallible rounding conversion discussed in Future possibilities.

This is an attempt at describing the existing behavior of as. The Nomicon only has this to say: “casting from an f32 to an f64 is perfect and lossless”

That’s what what this RFC proposes. The Alternatives section mentions it.

Sorry, I didn't read that section. But then

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 .

is not well defined.

Edit: I was wrong.

Could you say more about what’s wrong with it?

I see now that fallible float-to-int conversion is listed under future possibilities, and we probably do need an explicit method that matches the behavior of as, so I retract my objection. I still feel that fallible float-to-int conversion important enough and essential enough to pursue in tandem with the other methods described here, but it is not a blocker for this RFC.

approx_to does indeed sound better than round_to.

I feel like that argument is moving the goal posts of what I proposed. What I proposed would be no more generic or general purpose than TryFrom<usize> or using any of the ops traits both of which are already in the standard library.

I also don't accept the argument of supporting generic numeric code or interoperablity with library numerics being a non-goal when this is not something that is documented and something that was not decided in an open process.

To me this is an argument against including this API in the standard library. General purpose is hard, which is what this API is, even if it's the exact semantics of the as operator. So wouldn't it make sense for this API to also belong in num and not in std?

I don't see why this would be the special case, yes using the as operator is a pain, so is a lot of how the language special cases numerics, and in-completeness of operators having equivalent traits. Why is this one different?

Maybe I missed it, but I think that the conversions from float to int/float towards positive and negative infinity, as well as toward zero are missing. From what I saw, you only propose rounding toward nearest.

So in general this RFC does not immediately propose adding every kind of conversion there is, mostly those that are done with as today. And make so that more can be added later.

For those specifically, can they be implemented any more efficiently than chaining with .ceil(), .floor(), or .trunc()? If not, do you still think it’s wort having dedicated APIs?

I did some experimenting in this area and wrote the az crate, which uses traits.

  • You can write i.az::<O>() or cast::<I, O>(i) instead of i as O, with overflow panicking with debug_assertions or wrapping otherwise.
  • There are other traits/casts for checked, saturating, wrapping and overflowing conversions, e.g. i.checked_as::<O>().
  • For floating-point to integer conversions I chose truncation by default, but rounding is supported via a Round wrapper, so that
    assert_eq!(0.6f32.az::<i32>(), 0);
    assert_eq!(Round(0.6f32).az::<i32>(), 1);
    // Ties round to even.
    assert_eq!(Round(0.5f32).az::<i32>(), 0);
    assert_eq!(Round(1.5f32).az::<i32>(), 2);
    
  • I also wrote conversions into e.g Wrapping<i32>, which does wrapping.
  • For floating-point to integer conversions I used bitwise manipulation instead of intrinsic conversions as it wasn't clear what the guarantees where; this is probably much slower but every behavior is defined. And I was more interested in the API at this point.
2 Likes

I may miss something, but for me, .ceil(), .floor() and .truc() don't cover the same need.

// example with positive numbers
// 10.2 and 10.7 can round to 10 or 11 but not all functions give the same results

assert(approx_to<i32>(10.2_f64) == 10);
assert(approx_toward_infinity<i32>(10.2_f64) == 11);
assert(approx_toward_negative_infinity<i32>(10.2_f64) == 10);
assert(approx_toward_zero<i32>(10.2_f64) == 10);

assert(approx_to<i32>(10.7_f64) == 11); // different rounding
assert(approx_toward_infinity<i32>(10.7_f64) == 11);
assert(approx_toward_negative_infinity<i32>(10.7_f64) == 10);
assert(approx_toward_zero<i32>(10.7_f64) == 10);

// example with negative numbers
// likewise -10.2 and -10.7 can round to -10 or -11 but not all functions give the
// same results (except for the sign) than with positive numbers

assert(approx_to<i32>(-10.2_f64) == -10);
assert(approx_toward_infinity<i32>(-10.2_f64) == -10); // different rounding than with +10.2
assert(approx_toward_negative_infinity<i32>(-10.2_f64) == -11); // different rounding than with +10.2
assert(approx_toward_zero<i32>(-10.2_f64) == -10);

assert(approx_to<i32>(-10.7_f64) == -11);
assert(approx_toward_infinity<i32>(-10.7_f64) == -10);
assert(approx_toward_negative_infinity<i32>(-10.7_f64) == -11);
assert(approx_toward_zero<i32>(-10.7_f64) == -10);

Chaining function may work for floats into integers, but I don't think it is possible to do the same when converting f64 into f32 (or any big floats to a smaller float). Note that I only used integer in the above example, because it's easier to see what the result will be when rounding compared to the same operation with floats.

Also ceil() is like approx_toward_infinity, but only for positive numbers and it doesn't work for f64 to f32. If you want approx_toward_positive_infinity for negative numbers, you need to use floor() with the same limitation.

1 Like

One thing that is missing from both the current and proposed APIs is a way to do ergonomic casts that panic on out of bounds / overflow. Yes, I can litter my code with unwrap() calls, but there is a reason that Vec supports indexing rather than requiring get().unwrap() calls everywhere: the common option should ideally have the most concise syntax. This absence is especially annoying for u64 <--> usize conversions since in many cases realistically my code is never going to run on anything other than a 64-bit machine, yet I still have to spend mental cycles thinking about it.

That is not how .ceil() , .floor() and .truc() work, they are exactly what you are asking for. I have seen the names misused in the way you described (old versions of Excel), but most programing language (including Rust) follow their mathematical definition.

There is no equivalent for f64 to f32, but I don't see any use them. If someone can provide a good use then we can consider add them in the future.

2 Likes

Prior art:

That RFC proposed what this RFC proposes, basically, and was rejected because library team wanted to try to do it with a trait first (TryFrom).

Differences:

  • cast was used instead of to (wrapping_to -> wrapping_cast).
  • The Option-returning operation followed the analogy with arithmetic operations too (checked_add -> checked_cast, not try_into).
  • The RFC also introduced a simple cast operation that panicked on failure, like simple arithmetic operations (add -> cast).
  • Conversions involving floats weren't considered.

(I still think that RFC did everything right :p)

5 Likes