Pre-RFC: Trait for Coercion of untyped numeric literals

You can create NonZero numeric types and check at compile time that they are in fact, nonzero like so:

const { NonZeroU8::new(4).unwrap() }

But, it requires using a const block and an unwrap. It would be great to be able to just have a literal like 4 coerce into a NonZeroU8 similarly to how a literal such as 4 can coerce into a u8 or a u32

I'm proposing some kind of mechanism to allow users to define automatic coercion from an untyped numeric literal (such as 4, -11, but not 4u8) into an arbitrary type. For example via some const Trait.

Then, types such as NonZeroU8, NonZeroU32 could implement this Trait. You could then freely use the untyped numeric literals in places where NonZeroU8 and etc are expected.

An example:

use std::num::{NonZeroU8, NonZeroU16};

fn takes_nonzero_u8(n: std::num:::NonZeroU8) {}

fn main() {
    // 4 can coerce into a NonZeroU8, which is checked for correctness at compile time
    let a: NonZeroU8 = 4;
    takes_nonzero_u8(44);
    // this would not compile:
    let b: NonZeroU8 = -4;
    // similar to:
    // let b = const { NonZeroU8::new(-4).unwrap() }
}

Example definition of the u8::Coerce trait:

#[const_trait]
trait Coerce {
    fn coerce(n: u8) -> Self;
}

Example Implementation and usage of u8::Coerce for a struct NonZeroU16:

struct NonZeroU16 {
    inner: u16,
}

/// Any numeric type that can coerce to
/// a u8 will also be allowed to coerce to a NonZeroU16
impl const u8::Coerce for NonZeroU16 {
    fn coerce(n: u8) -> Self {
        if n == 0 {
            panic!("Cannot be zero");
        }

        Self { inner: n as u16 }
    }
}

fn main() {
  // compiles
  let a: NonZeroU16 = 4;  
  // compile error: expected NonZeroU16, found u8
  let a: NonZeroU16 = 4u8;
  // compile error: Cannot be zero
  let c: NonZeroU16 = 0;
}

This trait (u8::Coerce) would only work on untyped numeric literals that can coerce to a u8. All of the below examples will fail to compile:

fn main() {
  let a: u16 = 4;
  // compile error: expected NonZeroU16, found u16
  let b: NonZeroU16 = a;
  // compile error: expected NonZeroU16, found u16
  let c: NonZeroU16 = 4u16;
  // compile error: 1000 cannot coerce to u8
  let d: NonZeroU16 = 1000;
  // compile error: expected NonZeroU16, found u16
  let e: NonZeroU16 = 4_u8 as u16;
}

However, if we also implement u16::Coerce then those examples will compile:

impl const u16::Coerce for NonZeroU8 {
    fn coerce(n: u16) -> Self {
        if n == 0 {
            panic!("Cannot be zero");
        }
        Self { inner: n }
    }
}

fn main() {
  // compiles now
  let d: NonZeroU16 = 1000;
  // literals that can coerce to *either* u8 or u16 can
  // coerce to a NonZeroU16
}

Note: Since we implemented both u16::Coerce and u8::Coerce for NonZeroU16, we could remove the u8::Coerce implementation as untyped literals that can coerce into a u8 can also coerce into a u16.

Summary

We would add the following traits:

  • std::u8::Coerce
  • std::u16::Coerce
  • ...
  • std::i8::Coerce
  • std::i16::Coerce
  • ...
  • std::f16::Coerce
  • std::f32::Coerce
  • ...
  • std::usize::Coerce
  • std::isize::Coerce

When some type T implements $num_type::Coerce (where $num_type is any of the numeric types u8, i8, f32 ... ), any untyped numeric literal (Such as 4, -12.4, but not 100_u8) that can coerce to $num_type will be able to be implicitly converted to T by using $num_type::Coerce::coerce

Advantages

  • Improves ergonomics when creating number literals that must adhere to specific invariants checked at compile time
  • Allows implementing this trait for custom structs, such as a NonZeroU32 that must never be zero or EvenU32 that must always store an even number.
  • Does not rely on new syntax (such as adding new numeric type suffixes)

Disadvantages

  • Verbose implementation: Adds a new trait to the standard library for each numeric type
  • Adds more implicit coercions to the language
  • Relies on #![feature(const_trait_impl)]
  • Cannot be implemented in Rust, requires changes to the compiler itself

Alternatives

  • Specifically for NonZero*, we could add custom suffixes such as 16_nzu8 for std::num::NonZeroU8. This means we will lose the flexibility of a generic implementation, as well as adding extra syntax
  • Do nothing, and when we want to create a type like NonZeroU8 use const { NonZeroU8::new(4).unwrap() }

Prior art:

2 Likes

Could we instead say that there's only FromIntegerLiteral and FromFloatingLiteral traits?

In a way, make {integer} a real thing -- it'd be basically impl FromIntegerLiteral.

10 Likes

This is what haskell does, and it works pretty well.

Long term this is absolutely what should happen. I've toyed around with it in the compiler previously and ran into a number of roadblocks (in part due to lack of knowledge with that part of the compiler). With that said, for the immediate future, I suspect there is sufficient desire for nonzero literals that NonZero could simply be made a lang item and be special cased. Previously this would have been a pain due to the various types, but since they were merged into one generic type, it is appealing to me.

2 Likes

This would also be nice for the Wrapping and Saturating wrappers.

1 Like

Writing code that you want to be generic over number type (such as if you want a library user to specify the width) generally isn't too bad except for literals. Instead you have to do things like T::from_i32(2).unwrap() or T::one() + T::one(). Just being able to say T: FromIntegerLiteral { let x: T = 2; } would be so much nicer, especially since the FromIntegerLiteral can be mixed in with the other train bounds being used.

It would also make dealing with third party numeric types nicer, like BigInt, Ratio, Posit, Complex, etc. Wrapping and Saturating are pretty much in this same category, but happen to be located in core::num rather than a third-party crate.

Ratio and Posit would have a potential complication with implementing FromFloatLiteral since if they get passed an f64, or even an f128, the float could get rounded before being passed to the coercion, even if the target type can represent the value more exactly. The most trivial case of this would be x: Ratio<i32> = 0.1;, since 0.1 has a non-terminating binary representation, meaning it can't fit in even an f128, but a rational type could hold it exactly. Passing the raw string would mean that the conversion function would need to do string parsing though, which is a bit complex for what should be a compile-time operation.

I expect that all of these traits would give raw access to the infinite precision value, perhaps as the original string, perhaps as the digits, perhaps as a special infinite-precision type.

That's not just important for floats; a u256 obviously wants it as well.

2 Likes

I think even for integer literals there is a rather large design space. Should the literals be already parsed? If not, how to deal with bin/oct/hex literals? How to deal with overflow which is right now handled via a deny by default lint?

The most flexible is probably something like

const trait FromDecimalLiteral {
    type Overflow;
    const fn from_literal(&[DecimalDigit]) -> Result<Self, Self::Overflow>;
}

Where the Err variant is the overflow result.

Yes, but it's an extensible one.

The trait would take some can't-be-constructed-by-user-code type, and we can start with .to_u32() and .to_u128() and such, since we want easy things to be easy, but we can also add .raw_token_string() or .hex_digits_be() or whatever later as needed.

1 Like