Keywords II: Const Syntax
— 2022-10-26
- always-const execution
- never-const execution
- maybe-const execution
- const traits
- the need for always-const bounds
- the need to specialize
- summary: what is const?
- back to the drawing board
- 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:
- Find a way to declare the maybe-const
foo
context as always-const. - 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:
declaration | usage | |
---|---|---|
keyword never applies | fn foo() {} | fn bar() { foo() } |
keyword always applies | - | const FOO: () = {}; † |
keyword conditionally applies | const fn foo() {} | const fn bar() { foo() } |
- †: an "always const" context can only ever call "maybe-const" declarations since there is no "always-const" declaration available in Rust today.
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 context | sometimes const context | always 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 async | keyword const | |
---|---|---|
keyword never applies | fn foo() {} | fn foo() {} |
keyword always applies | async fn foo() {} | const FOO: () = {}; † |
keyword conditionally applies | async<A> fn foo() {} ‡ | const fn foo() {} |
- †: "always-const" functions don't exist in Rust today, so an alternate method is shown here
- ‡ : placeholder syntax for functions where the keyword conditionally applies
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 async | keyword const | |
---|---|---|
keyword never applies | fn foo() {} | fn foo() {} |
keyword always applies | async fn foo() {} | const fn foo() {} † |
keyword conditionally applies | async<A> fn foo() {} ‡ | const<C> fn foo() {} ‡ |
- † : "always-const" functions are new in this table, and use the existing "maybe-const" definition
- ‡ : placeholder syntax for functions where the keyword conditionally applies
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!