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:

  1. The anonymous function type of a const fn function would implement ConstFn, whereas a normal fn would not.
  2. Anonymous closure types could (probably with some annotation) implement ConstFn, so a bound could be something like F: Fn() + ConstFn.
  3. Function pointers would need a const fn variant (just like they have an unsafe fn variant) 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 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:

  1. Allowing methods to be marked const like free functions can.
  2. 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 Send.

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.