pre-RFC `const` function arguments


#1

This is a rough draft to get the discussion started.

(TODO: this needs to be more precise with respect to the terminology, in particular the technical terms const variables, const parameters, const expression, const projection, etc. should all be used correctly here)

Summary

This RFC allows const to be used in function argument position. This RFC does not propose an alternative syntax to the RFC2000: const generics, but rather builds upon it to improve ergonomics in some situations. RFC2000: const generics is still required for any of this to work.

Motivation

(I am biased towards stdsimd, so that’s what I can write about here, but maybe you’ll have some ideas about how to improve this section with other examples?)

Many vendor intrinsics are specified to take function arguments that must be compile-time constants:

// note: this is not C, `const` here means compile-time constant
__m128 _mm_blend_ps (__m128 a, __m128 b, const int imm8);

Users building abstractions around these intrinsics (stdsimd, faster) would benefit from being able to propagate this invariant. [RFC 2000: const generics] can be used to propagate these invariant:

fn _mm_blend_ps<const imm8:  i32>(a: __m128, b: __m128) -> __m128  { ... }

However, these vendors intrinsics in C and C++ are written using a bit of compiler magic and can be used like this:

constexpr auto c = ...;
auto r = _mm_blend_ps(a, b, c);

in a form that directly maps to the vendor spec. However in Rust, the function calls look like this:

const C: i32 = ...;
let r = _mm_blend_ps::<C>(a, b);
// and like this:
let r = _mm_blend_ps::<{2 * C}>(a, b);

I think that ergonomics do matter, and Rust should be able to call this like C as well:

const C: i32 = ...;
let r = _mm_blend_ps(a, b, C);
let r = _mm_blend_ps(a, b, 2 * C);

A consequence of this is that const generics become a bit easier to teach, at least for beginners. For example, after teaching a beginner the difference between:

const X: i32 = 3;
// vs
let x: i32 = 3;

One can mention that functions can take const arguments like this:

fn foo(const X: usize);
// and call them like this:
foo(42);

Without this feature, writing

fn foo<const X: usize>();
foo::<42>();

One would need to explain “angle brackets”, maybe a bit about generics, turbo-fish, why do we need {2 * N} when using const generics, etc.

Guide-level explanation / Reference

The motivation was a bit ad-hoc, but the feature itself is not. This RFC doesn’t let you write in Rust any kind of programs that you couldn’t write before using const-generics, it just adds a different syntax for them. This is similar, but not the same, as adding impl Trait as an alternative syntax for generics in function arguments.

This RFC allows const to be used in function argument position:

fn foo(const X: u8) { ... }

The function foo above is just different syntax for:

fn foo<const X: u8>() { ... }

The exact same semantics of [RFC 2000: const generics] apply here:

  • the value of the constant X is part of the function’s type
  • the constant could be used in where clauses (when const generics supports that)
  • the constant can be used as the length of an array [T; X] (when const-generics supports that)
  • users can’t take pointers to functions with const-function arguments because they are generic functions
  • same object-safety rules as for const-generics apply
  • structural equality: only types which have the “structural match” property can be used as const parameters (that is, floats are not allowed).
  • …

From the [RFC 2000: const generics], they can be used:

// 1. As an applied const to any type which forms a part 
// of the signature of the item in question
fn foo(a: [i32; N], const N: usize); // OK

// (2: does not apply)

// 3. As a value in any runtime expression in the body 
of any functions in the item.
fn bar(const N: usize) -> usize { N }

// 4. As a parameter to any type used in the body of any 
functions in the item:
fn baz(const N: usize) {
     let x: [i32; N];
     <[i32; N] as Foo>::bar();
}

// (5: does not apply)

They cannot be used (just like generic consts) in the construction of consts, statics, functions, or types inside a function body. That is, these are invalid:

fn foo(const X: usize) {
    const Y: usize = X * 2;
    static Z: (usize, usize)= (X, X);

    struct Foo([i32; X]);
}

Adapting another example from the [RFC 2000: const generics], the return value of const expressions is treated as projections. That is, this does not typecheck, because N + 1 appears in two different types:

fn foo(const N: usize) -> [i32; N + 1] {
    let x: [i32; N + 1] = [0; N + 1];
    x
}

but this does, because it appears only once:

type Foo<const N: usize> = [i32; N + 1];

fn foo(const N: usize) -> Foo<N> {
    let x: Foo<N> = Default::default();
    x
}

Note that for this second example to work we need [RFC 2000: const generics] to implement the type alias. That is, this RFC builds on [RFC 2000: const generics] and it is not proposing replacing its syntax.

There are two syntactic differences with respect to [RFC 2000: const generics] :

  1. One cannot pass const-function arguments via turbofish ::<> when calling functions:

    fn foo(const X: u8) { ... }
    foo(0); // OK
    foo::<0>(); // ~ERROR: function does not accept const-generic arguments
    

    See alternatives for a way to allow this.

  2. One does not need the braces ({2 * N}) to disambiguate:

    const N: usize = 3;
    
    fn foo<const I: usize>();
    foo::<{N * N}>();
    
    fn bar(const I: usize);
    bar(N * N);
    

Reference-level explanation

This is just syntax sugar for const-generics. Does this introduce any problems? I don’t know. I trust that you guys won’t let me be wrong on the internet.

Drawbacks

Like every feature, one drawback is increased implementation complexity.

Another drawback is that the syntax might throw away programmers coming from languages where const does not have the same meaning as in Rust (C, C++, …). That is, they might think that here:

fn foo(const X: usize) { ... }

X might just be “immutable” rather than a “compile-time constant”. AFAICT this problem already exists:

const X: i32 = 3;
// vs
let x: i32 = 3;

Programmers coming to Rust from these languages are going to need to learn the true meaning of const in Rust at one point or another. Otherwise the compiler will teach them the true meaning of const via a beautiful error message when they try to do something that’s not allowed.

There is also concern that this feature will set a precedent for allowing passing other kinds in function argument position. This RFC does neither prevent those feature from happening nor proposes them. Some languages like Swift allow this as:

doSomething(myType: T.self)  // call site

This looks nicer in Swift than what it would look like in Rust right now because Swift has named parameters.

Rationale and alternatives

If we could “do nothing” we would not be participating in the internals forum. Jokes aside, we could map const in function arguments to something different than const generics, but I don’t know what that would be.

The Guide-level explanation / Reference section states that “one cannot pass const-function arguments via turbofish ::<> when calling functions”. We could allow this by declaring that const function arguments are located at the end of the type-argument list in the order in which they are declared but then they must be omitted from the function arguments when calling the function:

fn foo<'a, 'b, T, U, const X: usize>(
     a: &'a T, const Y: usize, b: &'U, const Z: usize
) { ... }
foo::<i32, i32, /* X: */ 0, /* Y: */ 1, /* Z: */ 2>(&a, &b); // OK

This would only work as long as we never plan to add any new kind of arguments inside <> that need to come after type-parameters and const-generics. If we ever do that, this ad-hoc approach would break, because it is not very future proof.

Unresolved questions

Probably many.

  • Are there any arguments arguments for allowing const in function arguments for “consistency” reasons? One cannot use let in function arguments, e.g., foo(let x: i32), so being able to use foo(const X: i32) might be inconsistent?

  • We might want to allow const in patterns someday. I don’t see anything in this RFC that would prevent this, but if we ever had something like:

    struct A { 
        x: i32,
        const y: i32, // const in struct fields
    }    
    
     // const here means two very different things:
     fn foo(const X: usize, A { x, const y }: A) { ... }
    

    Having said this, const's do not occupy memory, so it will probably make more sense to allow structs to have associated const than to have const struct fields.


@withoutboats @eddyb @alexcrichton @rkruppe


#2

I feel like I should have more to say here, but: It’s just not clear to me how this improves ergonomics. What’s wrong with the “const arguments” being between the angle brackets instead of between the parentheses?


#3

If this were to come to pass, I’d also very much like let const, if possible. It’s very frustrating to have consts at local scope. In C++ I can write constexpr auto x = 0;, why can’t I do that in Rust?


#4

foo::<const x>() has much worse ergonomics than foo(x).


#5

Wouldn’t it be foo::<x>(), not foo::<const x>()?

(but I do see how foo(x) would be nicer than foo::<x>())


#6

perhaps? not sure where that discussion ended up. I remember that being a thing way back in the day when we were first discussing that.


#7

@lxrec

What’s wrong with the “const arguments” being between the angle brackets instead of between the parentheses?

Good question. First using foo::<3>() is in my opinion a bit worse than just using foo(3).

Then we have some vendor intrinsics APIs that are specified in a C-like language which is not C, let’s call it VC (for Vendor C). The main difference between VC and C is that in VC const means compile-time constant, just like Rust’s const, and as opposed to C’s const (which means something else).

So when we want to expose the vendor APIs, written in VC, in std::vendor, we need to map VC:

void foo(int a, const int b, int c, const int d);

to Rust:

fn foo<const b: i32, const d: i32>(a: i32, c: i32);

Calling that is a mess. This C-like code:

foo(2, 3, 4, 5);

becomes

foo::<3, 5>(2, 4);

I think that’s pretty bad because now we can’t really map 1:1 to the vendor specs anymore, but need to swizzle around arguments to pass them as types “because of syntax”.

This RFC is an attempt at making const-generics a bit more ergonomic in some situations by removing a barrier (however small this barrier might be).

I come from the stdsimd-side though, so I am biased towards vendor intrinsics and that’s what I can write about in the motivation, but I hope that others might have other examples where const-generics could be “made nicer”.


EDIT: I’ve edited the pre-RFC to make this more clear.


#8

My main concern is that this will confuse people learning rust.

fn foo(const x: usize) { ... }

looks a lot like "x is immutable". Rather than, "x is a compile-time constant". The same is not true of C/C++, where const mostly means “immutable”.


#9

Thanks for bringing this up! I fully agree.

AFAICT, this is a problem that already exists in a different form:

const x: i32 = 3; 
// vs
let x: i32 = 3;

So programmers coming to Rust from languages where const has a different meaning are going to have to learn its meaning in Rust at one point or another. Otherwise I hope that we will have a beautiful error message that will teach them the true meaning of const if they try to do something that is not allowed.

EDIT: I’ve added this to the “Drawbacks” section of the RFC, thanks for bringing this up!


#10

I guess that’s fair, but IMHO the difference between

const x: i32 = 3; 
// vs
let x: i32 = 3;

is smaller than and might be easier to explain than the difference between

fn foo(x: usize) {...}
// vs
fn foo<const x: usize>() {...}

The first example has just “compile-time constant” vs “immutable”. The second brings in const-generics, monomorphization, and codegen too… but maybe that’s just me?


#11

No it’s not just you, I think so too. This is why I think that const function arguments actually make explaining this easier. If I write

fn foo<const x: usize>() {...}

I would need to explain the angle-brackets, generic parameters, etc. If I were to just use const function arguments instead:

fn foo(const x: usize) {...}

Then I can just say x is a Rust constant variable (just like const X: i32 vs let x: i32) and move on, leaving the gory details for later. Hopefully, I would have teach them the difference between const X: i32 vs let x: i32 earlier on, otherwise they will be puzzled anyways :smiley:

EDIT: I’ve added better teachability to the motivation section, maybe you could gloss over that part and tell me if that makes sense?


#12

(Meanwhile, we’re going to get this this week:

#[rustc_args_required_const(2)]
fn _mm_blend_ps(a: __m128, b: __m128, c: i32) -> __m128  { ... }

I wonder why the attribute is not attached to the parameter c instead. )


#13

re: Turbofish for constant parameters: In general, some kind of marker at the call site is required to avoid unlimited lookahead. RFC 2000 chose {expr} instead of const expr and makes it optional for “identity expressions” (which include at least integer literals and identifiers), but for stuff like foo(x + 1) the turbofish alternative is foo::<{x + 1}>(). Yikes.


#14

One thing I haven’t seen mentioned is that const generics can only types with structural equality can be used for const parameters (at least according to RFC 2000; future RFCs may loosen this somehow but it’s not clear whether we’ll ever be able to use arbitrary types in const generics). This sugar would have to have the same restriction to actually be sugar.


#15

@kennytm

This RFC and that PR are motivated by the same problem in stdsimd.

That PR is a way of prohibiting users of some stdsimd functions from passing them non-const arguments. Otherwise, if we allow that and stabilize them, doing something better in the future would be a breaking change.

The way we map non-const arguments in the stdsimd crate to const arguments for the intrinsics is to use huge match arms. The attribute approach in that PR doesn’t solve that problem, but this RFC would.

If this RFC were accepted, it will be stabilized “after” const generics, so that attribute buys us time until that happens. If this RFC is not accepted, that attribute is still better than nothing.

Does this make sense?


#16

Yes, @rkruppe, this RFC mentions that const-function arguments have the same restrictions as const-generics (+ 1 syntactic restriction, the turbofish one). Structural-equality is one of them. Is there an example that violates it? Otherwise I’ll just add it to the list to make it more clear.


#17

An example in the post? I don’t see one. I just want to make sure that if this is implemented before const generics, we don’t accidentially wind up permitting (for example) float const parameters which the later const generics implementation won’t allow.


#18

That makes sense. AFAIK this requires full const generics to be implemented properly. And since the const generics RFC is already accepted and this one hasn’t even been submitted yet, I’d expect that one to be implemented before this one.

Having said this, I’ve added the point about structural equality to the RFC, explicitly mentioned that floats are not allowed, and also adapted the example of “Equality of two abstract const expressions” of the const generics RFC so that there aren’t any misunderstandings. The example shows that to actually make that work we would need const-generics, at least for the type alias.


#19

If this is sugar for const generics, it seems like it would be better waiting to consider it until we actually have experience with const generics to understand how bad the impact is in practice.

Curiosity: fn foo(const X: usize) makes it look like this is adding const as part of patterns. Can I also do match x { const y => ... }?


#20

I expect the attribute can be extended to forward “constness”, allowing us to buy a lot of time.

#[inline(always)] // <- needed to forward constness
#[rustc_arg_required_const(0)]
fn foo(a: u32) {
    bar(a);
}
#[rustc_arg_required_const(0)]
fn bar(a: u32) {
}

(Sorry I didn’t check the stdsimd source code for the actual implementation, I just assumed the situation is similar to the atomic types)