RFC 2000: const-generics

lang (syntax | typesystem | expressions | const-generics | const-eval | dependent-types)

Summary

Allow types to be generic over constant values; among other things this will allow users to write impls which are abstract over all array types.

Motivation

Rust currently has one type which is parametric over constants: the built-in array type [T; LEN]. However, because const generics are not a first class feature, users cannot define their own types which are generic over constant values, and cannot implement traits for all arrays.

As a result of this limitation, the standard library only contains trait implementations for arrays up to a length of 32; as a result, arrays are often treated as a second-class language feature. Even if the length of an array might be statically known, it is more common to heap allocate it using a vector than to use an array type (which has certain performance trade offs).

Const parameters can also be used to allow users to more naturally specify variants of a generic type which are more accurately reflected as values, rather than types. For example, if a type takes a name as a parameter for configuration or other reasons, it may make more sense to take a &'static str than take a unit type which provides the name (through an associated const or function). This can simplify APIs.

Lastly, consts can be used as parameters to make certain values determined at typecheck time. By limiting which values a trait is implemented over, the orphan rules can enable a crate to ensure that only some safe values are used, with the check performed at compile time (this is especially relevant to cryptographic libraries for example).

Detailed design

Today, types in Rust can be parameterized by two kinds: types and lifetimes. We will additionally allow types to be parameterized by values, so long as those values can be computed at compile time. A single constant parameter must be of a single, particular type, and can be validly substituted with any value of that type which can be computed at compile time and the type meets the equality requirements laid out later in this RFC.

(Exactly which expressions are evaluable at compile time is orthogonal to this RFC. For our purposes we assume that integers and their basic arithmetic operations can be computed at compile time, and we will use them in all examples.)

Glossary

Declaring a const parameter

In any sequence of type parameter declarations (such as in the definition of a type or on the impl header of an impl block) const parameters can also be declared. Const parameters declarations take the form const $ident: $ty:

struct RectangularArray<T, const WIDTH: usize, const HEIGHT: usize> {
    array: [[T; WIDTH]; HEIGHT],
}

The idents declared are the names used for these const parameters (interchangeably called "const variables" in this RFC text), and all values must be of the type ascribed to it. Which types can be ascribed to const parameters is restricted later in this RFC.

The const parameter is in scope for the entire body of the item (type, impl, function, method, etc) in which it is declared.

Applying a const as a parameter

Any const expression of the type ascribed to a const parameter can be applied as that parameter. When applying an expression as const parameter (except for arrays), which is not an identity expression, the expression must be contained within a block. This syntactic restriction is necessary to avoid requiring infinite lookahead when parsing an expression inside of a type.

const X: usize = 7;

let x: RectangularArray<i32, 2, 4>;
let y: RectangularArray<i32, X, {2 * 2}>;

Arrays

Arrays have a special construction syntax: [T; CONST]. In array syntax, braces are not needed around any const expressions; [i32; N * 2] is a syntactically valid type.

When a const variable can be used

A const variable can be used as a const in any of these contexts:

  1. As an applied const to any type which forms a part of the signature of the item in question: fn foo<const N: usize>(arr: [i32; N]).
  2. As part of a const expression used to define an associated const, or as a parameter to an associated type.
  3. As a value in any runtime expression in the body of any functions in the item.
  4. As a parameter to any type used in the body of any functions in the item, as in let x: [i32; N] or <[i32; N] as Foo>::bar().
  5. As a part of the type of any fields in the item (as in struct Foo<const N: usize>([i32; N]);).

In general, a const variable can be used where a const can. There is one significant exception: const variables cannot be used 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]);
}

This restriction can be analogized to the restriction on using type variables in types constructed in the body of functions - all of these declarations, though private to this item, must be independent of it, and do not have any of its parameters in scope.

Theory of equality for type equality of two consts

During unification and the overlap check, it is essential to determine when two types are equivalent or not. Because types can now be dependent on consts, we must define how we will compare the equality of two constant expressions.

For most cases, the equality of two consts follows the same reasoning you would expect - two constant values are equal if they are equal to one another. But there are some particular caveats.

Structural equality

Const equality is determined according to the definition of structural equality defined in RFC 1445. Only types which have the "structural match" property can be used as const parameters. This would exclude floats, for example.

The structural match property is intended as a stopgap until a final solution for matching against consts has been arrived at. It is important for the purposes of type equality that whatever solution const parameters use will guarantee that the equality is reflexive, so that a type is always the same type as itself. (The standard definition of equality for floating point numbers is not reflexive.)

This may diverge someday from the definition used by match; it is not necessary that matching and const parameters use the same definition of equality, but the definition of equality used by match today is good enough for our purposes.

Because consts must have the structural match property, and this property cannot be enforced for a type variable, it is not possible to introduce a const parameter which is ascribed to a type variable (Foo<T, const N: T> is not valid).

Equality of two abstract const expressions

When comparing the equality of two abstract const expressions (that is, those that depend on a variable) we cannot compare the equality of their values because their values are determined by a const variable, the value of which is unknown prior to monomorphization.

For this reason we will (initially, at least) treat the return value of const expressions as projections - values determined by the input, but which are not themselves known. This is similar to how we treat associated types today. When comparing the evaluation of an abstract const expression - which we'll call a const projection - to another const of the same type, its equality is always unknown.

Each const expression generates a new projection, which is inherently anonymous. It is not possible to unify two anonymous projections (imagine two associated types on a generic - T::Assoc and T::Item: you can't prove or disprove that they are the same type). For this reason, const expressions do not unify with one another unless they are literally references to the same AST node. That means that one instance of N + 1 does not unify with another instance of N + 1 in a type.

To be clearer, 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
}

Future extensions

Someday we could introduce knowledge of the basic properties of some operations

Specialization on const parameters

It is also necessary for specialization that const parameters have a defined ordering of specificity. For this purpose, literals are defined as more specific than other expressions, otherwise expressions have an indeterminate ordering.

Just as we could some day support more advanced notions of equality between const projections, we could some day support more advanced definitions of specificity. For example, given the type (i32, i32), we could determine that (0, PARAM2) is more specific than (PARAM1, PARAM2) - roughly the analog of understanding that (i32, U) is more specific than the type (T, U). We could also someday support intersectional and other more advanced definitions of specialization on constants.

How We Teach This

Const generics is a large feature, and will require significant educational materials - it will need to be documented in both the book and the reference, and will probably need its own section in the book. Documenting const generics will be a big project in itself.

However, const generics should be treated as an advanced feature, and it should not be something we expose to new users early in their use of Rust.

Drawbacks

This feature adds a significant amount of complexity to the type system, allowing types to be determined by constants. It requires determining the rules around abstract const equality, which result in surprising edge cases. It adds a lot of syntax to the language. The language would definitely be simpler if we don't adopt this feature.

However, we have already introduced a type which is determined by a constant - the array type. Generalizing this feature seems natural and even inevitable given that early decision.

Alternatives

There are not really alternatives other than not doing this, or staging it differently.

We could limit const generics to the type usize, but this would not make the implementation simpler.

We could move more quickly to more complex notions of equality between consts, but this would make the implementation more complex up front.

We could choose a slightly different syntax, such as separating consts from types with a semicolon.

Unresolved questions