`u32` as a second fallback type

Sometimes I want to implement a trait for multiple unsigned types.

Examples from the standard library (if it was designed from scratch):

  • std::ops::Shl<Rhs> only really makes sense for unsigned Rhs types
  • Iterator::take(n) would make sense for any unsigned n, not just usize, for instance you may want to have 64-bit n on a 32-bit platform so usize is not sufficient

Changing the standard library like this wouldn't be backwards compatible, but similar considerations apply to other code elsewhere.

One problem with this is that if an integer literal is valid for multiple types but not for i32, then there is no default. So if Iterator::take supported multiple unsigned types, you wouldn't be able to just say .take(5).

A solution to this is to have second fallback type: if i32 doesn't work, but u32 works, use u32.

This seems backwards compatible to me, since it only affects scenarios that currently don't compile.

3 Likes

With regards to Iterator::take(n). I think that with specialization it might (I am not sure of this point) be possible to add a second larger unsigned integer.

However for the << operator. That is generic so any types (modulo orphan rules) can be used. That is by design.

The core problem here is that "fallback" goes very poorly with inference -- it gets particularly hairy when there are multiple places where the fallback could be applied, since it could matter which of the two places get the fallback.

I definitely want something to fix more of this. For example, I want to allow indexing by all the unsigned types. But that wants it to fallback to usize not u32, for consistency with existing code, in those cases.

Couldn't an algorithm be designed such that if there is an ambiguity depending on which of the two places would get the fallbacks applied first, it gives up and fails to typecheck?

I have been doing some experiments to see how the i32 fallback works in tricky cases, and it already seems to fail in tricky scenarios, even in ones where there is only one solution:

trait Trait<T> { fn f(&self, x: T) {} }

impl Trait<i64> for i32 {}
impl Trait<i64> for i64 {}

fn foo() {
    let a = 5;
    let b = 8;
    a.f(b);
}

The compiler wants to assume that b is i32 when really the only possibility is that it's i64, and it fails to resolve this.

Maybe with the new Chalk trait solver there is a way to define it formally and design an algorithm?

Regarding array indexing, I don't really mind having to use usize only, similar to how adding u8 to u32 doesn't automatically promote u8 to u32. At least this protects you from a common mistake where you use u32 for arrays everywhere and suddenly your program stops working when the data gets larger than 4GB.

The problem is that it often doesn't provide much protection, as often what people will do is say "well I know it fits in u32" and just to [i as usize] (since they're not planning on running on 16-bit ever).

And similarly, if they're using u64 file positions as also indexes, it'd be better for a really-big file to fail to index on 32-bit, rather than doing something weird when people [i as usize] and it wraps.

Basically, most often the "correct" thing to do is [i.try_into().unwrap()], but nobody every wants to type that. So it should just work. Sure, a[u128::MAX] will always fail, but so will a[usize::MAX] -- the type check isn't really gaining anything, since it's not a dependant type where its width is exactly the right size to correspond to valid indexes.

3 Likes

What I find weird is that the "as" operator silently truncates even in debug mode, where the rest of the language fails on overflow in debug mode. If somebody is using "as" pervasively, that's a big code smell to me.

1 Like

I'd love to have a .truncate::<Type>() function for the cases that really do want truncation. If that becomes widely enough used, then perhaps one day we'll be able to migrate as in debug mode to check overflow.

It's unfortunate when the wrong thing is easier to write and easier to read than the right thing. as usize and as u64 and as u32 are short and simple. The equivalent using try_into requires at least .try_into()?, possibly also a type annotation depending on context (not in indexing, but in other contexts), doesn't show the type, and may potentially be the first thing in an otherwise infalliable function that requires changing it to return a Result.

There are a few things we could potentially do here:

  • Finish the safe-transmute work.
  • Support type ascription.
  • Support declaring runtime constraints that broaden the usability of .into() (most code can assume usize is at least 32 bits, and some code can assume usize is 64 bits).

(That said, please don't take this post as support for using types other than usize as indexes. Strict types for indexing is a pain, but it's a pain because it surfaces real issues that we shouldn't necessarily hide.)

2 Likes

Another one is finish portability lints, so that [0u32.into()] can work for indexing.

I'm annoyed when Rust insists my program must be compatible with 16-bit architectures when just my exe takes 150MB.

4 Likes

That was what I mean by constraints that broaden the usability of .into(). You should be able to declare that your code will never run on 16-bit platforms, and then you should get appropriate From/Into instances.

For what it's worth, even std::u32::MAX is larger of a number than can actually be used as the size for a static array of u8 in Rust. That is, even on x86_64-unknown-linux-gnu in Release mode it will result in an immediate stack overflow.

I'm not sure if there's any Rust target where the range covered by the platform's usize is not significantly larger than the range of stack-allocated array indices that could ever actually exist on said platform.

So any benefits of strict usize-based indexing are largely only applicable to heap-based data overall, I'd say.

That's true for the default stack size. It's possible to create a thread with a larger stack, though.

I ran into some interesting bugs when trying to create a thread with a stack that large, though. (Working on characterizing and reporting those bugs.)

EDIT: Regressions with large (2-4GB) stack arrays on large stacks · Issue #83060 · rust-lang/rust · GitHub

There's also gargantuan static data arrays to consider; these could easily come up in scientific code, particularly if it's being converted from Fortran.

I can definitely see the arguments for needing usize for indexing slices. I personally don't find them that persuasive -- especially with the precedent of things like shifting, where even << u16::MAX is more than I expect will ever be useful, but << u128::MAX is allowed -- but I understand the point of view.

I think it's much harder to argue that only usize should be supported for indexing a [_; 16], though. And especially for LUTs like [_; 256], indexing by u8 is really elegant.

2 Likes

I would love this, but when I have seen it mentioned in the past the kind of "shortest path" fix would need some kind of default type hinting. The (technically minor) breakage for all code that resembles

let i = 2;
let x = xs[i];

is pretty severe. So it would be nice in that case if the type of i were inferred to be usize.

In this case you'd be using too large a type, which is not as much of a problem as using too small a type is. I would prefer that shifts only supported u32 though. Incidentally, I find shifts dangerous for a different reason: shifting u32::MAX >> 32 fails! That is really wrong and dangerous, it should just be 0.

I agree that for small arrays it would be fine.

However the u8 lookup table case could be solved even better by having a user-defined type to do just that. It would be better because it would only allow u8 as keys, which is appropriate for that use case. Similarly, you could define an analogous one indexed by all values of i8, where a direct array doesn't work.

This is a bit of a side topic, but just for reference: If you expect u32::MAX >> 32 to be zero, you will be disappointed not just in Rust but also in languages like C#, Java, and JavaScript (which truncate the right-hand-side to 5 bits, as does Rust in release mode), and C and C++ (in which this is undefined behavior).

The reason is that the hardware shift instructions on a typical processor behave like Rust/C#/Java/JS (i.e., ignoring all but 5 bits of the right-hand argument). Diverging from this behavior would slow down every single shift operation, since it could no longer compile to a single instruction. Programs that use bitwise operations frequently care a lot more about speed of the non-overflowing case than behavior of the overflowing case.

(One notable outlier is Swift, in which the >> operator behaves as you request. The separate &>> operator behaves like the hardware shift instructions, for use in performance-sensitive code.)

2 Likes

Well, what prevents there being impl<T> Index<u8> for [T; 256]?

It's a checked operation in Rust though, so I'm not sure how much weight the performance argument carries. (The failure path could be "return 0" instead of "panic".)

It's only checked in debug builds. In release builds, it silently truncates (playground) and can compile to a single instruction (Godbolt).

1 Like

I think in that case it always would be inferred as usize, even if it were otherwise possible to use not-usize for indexing in more explicit contexts.

That's true, though I feel like those would be most realistically instantiated as actual static variables that amount to hardcoded data in the executable, as opposed to something that lives on the stack.