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 Send
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
“async method 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 const
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 Send
. For Send
,
different types are or not safe to send across threads (like Rc
vs Arc
), whereas 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 Iterator
)
can be 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 Send
.
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 fn
function would implementConstFn
, whereas a normal fn would not. - Anonymous closure types could (probably with some annotation) implement
ConstFn
, so a bound could be something likeF: Fn() + ConstFn
. - Function pointers would need a
const fn
variant (just like they have anunsafe fn
variant) which would implementConstFn
, 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
use a for
loop in a const context, which desugars to calls to Iterator::next
).
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
tasks implement 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
explicitly marked 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
. Here,
const
would behave differently from other annotations in that it would be acceptable to mark a
method 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
function modifiers.
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 Send
and 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).
Conclusion
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
const
like 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 isSend
.
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.