Could macros be used to work around the lack of support for const generics arithmetic?

I don't understand why const generics arithmetic is so hard to implement as a language feature, and I don't think I ever will. I couldn't begin to understand the intricacies of the Rust compiler (what even is this???). I have no doubt that it's either an incredibly difficult or incredibly tedious problem to solve -- Rust developers/contributors have nothing but respect from me (especially with the insanely cool things you can do like test private functions and really testing in general, not to mention the million other things that Rust makes incredibly painless in comparison to C++).

That said, could macros somehow come to the rescue? I would really like to be able to do the following:

fn bean_doubler<N: usize>(beans: [i32; N]) -> [i32; 2 * N] {
  let mut doubled = [0; 2 * N];
  for i in 0..N {
    doubled[2 * i] = beans[i];
    doubled[2 * i + 1] = beans[i];
  }
  return doubled;
}

This also raises the issue of whether or not arrays should be prioritized as a language feature or even used, and the validity of using non-std libraries to bridge the gaps of a seemingly core language feature.

I guess that I could use Vec everywhere I was trying to use arrays, but the loss of size specificity/semantics seems like a real bummer to me. As a spaceflight simulation developer, it's really nice to see the exact size of your contiguous types and relate them through expressions.

All types of responses are appreciated! I'm really interested in const generics arithmetic and would love to learn about whether or not anyone else cares and/or why it's so hard.

In nightly Rust, it already works

#![feature(generic_const_exprs)]
#![allow(incomplete_features)]
pub fn bean_doubler<const N: usize>(beans: [i32; N]) -> [i32; 2 * N] {
  let mut doubled = [0; 2 * N];
  for i in 0..N {
    doubled[2 * i] = beans[i];
    doubled[2 * i + 1] = beans[i];
  }
  return doubled;
}

You can refer to GitHub - rust-lang/const-eval: home for proposals in and around compile-time function evaluation .

1 Like

For some things, using “fake” const generics via typenum is still a lot more powerful than the true const generics…

use generic_array::{arr, ArrayLength, GenericArray};
use std::ops::Mul;
use typenum::{op, U2};

fn bean_doubler<N>(beans: GenericArray<i32, N>) -> GenericArray<i32, op!(U2 * N)>
where
    N: ArrayLength<i32>,
    U2: Mul<N>,
    op!(U2 * N): ArrayLength<i32>,
{
    let mut doubled = GenericArray::default();
    for i in 0..N::USIZE {
        doubled[2 * i] = beans[i];
        doubled[2 * i + 1] = beans[i];
    }
    return doubled;
}

fn main() {
    let array = arr![i32; 1, 2, 3, 4, 5];
    let array_doubled = bean_doubler(array);
    println!("{array:?} => {array_doubled:?}");
}
[1, 2, 3, 4, 5] => [1, 1, 2, 2, 3, 3, 4, 4, 5, 5]

Rust Playground

2 Likes

Why can't the "real" const generics just do what the "fake" const generics do?

The short explanation is probably just is that const generics are an afterthought, or retrofitted, if you will, into an existing type system. So it's unsurprising that true and actual types may still have benefits of better fitting into the type system they were designed for.

This is not to say there's any fundamental reasons against the true const generics supporting the same kind of use-case, it's just more ongoing design and implementation work for the compiler. With the fake ones that are truly types, the operations then are truly traits, and the constraints can be ordinary trait bounds. With const generics that aren't true types, operations on const generics don't have corresponding traits, and alternative mechanisms are required that aren't fully available (on stable Rust) yet.

4 Likes

Fantastic explanation. Thanks a bunch!

For completeness:

  1. smallvec and arrayvec were some other solutions I considered, but they only make the capacity explicit -- not the size.
  2. This other post is relevant.

This blog post may offer some insights into why const generics are complex. A sound type system must account for various possible corner cases. For example, what should language do with your example if 2*N is bigger than usize::MAX? Multiplication via * is just a sugar for std::ops::Mul, what should language do if its implementation panics (like on overflow)? Allowing widespread monomorphization errors is not a pleasant can of worms to open. Should compiler be able to derive that 2 * N is equal N * 2? What about N + N? It looks trivial on the first glance, but presents quite a rabbit hole if you start digging into it.

2 Likes

This topic was automatically closed 90 days after the last reply. We invite you to open a new topic if you have further questions or comments.