rfcs icon indicating copy to clipboard operation
rfcs copied to clipboard

RFC: const ergonomics for NonZero<T>

Open sandersaares opened this issue 8 months ago • 36 comments

The std::num::NonZero<T> type allows non-zero integer semantics to be clearly expressed. Yet this type is only seamlessly usable if all APIs with non-zero semantics use this type, due to required to/from conversion at any API boundary that differs in its use of NonZero.

The burden of these conversions is especially heavy in tests and examples. This RFC proposes new coercions to facilitate implicit conversion to NonZero from integral constants, simplifying usage in tests and examples, where succinctness and readability are paramount.

Rendered

sandersaares avatar Mar 07 '25 09:03 sandersaares

TBH what I really want for this is a const Trait-based approach that's extensible for everyone. I want someone writing a u256 type to be able to have an automatic-for-the-users conversion from literals too. And for let x: EvenU32 = 3; to trigger a panic! in const, resulting in a nice compile-time error message.

scottmcm avatar Mar 07 '25 20:03 scottmcm

TBH what I really want for this is a const Trait-based approach that's extensible for everyone. I want someone writing a u256 type to be able to have an automatic-for-the-users conversion from literals too. And for let x: EvenU32 = 3; to trigger a panic! in const, resulting in a nice compile-time error message.

Languages with prior art for this (albeit none restricting it to compile time) include Swift and Haskell (and some of its derivatives, like Agda). Julia also has a precedent but by different means (user-defined conversion and promotion semantics).

pthariensflame avatar Mar 07 '25 21:03 pthariensflame

TBH what I really want for this is a const Trait-based approach that's extensible for everyone.

I think that would need the source text including the sign to be passed into the const Trait, so you can properly handle things like let v: NonZero<i8> = -0x80; (only valid when you know it's negative) or let v: u256 = 0x1_00000000_00000000_00000000_00000000; (too big for u128). it would be nice to allow it to handle custom suffixes too, e.g. 0x123_hdl_u12 which I'm using in fayalite to create a Expr<UInt<12>> literal.

maybe have an API like:

pub ~const trait FromIntLiteral<const SUFFIX: str>: Sized {
    ~const fn parse<E, F: ~const FnOnce(fmt::Arguments<'_>) -> E>(negative: bool, text: &str, parse_error: F) -> Result<Self, E>;
}
pub ~const trait FromFloatLiteral<const SUFFIX: str>: Sized {
    ~const fn parse<E, F: ~const FnOnce(fmt::Arguments<'_>) -> E>(negative: bool, text: &str, parse_error: F) -> Result<Self, E>;
}
// maybe have `FromStringLiteral` too?

that way you can try to parse things at runtime without panicking on parse failure, and you can just pass in a function that panics at compile time.

example:

pub struct UInt<const N: usize>(...);
pub struct SInt<const N: usize>(...);
pub struct Expr<T>(...);

impl ~const FromIntLiteral<*"hdl_u12"> for Expr<UInt<12>> {
    ...
}

impl ~const FromIntLiteral<*"hdl_i5"> for Expr<SInt<5>> {
    ...
}

pub fn f() {
    let a = 123_hdl_u12; // has type Expr<UInt<12>>
    let b = -0x10_hdl_i5; // has type Expr<SInt<5>>
}

programmerjake avatar Mar 07 '25 22:03 programmerjake

Definitely like the idea of a fully generic solution, although it feels like we're still a ways off from being able to accomplish that.

That said, I think that there's a clear issue with literal suffixes here, and that's that a lot of people (myself included) use them to remove type ambiguity, but this change would effectively break that. Now, for example, 1u32 could be either u32 or NonZero<u32> and it's not possible to explicitly clarify which one you want, even though we know that 0u32 is unambiguously u32.

I'm not 100% sure if this is a particularly big issue (especially since it would probably default to the specified suffix type if it's ambiguous, rather than choosing NonZero) but it feels like this is a main blocker to any proposal of this kind being merged. Specifically, it should decide whether NonZero is always coercible from literals of their respective types, or if there should be dedicated nz* suffixes (like nzu32) to explicitly clarify that. I don't like the idea of the nz* suffixes, but it feels like that should be figured out as part of this RFC.

Also: it's worth saying that since NonZero::new and Option::unwrap are now both const-stable, you can always do const { NonZero::new(x).unwrap() } to get nonzero constants, even though it's substantially more verbose than what's proposed. So, it's not about being able to have these constants as much as it's making it easier to make them. And I will admit to doing stuff like const ONE: NonZero<u32> = NonZero::new(1).unwrap(); because it's obnoxious to type.

clarfonthey avatar Mar 08 '25 14:03 clarfonthey

I love this proposal! I was surprised when I was learning rust and discovered this didn't exist.

This means that tests and examples are much more noisy than real-world usage for an API that uses NonZero, giving a false impression of API complexity and discouraging API authors from using NonZero despite its advantages.

This has been my exact thought process multiple times, I usually code my own nonzero! macro for my programs, so if this RFC is approved (even if you go down the macro path) it will improve my "user experience" greatly.

neeko-cat avatar Mar 09 '25 23:03 neeko-cat

On suffixes: I think we shouldn't add any more of those, but should instead find a way to make an ascription syntax that people can be happy with.

While it's true that we had to get rid of x:i32 because of the typo-proneness, that problem doesn't come up the same way with literals since 3::foo isn't ever a valid path. So, spitballing, we could say that 5:MyOddU24 is legal (giving that : extremely high precedence) and then we never need to add more suffixes to tokens ever again.

(AKA the compiler only calls the hypothetical FromIntegerLiteral trait for things without suffixes. The suffixes that exist remain for back-compat and always give the builtin types. So 3_u32 would in effect -- if perhaps not in implementation -- get translated to 3:u32.)

scottmcm avatar Mar 10 '25 07:03 scottmcm

Based on initial comments received, I have adjusted the RFC text to remove coercion from constant variables (limiting proposed behavior to only literals), and to clarify some open topics the initial draft failed to address, defaulting to relatively conservative viewpoints to start from until/unless the discussion suggests more ambitious moves are necessary:

  • For suffixed literals like 0u32 the coercion would only apply if the T in NonZero<T> is inferred or explicitly the same as the type in the literal suffix.
  • The coercion only applies to literals, so expressions like 123 | 456 would not be coerced to NonZero (and neither would the literals within). In other words, behavior in case of expressions (even if const-folded) is unchanged and trying to use 123 | 456 as a value for a NonZero would be an error, as it is today.

This still seems to satisfy the main mission of making tests and examples more readable, while avoiding complexity in corner cases and avoiding new syntax/suffixes.

sandersaares avatar Mar 10 '25 08:03 sandersaares

The "only literals, not expressions" logic has some interaction with https://github.com/rust-lang/compiler-team/issues/835 in that if literals do not have signs, this coercion would be limited to positive values only, which would be imperfect (though still valuable).

sandersaares avatar Mar 11 '25 08:03 sandersaares

The "only literals, not expressions" logic has some interaction with rust-lang/compiler-team#835 in that if literals do not have signs, this coercion would be limited to positive values only, which would be imperfect (though still valuable).

the API I proposed in a thread above basically treats literals as a signed literal where conversion from a literal to a user-defined type has separate inputs for the sign and for the literal's text. basically, if negation does something weird, the conversion function knows the input was negated (even if it's -0), so can properly apply whatever weird thing you want negation to do.

programmerjake avatar Mar 11 '25 08:03 programmerjake

I agree with @clarfonthey wrt literal suffixes and explicitness - in the "alternatives" section about suffixes, I feel like the theoretical allowedness is a bit the wrong way round, and I'd rather see:

takes_nz(1); // ok
takes_nz(1_u32); // error: u32 always means u32
takes_nz(1_u32_nz); // ok

Having a new int literal coercion that doesn't have an explicit suffix associated seems like it could lead to some annoying inconsistencies, e.g. a macro that takes a $x:literal that it expects to have a suffix to determine type inference, though that might be pretty niche.

coolreader18 avatar Mar 13 '25 07:03 coolreader18

On suffixes: I think we shouldn't add any more of those, but should instead find a way to make an ascription syntax that people can be happy with.

While it's true that we had to get rid of x:i32 because of the typo-proneness, that problem doesn't come up the same way with literals since 3::foo isn't ever a valid path. So, spitballing, we could say that 5:MyOddU24 is legal (giving that : extremely high precedence) and then we never need to add more suffixes to tokens ever again.

I think the type ascription approach is really powerful, though I would argue that implicit coercion should perhaps still be on the table, regardless, as type ascription for every argument will still be quite verbose.

That is, if we imagine a FromIntegerLiteral trait, implemented for NonZero<T>, compare:

explicit(1:NonZero, 2:NonZero, 4:NonZero, 5:NonZero);

implicit(1, 2, 4, 5);

The literals are drowned out in the first case! Hence, much like type suffixes are typically rare in code thanks to type inference, I think there's a strong argument for coercion here so long as type inference has figured out the argument types.

Note: and yes, syntax wise, I do find type ascription preferable to suffixes too; using suffix literals in C++ is painful as the conversion operators need to be imported separately, and sometimes clash when you try and import a bundle of them... it's also always not clear which operator creates which type.


I would guess we'd need the fully family, then: From[Integer|Float|Str|Bytes]Literal.

And in terms of signature, I think it could be interesting to support "raw" literals in them. That is, even for the Integer|Float case, passing the token string, rather than the pre-parsed integral:

  • It's easy enough for the implementer to call .parse() and deal with the output.
  • It's impossible for the implementer of the trait for a BigNum, or a Decimal type, to use the built-in types. The former suffers from even i128 and u128 being too short, the latter suffers from f64 having already lost the precision.

matthieu-m avatar Mar 13 '25 18:03 matthieu-m

+1 on the eventual FromIntegerLiteral const trait that lets you make arbitrary types from integer literals via parsing a token string, but (as an outsider who isn't in the loop on a lot of things) it looks to me like that would be a long ways off, and this is something that could be concretely done now to improve things while also being backwards-compatible with the future implementation (assuming you allow implicit type inference when the exact type can be inferred) (except maybe not allowing 5_u32 to be inferred as NonZero<U32>, that feels weird to me). So I'd love to see this happen as a quick-and-easy improvement (at least, I hope this would be quick and easy) while the necessary const trait infrastructure and such takes time to mature.

JarredAllen avatar Mar 13 '25 21:03 JarredAllen

Some way of using number literals to safely create some types (like NonZero, BigInts, and so on) could be good to have (as long as it's simple to use, to understand and it's safe). I even opened a ER that asks for a little macro that avoids the unwrap for compile-time NonZero values. But some people are working at introducing pattern types in Rust, like "u8 is 1 ..", that probably will never really deprecate the NonZero std lib types, but I think the design of everything in this issue should be seen in a wider contest of adding pattern type literals too.

leonardo-m avatar Mar 15 '25 10:03 leonardo-m

I was skeptical of the special casing of the RFC as of writing, even though I totally run into this everywhere (not just tests as the RFC repeatedly emphasizes, if it were just tests I would just use macros and short functions like my awint::bw function). However, after reading the literals-only const trait suggestions from the comments, I am 110% onboard with this. This would be a huge deal for my const-capable bigint crate https://crates.io/crates/awint and some of my other crates and strategies. If it is general enough, I could straight up write literals like -246_i100 and have them converted to my InlAwi type instead of always needing to write inlawi!(-246_i100). I really want such a trait to handle my arbitrary fixed point syntaxes like -0x1234.5678_p-3_i32_f16 (https://docs.rs/awint/0.18.0/awint/struct.ExtAwi.html#impl-FromStr-for-ExtAwi). Preferably, before this happens we would fix the issue where hex float literals can't parse at all, and of course we need those const traits. I would be fine with a forwards compatible thing that only works with NonZero<T>, but we must make sure there aren't any weird edge cases if it were rewritten to use the future trait.

AaronKutch avatar Mar 15 '25 20:03 AaronKutch

I like the RFC and would find benefit from it.

I don't like most of the taking it further discussions. Consider:

// Doesn't matter what this does, just that `+` isn't normal addition.
const WEIRD: MyWeirdInt = 123 + 456;

Versus:

const INTERMEDIATE: u32 = 123 + 456;
const WEIRD: MyWeirdInt = INTERMEDIATE;

Now to be clear I recognize that everyone is only discussing literals right now. But the refactor is something one would expect to work. I would argue against Rust having custom implicit conversions. But if it did have custom implicit conversions they should really work everywhere and the obvious refactors shouldn't change the meaning of things.

I don't believe this problem comes up often enough but if someone convinced me it did I would instead propose raising or re-raising custom string suffixes e.g. "123"mytype or somesuch (which isn't an argument against this RFC, It's fine to have the occasional special thing in stdlib imo). Such an approach at least makes it clear that there is magic, and it is easy to explain that it desugars to e.g. mytype::new("value") or some callout to a macro.

I see other problems with a generalized approach, but they're off topic.

ahicks92 avatar Mar 17 '25 02:03 ahicks92

Versus:

const INTERMEDIATE: u32 = 123 + 456;
const WEIRD: MyWeirdInt = INTERMEDIATE;

Now to be clear I recognize that everyone is only discussing literals right now. But the refactor is something one would expect to work.

well, imo its something one would expect not to work, if you have type MyWeirdInt = u64 it doesn't work in Rust now.

I would argue against Rust having custom implicit conversions.

I agree custom implicit conversions are generally bad. [^1] [^1]: though there could be exceptions -- e.g. custom &T -> &DynTraitButForCPlusPlus.

The way I see it, literals can be one of many types, and the actual type they have is deduced from how they're used, so 123 + 456 could very well be a u32 or a i8 but only if they're deduced to be a i8 and then they can't also be a u32 at the same time -- so each literal (after macro expansion and type deduction) has only one type, and they behave like that type, no other types. All we're doing is expanding the set of possible types literals can end up being. The FromIntegerLiteral trait is used only so Rust can figure out what to do for a literal of whatever type the literal deduces to -- so FromIntegerLiteral is a conversion, but only in the sense that writing 1234u32 is a conversion by the compiler from the string "1234" to the type u32.

programmerjake avatar Mar 17 '25 03:03 programmerjake

I don't like most of the taking it further discussions. Consider:

// Doesn't matter what this does, just that `+` isn't normal addition.
const WEIRD: MyWeirdInt = 123 + 456;

Versus:

const INTERMEDIATE: u32 = 123 + 456;
const WEIRD: MyWeirdInt = INTERMEDIATE;

I think the consensus is both are not going to work. In the first case you need explicit conversion make it clear whether you want u32::add or MyWeirdInt::add.

// assuming `MyWeirdInt: const Add`.
const WEIRD: MyWeirdInt = (123 as MyWeirdInt) + (456 as MyWeirdInt);

kennytm avatar Mar 17 '25 03:03 kennytm

@programmerjake They are obviously different at a close look if you know the semantics. I'm not disputing that.

But I am not a fan of it anyway. Different code should be clearly different. It should be easy to spot NFC.

Literals are sort of a weird any-type case (I don't actually know how Rust formalizes this) but + always means the same thing today even across the boundary of const vs non-const. In "weird" cases the chain at least always obviously starts with something "weird".

The reason I like this RFC anyway is because of all the times I've avoided NonZero just because it is annoying to have your nice fancy config struct or whatever be easy to fill out or the like. But I think that supposing it has a future direction to generalize the mechanism either in the sense of evaluating expressions or in the sense of supporting user-defined types is dead on arrival unless someone is very very clever. So from my perspective the open question is whether or not this special case always being special is worth it. I genuinely don't know how I'd answer that question. The RFC itself acknowledges easy alternatives and makes me feel silly for not just writing const fn nz(x) => NonZero::new(x).unwrap() somewhere in my code to be honest. I'm a good enough coder I should have found that on my own.

ahicks92 avatar Mar 17 '25 18:03 ahicks92

Updated RFC text to address a few points raised in recent weeks:

  • Clarified that the coercion does apply for unary negation expressions (e.g. -123 is a valid candidate for coercion).
  • Adjusted suffix handling to only permit the coercion if there is no suffix (e.g. 123u32 already has a type and does not get coerced to NonZero<u32>).

sandersaares avatar Mar 24 '25 07:03 sandersaares

Is your intention that the following would be legal?

let x = 5;
let y: NonZeroU8 = x; 

Because that's an order of magnitude more impl work than all the other examples shown in this RFC.

oli-obk avatar Mar 31 '25 08:03 oli-obk

If "you" refers to me then no - the RFC is intended to only be scoped to (optionally negated) integer literals, so in this case it would need to be let y: NonZeroU8 = 5 to be applicable.

Is there something that suggests otherwise? Perhaps I can clarify the text accordingly.

sandersaares avatar Mar 31 '25 08:03 sandersaares

Cool. Yea maybe add it to the future possibilities section. And add an example to the reference section stating that it's explicitly not supported

oli-obk avatar Mar 31 '25 08:03 oli-obk

Is your intention that the following would be legal?

let x = 5;
let y: NonZeroU8 = x; 

I'd expect that to be legal...both x and y should have type NonZeroU8.

Because that's an order of magnitude more impl work than all the other examples shown in this RFC.

why would that be more work? wouldn't the type deduction semantics be similar to:

let x = lit.into();
let y: NonZeroU8 = x;

programmerjake avatar Mar 31 '25 09:03 programmerjake

wouldn't the type deduction semantics be similar to:

We'd need to either go the route where we allow this for all types that implement specific traits or add more magic to Inference. Just using the type hint within a single expression is 15 LOC in a single location with no new traits or type system logic

Same reason

let x = 62 as char;

compiles, but

let x = 62; 
let y = x as char; 

Does not

oli-obk avatar Mar 31 '25 09:03 oli-obk

wouldn't the type deduction semantics be similar to:

We'd need to either go the route where we allow this for all types that implement specific traits or add more magic to Inference. Just using the type hint within a single expression is 15 LOC in a single location with no new traits or type system logic

well, I think most of the benefit is when using APIs that have arguments or fields that are NonZero, where you don't want to have to have a separate let with a type annotation just so you can have new literal types...so I've been expecting we'd want to change it to have an unstable trait Literal that has impls for all integer types and this RFC just adds impl Literal for NonZero, or something 100% equivalent to that.

if you want to restrict literals to not have just as powerful type deduction for NonZero as for primitive integer types, so type deduction can't work through two lets, then I think we should not accept that as making the language too inconsistent and special-cased.

so, since this works:

    let a = 45;
    let b: u8 = a;

I'd expect this to too, since the literal's type is essentially a type variable:

    let a = 45;
    let b: NonZeroU8 = a;

programmerjake avatar Mar 31 '25 09:03 programmerjake

let x = 62 as char;

tbh i'm surprised that compiles...

programmerjake avatar Mar 31 '25 09:03 programmerjake

Just want to make sure what is being proposed. Because what @programmerjake is asking for needs a lot more reference level explanations and some guide level explanations

oli-obk avatar Mar 31 '25 09:03 oli-obk

since this works:

    let a = 45;
    let b: u8 = a;

That works because integer literals not matching their type's range is just a lint.

oli-obk avatar Mar 31 '25 09:03 oli-obk

since this works:

    let a = 45;
    let b: u8 = a;

That works because integer literals not matching their type's range is just a lint.

why would lints matter here? all of a and b and the literal 45 have (deduced) type u8 here...this makes me think there's a misunderstanding here about something...

programmerjake avatar Mar 31 '25 10:03 programmerjake

let a = 45; gives a and the 45 an inference type, not type u8. After the fact, the let b: u8 = a; turns that inference type into a concrete u8. But any processing of the literal has already happened. This is obviously just an implementation detail, but literals are so common, changing this is both

  • likely to cause inference changes in other situations and,
  • likely to cause compile-time performance regressions

oli-obk avatar Mar 31 '25 11:03 oli-obk