Const as an auto trait
The previous two posts in this series tried to discuss the design of Rust through the lens of some higher level language concepts:
- First, the notion that programming languages have different registers
- Second, the idea that not all patterns should be made into abstractions
This post does not introduce any such high-minded concept. It is entirely down in the weeds. That’s partly because it is based on content I removed from the previous post so that post could focus on the higher point.
As I have already written, I believe the control-flow effects are best managed in Rust by
introducing generators and try blocks so that they can be composed naturally using Rust’s inherent
form of imperative expression composition. However, I separated
const from the control-flow
effects because it did not fit well into that pattern. As I wrote,
const fn is not syntactic sugar
which modifies the body the way the control-flow effects are, it does not make additional operators
possible within its scope, instead it restricts it, and it does not determine “how” a block of code
runs but “when” that block can run.
I contended briefly that
const is more like the auto traits in this regard: just as
restricts which types can be sent between threads,
const restricts which functions can be called
at compile time. I want to explore this connection more thoroughly, and especially connect it to the
Send” problem described by Niko Matsakis.
const is not an auto trait (yet?)
While it seems fruitful to conceive of const as providing the same kind of check as an auto trait,
in implementation there are some obvious differences. The first thing we can say is that there is no
ConstFn trait” that can or can’t be implemented by anything. Instead, what there is is a marker
that can be applied to functions.
The function of these auto traits and of const are interestingly similar in that they are recursive:
a type is
Send if all of its fields are also
Send, and a function marked
const will compile if
all expressions within it are also
const. However, there is an inversion of the annotation burden:
types are automatically
Send if all their fields are, whereas a function containing only
expressions is only
const if you explicitly annotate it as such.
Additionally, what exactly is
being marked by
const is quite different from what is being marked by
different types are or not safe to send across threads (like
const is not
determined by the types in the function, but rather the expressions.
This comparison gets a bit more interesting when we consider coroutines like
async fn and
generators. For these, the anonymous state machine type they return (a
Future or an
Send or not. This is more similar to const fn, in that it is “body” of the function
(reified into a state machine, in this case) that does or doesn’t implement the auto trait. However,
unlike const, the “function” itself (in the case of coroutines, the pure constructor of the state
machines) always implements
Pushing on this comparison, one could imagine a kind of auto trait that does mean “can be executed at compile time.” The implementation of this for types that aren’t functions would be meaningless and irrelevant, but for all types that “are” functions it would be meaningful. Here, its important to note one detail of Rust that is often overlooked by users: every function in Rust has a unique anonymous type which can’t be written in the surface language, but exists in the type system.
If there were such a
ConstFn auto trait, it would be fairly straightforward to figure out which
function types implement it and which don’t:
- The anonymous function type of a
const fnfunction would implement
ConstFn, whereas a normal fn would not.
- Anonymous closure types could (probably with some annotation) implement
ConstFn, so a bound could be something like
F: Fn() + ConstFn.
- Function pointers would need a
const fnvariant (just like they have an
unsafe fnvariant) which would implement
ConstFn, whereas others would not.
I don’t necessarily think that such an auto trait should be added to the language and exposed
directly, but I want to consider it further conceptually in this post. For example, maybe instead of
a real auto trait a const closure needs to be written as something like
F: const Fn() with special
syntax. The point is that it acts like an additional bound on function types, which the compiler can
determine whether they meet or not by examining their definition.
const and trait methods
We haven’t yet done anything to solve the real problem the keyword generics group set out to solve:
allowing you to call trait methods which are const in a const context (for example, to allow you to
for loop in a const context, which desugars to calls to
Here, the comparison to
Send async methods becomes especially crisp: at a high level, this is also
the same problem. You want to spawn a task, calling an async method, on an executor that requires
Send (so it can schedule the work across threads). Abstractly, this is the same
problem as wanting to call a const method in a const context. This analogy was also previously made
in the work on “trait transformers,” but it can be made even without bringing
that syntax into play.
There is an important difference in how a
Send async method and a
const method are defined,
which I have hinted at earlier. An async method is
Send if its body holds no
!Send types across
an await point, without any additional annotations. In contrast, a function is only
const if it is
const. There are pros and cons to each approach, but it’s clear that Rust
previously has chosen that auto traits are implicitly implemented, whereas
const only applies with
an explicit annotation.
Accepting that difference, the only way to make a method const would be to mark it
const would behave differently from other annotations in that it would be acceptable to mark a
const without the trait definition being marked
const. From my perspective, this
irregularity is acceptable as arising from the difference in how const behaves from the other
So, let’s say for the sake of the argument that you now have a way to make a trait method const, and
it’s as simple as adding
const to that trait method. How, in a generic context, do I restrict a
bound to say “I want only the iterators whose “next” method is const?”
Here, the problem merges with the
Send async method problem: just as we need some way to say that
an async method is
Send, we need some way to say that a method (any method) is
ConstFn. And so
these can be solved with the same syntax. For this, I am most enthused by so-called “return type
annotation” syntax, but with a twist: perhaps rather than thinking of this as a syntax for the
return type, this should be thought of us applying a bound to “the function,” which in the case of
coroutines includes the state machine associated with that function.
This might seem unusual, but it should work in practice because of the fact that any bound
implemented by the state machine (for coroutines) will also be implemented by its pure constructor -
all of these constructors implement
ConstFn and anything else you might care about,
because they are all pure constructor functions. So rather than talking about the state machine as
“the return type,” we can conceptualize it as the function (reified into a state machine), and use
an analogous syntax for requiring that any function (including one which isn’t reified into a state
machine) as being const.
In this formulation, the solution to the iterator problem would be as simple as writing
T: Iterator, T::next(): ConstFn, or possibly some special syntax like
T::next(): const. Similarly,
an async method or generator method could be marked as const and the same syntax could be applied to
it, also implying that its state machine can be processed to completion at compile time (this is
more relevant for generators than async, I think).
The goal of this proposal is to find the most minimal and least invasive language change needed to
support trait methods in a const context. Assuming we are already getting something like “return
type notation” to solve the async method
Send problem, I believe the problem of calling trait
methods in a const context can be solved by:
- Allowing methods to be marked
constlike free functions can.
- Having some way of expressing in a bound that a method on a trait is
const, using the same syntax as expressing that an async method is
This seems to me like all you actually need. While there might be advantages to a broader and more complex kind of abstraction, these advantages would need to be weighed against the cognitive burden of adding another sweeping language construct to Rust. To me, that represents quite a hurdle to overcome when this more minimal alternative exists.
In my next post, I intend to return to the async Rust and coroutines, and explore more thoroughly the question of the low-level register, and why it matters.