Keywords II: Const Syntax
— 2022-10-26

  1. always-const execution
  2. never-const execution
  3. maybe-const execution
  4. const traits
  5. the need for always-const bounds
  6. the need to specialize
  7. summary: what is const?
  8. back to the drawing board
  9. conclusion

On the Keyword Generics Initiative we spend a lot of time talking about, well, keyword generics! We're working on designing a system which works with Rust's various effects, which are represented through keywords. And sometimes we discover interesting interactions or meanings of keywords.

Previously I've written about the meaning of unsafe, showing how it actually is a pair of keywords whose meaning depends on whether it's in caller or definition position. const shares some similarities to this, and in this post we'll be looking closer at the different uses of const.

always-const execution

If you want to execute a function during compilation, you need to have a way to make that happen. This is what we've been referring to as an "always const" execution, where you have a context which will always be evaluated at compile-time and never during runtime.

In Rust we can create an always-const context in a few different ways:

// Defining a `const` value
const HELLO: &str = {
    "hello"
};

// Defining a const value in an array expression
[0u8; {1 + 1}];

// Using `RFC 2920 experimental in-line const`
let x = const { "hello" };

// In const-generic parameters through `#![feature(`const_evaluatable_checked`)]`
fn foo<const N: usize>() -> [u8; N + 1] { [0; N + 1} }

These contexts also disallow the use of non-const values. Using them there will quickly yield an error:

fn foo(length: usize) {
    [0u8; length];
}
   Compiling playground v0.0.1 (/playground)
error[E0435]: attempt to use a non-constant value in a constant
 --> src/main.rs:5:11
  |
4 | fn foo(length: usize) {
  |        ------ this would need to be a `const`
5 |     [0u8; length];
  |           ^^^^^^

For more information about this error, try `rustc --explain E0435`.
error: could not compile `playground` due to previous error

never-const execution

Code which cannot be evaluated during compilation is considered "never-const". These contexts are effectively "runtime-only". Because const is opt-in in today's rust, it means that functions by default will not and cannot be evaluated during compilation.

fn hello() -> &'static str {
    "hello"
}

const HELLO: &str = hello();
error[E0015]: cannot call non-const fn `hello` in constants
 --> src/lib.rs:5:23
  |
5 | const HELLO: &str = hello();
  |                     ^^^^^^^
  |
  = note: calls in constants are limited to constant functions, tuple structs and tuple variants

maybe-const execution

The third type of const context is "maybe-const". This is a context which can be evaluated both during compilation and at runtime. The classic const fn definition is considered a "maybe-const" context:

// Can be evaluated both at runtime and during `const` execution.
const fn square(num: u64) -> u64 {
    num * num
}

// Const evaluation
const NUM: u64 = square(12);

// Runtime evaluation
fn main() {
    println!("{}", square(12));
}

As an aside: In practice both these functions will be evaluated during compilation thanks to an optimization called "const folding". But that's just an optimization; not all code can be const-folded, and the code wouldn't compile if it wasn't valid at runtime. Whether or not a function is marked const has no impact on whether or not the code will be const-folded. But I figured it'd at least be worth mentioning the optimization as existing once in this post.

const traits

Something which we don't yet have is the idea of "const traits". Right now you can think of them like this:

const trait Trait {
    fn func() -> usize;
}

This is a "const trait" with a single "const method" which returns a type. Just like const fn, this should be considered a "maybe-const context": it should both be able to be evaluated at runtime and during compilation. For example:

// evaluates both at runtime and during compilation
const fn foo<T: ~const Trait>() -> usize {
    T::func()
}

the need for always-const bounds

The current system of const syntax has some interesting limitations. Take for example the following code:

// Can be called at runtime with a `T` which doesn't impl
// `const Trait`.
const fn foo<T: ~const Trait>() {
    [(); <T as Trait>::func()];
    //   ^ illegal
}

What we're seeing here is an "always-const" context nested in a "maybe-const" context. A fun twist on the usual effect sandwich. In theory that should be fine, but the issue here is that we're using a variable which is "maybe-const". To get an understanding of why this is bad, here is roughly the same example, but without the traits:

const fn foo(len: usize) {
    [(); len];
}

This yields the following error about wanting to have an "always const" value, but what we got is a "maybe const" value:

   Compiling playground v0.0.1 (/playground)
error[E0435]: attempt to use a non-constant value in a constant
 --> src/lib.rs:2:9
  |
1 | const fn foo(len: usize) {
  |              --- this would need to be a `const`
2 |     [(); len];
  |          ^^^

For more information about this error, try `rustc --explain E0435`.

The earlier trait example this would yield a very similar error, but using more steps. Thinking about solutions here, we practically have two ways to design our way out of this:

  1. Find a way to declare the maybe-const foo context as always-const.
  2. Find a way to declare the maybe-const T: ~const Trait as always-const.

This is an unresolved problem, so we're full on speculating here. But we could imagine that if we support ~const Trait meaning "maybe-const", we could also support const Trait to mean "always const":

// This now compiles because `T` is always guaranteed to be `const`.
const fn foo<T: const Trait>() {
    [(); <T as Trait>::func()];
}

This gets us into a difficult spot though. This would create a difference between the meaning of const when declared and when used in parameters. Ideally we could, like, not have that. Because as we've seen in other places having the same word mean different things in different contexts can be pretty confusing.

The need to specialize

Another thing to mention here is the idea of specialization. In the compiler we have const_eval_select which allows you to specialize depending on whether you're executing at runtime or during compilation. This can be useful to optimize implementations with, since more assumptions can be made about the exact environment you'rer running on during runtime. During compilation Rust is executed in an interpreter, which by design creates a uniform environment regardless of what platform the compiler is actually running on.

#![feature(const_eval_select)]
#![feature(core_intrinsics)]
use std::intrinsics::const_eval_select;

// A function which returns a different result based on runtime or compiletime
pub const fn inconsistent() -> i32 {
    fn runtime() -> i32 { 1 }
    const fn compiletime() -> i32 { 2 }
    unsafe {
        const_eval_select((), compiletime, runtime)
    }
}

const CONST_X: i32 = inconsistent();
let x = inconsistent();
assert!(x != CONST_X);

Something we're talking about in the initiative group is: "How can we enable people to author different versions of the same resource which only differentiate based on the effect?" This would effectively fill the same role as const_eval_select, but you could imagine it working for other effects such as async as well. The syntax we've been playing around is some version of:

// maybe-async definition using an intrinsic to switch based on the context
async<A> fn foo() { async_eval_select(..) }

// the equivalent overload-based variant
async fn foo() -> usize { 1 }   // always async
fn foo() -> usize { 2 }         // never async

Something we haven't really figured out is how we should define this for const definitions. We'd want to declare two variants: one which is definitely not const. And one which definitely is. And have these create a unified definition.

// maybe-const variant with the intrinsic
const fn foo() -> usize { const_eval_select(..) }

// this wouldn't work because we can't define an "always const" context
const fn foo() -> usize {}   // sometimes const
fn foo() -> usize {}         // never const

Summary: what is const?

Let's recap everything we've seen about const so far. In today's Rust the const effect can roughly be thought of as having three different modes, split between declaration and usage:

declarationusage
keyword never appliesfn foo() {}fn bar() { foo() }
keyword always applies-const FOO: () = {};
keyword conditionally appliesconst fn foo() {}const fn bar() { foo() }

The const keyword here means different things. const FOO is used to declare an "always-const" context. But when you write const fn foo you're in fact declaring a "sometimes-const" context. This means const does not have a consistent meaning in the language: depending on where it's used, it may either signal an "always const" context, or a "maybe const" context. And as we've seen in the case of trait bounds, there is a meaningful observable distinction between "always const" and "maybe const" contexts, bounds, and declarations.

This following table shows how "const definitions" interact with "const contexts". A "never const" context is runtime-only, an "always const" context is compilation-only, and a "sometimes const" context can be evaluated both during compilation and at runtime:

Can [def] be evaluated in [context]?never const contextsometimes const contextalways const context
never const def
sometimes const def
always const def

The way to think about "const" is as a subset of "base" Rust. We gain the ability to evaluate code during compilation by removing capabilities from the language. This means we don't get the ability to access statics, or any host functionality. Because "const Rust" is a subset of "base Rust", you can also think about it in an inverse and consider "base Rust" a superset of "const Rust". If we pull in "async Rust" as well, we can plot the following diagram:

                   +---------------------------+                               
                   | +-----------------------+ |     Compute values:
                   | | +-------------------+ | |     - types
                   | | |                   | | |     - numbers
                   | | |    const Rust     |-------{ - functions               
                   | | |                   | | |     - control flow            
 Host access:      | | +-------------------+ | |     - traits (planned)                 
 - networking      | |                       | |     - containers (planned)
 - filesystem  }-----|      "base" Rust      | |                               
 - threads         | |                       | |                               
 - system time     | +-----------------------+ |     
 - statics         |                           |     Control over execution:      
                   |        async Rust         |---{ - ad-hoc concurrency      
                   |                           |     - ad-hoc cancellation/pausing/resumption
                   +---------------------------+     - higher-order control flow; ad-hoc timeouts, etc.

Back to the drawing board

Okay, analysis time stops here. What I've said so far is mostly factual, and I think we should be able to reference as mostly true for the foreseeable future. From this point onward we put our imagination caps on and strap in to speculate about what could be. If we could go back to the drawing board, how could we change things?

Well, for one, we now know we not only want to have "maybe const" functions and types. We also want "maybe async", "maybe throws"; basically "maybe anything" effects. The fact that const fn is "maybe" by default but async isn't can be a bit surprising. Here they are side-by-side:

keyword asynckeyword const
keyword never appliesfn foo() {}fn foo() {}
keyword always appliesasync fn foo() {}const FOO: () = {};
keyword conditionally appliesasync<A> fn foo() {} const fn foo() {}

The difference is pretty striking right? We don't have an "always const" function. And what we mean by "maybe const" in one place means "always async" in the other. When we look at usage, const and async look a lot more similar though:

// Using `RFC 2920 experimental in-line const`
let value = const { "hello" };

// async block
let value = async { "hello" }.await;

They're not quite the same because they're not the same feature. But the usage here feels pretty good already. Now how could we bring the rest of const to feel a bit more in line with the other keywords. Probably the boldest answer would be to change the meaning of const fn to mean: "always const". It could then use a similar syntax to "maybe async" to declare "maybe const" contexts, yielding us the following table:

keyword asynckeyword const
keyword never appliesfn foo() {}fn foo() {}
keyword always appliesasync fn foo() {}const fn foo() {}
keyword conditionally appliesasync<A> fn foo() {} const<C> fn foo() {}

This would fix the main inconsistency between the effects, enabling them to be used in a much more uniform manner. Looking at our earlier example, the original formulation would have to become:

// declraring a "maybe const" context which takes "maybe const" params
const<C> fn foo<T: const<C> Trait>() {
    [(); <T as const Trait>::func()];
    //   ^ illegal; `T` must be "always const"
}

This, at least to me, feels like it more clearly describes the problem at hand. const fn and T: const now use the same syntax to indicate they're conditional. And we're trying to cast a T which is "maybe const" to be "always const". Diagnostics should make this fairly easy to point out.

This means the solutions to this issue could also be a lot clearer: we either cast T to be "always const", or mark the const fn foo to be an "always const context":

// maybe-const fn foo, with an always-const trait T
const<C> fn foo<T: const Trait>() {
    [(); <T as const Trait>::func()];
}

// always-const fn foo, with an always-const trait T
const fn foo<T: Trait>() {
    [(); <T as Trait>::func()];
}

I'm less sure about having const fn foo imply that all generics are also const, all casts are const, etc. Maybe they should be specified as const as well? But on the other hand: they would have to be const for it to work, so maybe it can be ommitted? Though that's mostly a syntactical question, as it wouldn't affect the semantics.

Making the meaning of const unambiguous seems like it would make cases like these a lot easier to work with. And it doesn't just stop at bounds; if we wanted to enable effect-based overloading, the example we saw earlier would Just Work:

// maybe-const variant with the intrinsic
const<C> fn foo() -> usize { const_eval_select(..) }

// under the new rules this would be equivalent to a single "maybe const"
// declaration:
const fn foo() -> usize {}   // always const
fn foo() -> usize {}         // never const

If we were to introduce this change, I believe we could actually do it over an edition bound. The main "new" feature is the ability to declare "always-const" functions, traits, methods, etc. These couldn't be declared in older editions; but because changing an existing "maybe-const" definition to become "always-const" would be considered a breaking change, it means that it wouldn't just break existing code simply by existing.

const fn code written on an older edition would be reinterpreted as "maybe-const" in newer editions. And possibly we could also just add support for trait bounds through const<C> on the old edition too, so on older editions there would just be multiple ways to declare similar "maybe const" contexts:

//! The different meanings of `const` between editions

// old edition
const fn foo() {}                     // maybe-const no traits
const<C> fn foo<T: const<C> Foo>() {} // maybe-const with traits

// new edition
const fn foo() {}                     // always const
const<C> fn foo() {}                  // maybe-const no traits
const<C> fn foo<T: const<C> Foo>() {} // maybe-const with traits
//! Even if older editions can't define always-const functions,
//! they could still be available to older editions. The hardest part
//! is probably figuring out the diagnostics for the older edition to talk
//! about a concept which can be used, but not created.

// new edition: defines an always-const function
crate new {
    const fn foo() {}    // always const declaration
}

// old edition; uses an always-const function
crate old {
    use super::new::foo;
    const Foo: () = foo();  // okay to call an always-const fn in an old edition
                            // but no way to declare new ones
}

As I've mentioned before: the exact syntax for conditional effects is still undecided. But regardless of what we end up with, the idea is that we can create something consistent between keywords.

Conclusion

In this post we've gone over what const is, how it relates to other keywords, and gone deeper on the different uses and meanings of const in Rust today. We've then taken a shot at trying to resolve these issues, with an outline of how we could potentially normalize the meaning of the const keyword over an edition bound.

Thanks to Oli for proof reading this post!