Try   HackMD

Design document 2025-06-18: const traits + target features

Goals and anti-goals

Things I hope we will do

  • Clarify mental model for effects
  • Choose between 3 core approaches
    • Bottom-up (associated effects)
    • Top-down, trait-level (const trait { fn default() })
    • Top-down, method-level (const trait { [const] fn default() })

Things I don't expect to do

  • Resolve all bikesheds but I would like to identify who to have more focused discussions with.

Brief history and observations

Investigation into effects and keyword generics

The keyword generics line of inquiry began with the observation that Rust has a lot of "modal" thingslike unsafe, async, constwhere you can apply a keyword to a function f and then f can only be called within a certain context. This in turn led to investigation in to academic work on effects.

An effect can be thought of as "side-effects the function has on its way to computing its result". A side-effect is basically some sort of interaction with the environment could be allocating memory, reading or mutating global variables, panicking, etc. Anything that falls outside the normal (param1, param2) -> output signature.

If we stretch the definition, we can include things like "infinite loop" as a side-effect (the function never returns the value) or even unsafe (function does something that requires caller to prove add'l bounds beyond what a safe function would provide). Opinions vary on how well unsafe in particular fits. Not important for this doc.

Key takeaways from this investigation

  1. Associated effects are a good fit versus the more traditional effect generics. The idea is to describe what effects a function body has (i.e., make it an "output" of the fn and its choice of types) versus making a function take a generic parameter (which implies the caller gets to choose the effects a function has). This is the approach used by Flix.
  2. Effects come in categories:
    • "Intrinsic" effects affect how compilation itself is done:
      • The main example is const, which indicates the set of things a function can do either during compilation or runtime, vs runtime, which indicates the set of things a function can do only at runtime. The typical fn can be seen as having a default set of effects, const+runtime.
      • There is a distinct concept, comptime, for effects that can only occur during compilation (e.g., obtaining layout information from a TypeId).
    • "Marker" effects are those that do not affect the "API" of a function, they simply document operations the function will perform (which in turn imply expectations on the environments in which the function can be invoked, as those operations must be supported). Examples of marker effects:
      • Does the function panic or allocate?
      • Does it access mutable global state?
    • "Transformer" effects are implemented by transforming the signature of the function in some way:
      • async being the canonical example, which modifies the function to return an impl Future.
  3. There are some differences in how we handle these kinds of effects in Rust today:
    • We currently "acknowledge" transformer effects at each step versus auto-propagating them (e.g., we explicitly "await" the result of an async fn, and we use ? to propagate an error). In contrast, marker and intrinsic effects by their nature are "transitive" (if I call a function that panics, it.. just panics).
  4. This stuff makes people nervous. There's no other way to say it: more than most proposed designs, effect generics raises concerns of Rust "jumping the shark" when it comes to complexity.

Question 1

One of the Questions I'd like to settle today is what effects to focus on. My position is that we

Focus initially on a known set intrinsic and marker effects. Leave user-defined or transformer effects to be considered in the future. They are more complex but also raise non-trivial impl concerns due to the transformations they require. Virtually everyone sees the value of marker effects and our experience with them will inform whether we believe effects could be extneded to handle transformer effects or not.

I don't expect a lot of controversy here, so we don't have to spend time on it, but good to know.

Implementation exploration for const traits

Semi-independently there has been a desire to extend const functions with the ability to be generic and invoke trait methods. This is required for key operations like operators ==.

Key takeaways from this investigation

Numerous techniques were tried to implement const traits in the compiler. Most were dead-ends but they taught us some lessons. There is a particular 'feature contour' that fits very well with the compiler:

  • All methods in a trait are const or not const at the same time.
    • This implies we do not need to modify the definition of a function signature. Modifying that structure is not fundamentally hard but is invasive and will take time.
  • Limit effects to "predicates" applied to traits no generic effect parameters or naming of effects. This allows us to implement the checks by manipulating what predicates are considered in the environment and what impls are considered.
    • Implementing generic effect parameters would require adding an explicit notion in the compiler of "sets of effects", which we don't currently have. Adding generic parameters to traits interacts with the code that selects trait impls past attempts led to a sharp increase in ambiguous trait resolution that proved unworkable. We can make changes here but we have to proceed with caution.

Formalization

We did some initial formalization of associted effects in a-mir-formality. The goal was to create a formal notation that aligned with the implementation limitations of the compiler (or modest extensions of it). This is not intended as a proposal to extend Rust, but rather to give us a shared vocabulary. More work is needed here but we made enough progress to document it.

Key concepts

First, associated effects as members of traits. The values of these effects will be specified by impls, allowing the impl to choose what effects the methods in the trait should have.

trait Default {
    do Effect; // effect of methods

    // ... more to come ...
}

Second, the value of these associated effects is an effect parameter E defined by

  • const things that can be done at compilation time
  • default superset of const that includes things that can be done at runtime
  • <Ty as Trait>::Effect an associated effect
  • E1 + E2 union (least upper bound) of two effects

Third, all functions are annotated with a list of effects, where the default effect is explicit:

// fn foo() {}
fn foo()
do
    default,
{}

// const fn bar() {}
fn bar()
do
    const,
{}

Using this, we can define the trait Default fully:

trait Default {
    do Effect;

    fn default()
    do
        <Self as Default>::Effect;
}

and we can write some impls

impl Default for u32 {
    do Effect = const;
    fn default() do const { 0 }
}

impl<T: Default> Default for Arc<T>{
    do Effect = default;
    fn default() do default { Arc::new(T::default()) }
}

impl<T: Default> Default for (T,) {
    do Effect = <T as Default>::Effect;
    fn default() do <Self as Default>::Effect { Arc::new(T::default()) }
}

Finally, we add the ability to put a bound on the effect in a trait, so that we can support "must be const" requirements:

fn compute<T>()
where
    // Read as: `T::Effect` is a subset of `const`
    T: Default<Effect: const>,
do
    const,
{
    const { T::default() } // OK
}

We do not require other forms of bounds for the basic const subset.

Extending to target features

Tyler Mandry made the observation that effects could capture the work that Luca Versari was doing on supporting code that is generic over what set of target features it requires. The motivation is to support an in-progress Rust port of the libjxl library from C++ into Rust. That library makes use of Hwy, a C++ library for writing SIMD code. Hwy lets you write templated code like this:

// A function that adds two vectors using Highway
template <class D>
void MySimdEnabledOperation(D d, const float* a, const float* b, float* result, size_t count) {
    // Implementation uses operations like Load(d, ...), Add(d, ...), Store(...)
    // that are specialized based on the descriptor D
    // ...
}

The choice of type D controls what SIMD opeations are used, and the idea is that the user will first test what's available and pick something appropriate. In Rust, this might be written like so:

// Trait representing a SIMD architecture descriptor
trait SimdDescriptor {
    type Vector;
    
    fn load(&self, data: &[f32]) -> Self::Vector;
    fn add(&self, a: Self::Vector, b: Self::Vector) -> Self::Vector;
    fn store(&self, vec: Self::Vector, result: &mut [f32]);
}

// A function that would ideally use target features based on D
fn vector_add<D: SimdDescriptor>(d: &D, a: &[f32], b: &[f32], result: &mut [f32]) {
    // Implementation would use d.load(), d.add(), d.store()
    // ...
}

and the idea is that the implementation of D::load etc would be written with target-features enabled:

impl SimdDescriptor for Avx512 {
    type Vector = Avx512Vec; // whatever

    #[target_feature(enable = "avx512f")]
    fn load(&self, data: &[f32]) -> Avx512Vec {
        ...
    }

    ...
}

The problem is that it is unsafe to call these methods unless you've tested for being in avx512. Of course we can make the whole trait unsafe but this feels like something that could be covered by the safe type system with only a mild extension, so that would be unfortunate.

In particular, you can extend our formalization to cover this concept fairly easily:

trait SimdDescriptor {
    type Vector;
    do Effect;

    
    fn load(&self, data: &[f32]) -> Self::Vector
    do
        default + Self::Effect;
}

impl SimdDescriptor for Avx512 {
    do Effect = avx512;
    type Vector = Avx512Vec; // whatever

    #[target_feature(enable = "avx512f")]
    fn load(&self, data: &[f32]) -> Avx512Vec
    do
        default + avx512,
    {
        ...
    }

    ...
}

There is one additional piece required, though. Because the main function does not declare the avx512 effect, we need some way to make that effect available. This didn't arise for const fn because the default effects on main were a superset of const. One way to do this is to have some kind of unsafe "effect masking block" that lets you write a block that can have effects that are hidden from the outside:

if avx512_available() {
    #[unsafe do(avx512)] {
        
    }
}

I've written this using a mildly odd notation to emphasize the scoping of unsafety: masking an effect is unsafe, or at least masking this effect is, because if you are wrong, you will get illegal instruction errors. Note that checking whether avx512 is available requires OS support and isn't really suitable for building into the language as a primitive[1]; also some effects (e.g., comptime) are not suitable for masking. But we don't want that unsafety to "infect' the contents of the block itself, it should be limited to the assertion that avx512 is safe to use.

Key takeaways from this investigation

  • Extending the formalization and basic structure to cover simd effects can be done in a straightforward fashion
    • but it requires masking effects
  • The formalization writes out the full set of effects on each function (do default + avx512), and that seems awkward. In practice I imagine we would want to define a floor (e.g., fn has default by default) and then layer add'l effects on top (e.g., #[target_feature(avx512)] adds an additional effect to whatever floor is defined). I'll return to this theme.

The Cambrian explosion of ideas

The original const trait RFC, covered briefly below, gave rise to a series of explorations of how to expose const in the surface syntax. The original proposal gave a clear minimal semantics and a strawperson syntax, but those concepts hadn't been exposed to the lang team for very long. So this gave rise to a lot of exploration.

The original proposal

The original const proposal had generic functions like this

const fn maybe_default<T: ~const Default>(setting: Option<T>) {
    match setting {
        Some(v) => v,
        None => T::default(),
    }
}

which desugar to something that is const if T implements Default in a const way:

fn maybe_default<T: Default>(setting: Option<T>)
do
    const + T::Effect,
{

}

Users could also write T: const Default bounds which would require a const impl. That translates to T: Default<Effect: const> in our formal notation.

Questions to be addressed

Reviewing this proposal led to a number of questions and investigations:

  • How do users understand const and ~const and how should they?
  • It seems like T: ~const Default is the behavior people will want most often. Should we just spell it T: const Default?
  • Should we write ~const fn instead?
  • Sigils like twiddle are bringing back PTSD for some of us. Can't we do better?
  • What about traits that want "always const" methods or other cases, is this proposal general enoughj?

I believe some of those have "firm answers", others are worth discussing today. We'll cover those in the next section.

Summary of major questions explored during the Cambrian explosion

How do users understand const and ~const and how should they?

Before we dive into the back and forth, I want to put forward a model for two ways of thinking about const that are both quite common. My goal here is mostly descriptive I want to capture the way people think about it so that we can figure out what will be intuitive. But it's also mildly prescriptive in that I think we want to nudge people towards intuitions that will let us generalize from const to other marker effects.

Rust today currently permits const functions:

const fn get_or_default(setting: Option<u32>) -> u32 {
    match setting {
        Some(v) => v,
        None => 0,
    }
}

This means a function that can be called at compilation time (but also runtime). This distinction is occasionally important. The body of a const fn is not known to run at compilation time. In contast, the body of a const item or const expression must run at compilation time:

const X: T = /* this must run at compilation time */;
const { /* this too */ }

I've seen two major ways of thinking about constness, both of which I think are compatible.

the "const-safe" mental model

One way to think of a const fn is that a "const-safe function" is one that can be called from const blocks. This is a familiar "syntactic pattern" in Rust, e.g., an unsafe function can only be called from an unsafe block and an async function can only be called from an async block.

This intuition can lead you astray when you think about effects, because it can make you think of const as an extra effect, but in fact it's a narrowing (a const fn has fewer effects than a fn, despite being written with a keyword). Nonetheless it is probably the predominant way people think about it, so it's good if we can ensure our notation is largely compatible with it.

the "side effects" model

Another way to think about a const fn is it is a function that cannot have side effects, or that can't interact with the operating system. If you think of something like eprintln! or Box::new, these are things that "can only happen at runtime". Many users have an intuitive sense for what "couldn't possibly happen at compilation time". This sense is likely too narrow, in fact (e.g., we can create a miniature operating system that supports allocation if we want to, but we have to answer some thorny questions), but it's there. So you can think of a const fn as one that avoids those things.

This intuition leads you pretty directly into thinking of consts as effects.

the "always const, maybe const" variation

In this model, const means "always const" and ~const "maybe const". I'll cover this a bit in the question about whether we should write const fn or ~const fn, but the tl;dr is that I think is not helpful, and we should nudge people towards one of the previous two models.

Should T: const Default mean "maybe const"?

In the original RFC, most const fns will want to use ~const trait bounds like T: ~const Default. const bounds like T: const Default are only needed if the methods are going to be used from a const item or a const expression. Given that, there was a proposal that T: const Default should mean "maybe const" when used on a const fn:

const fn maybe_default<T: const Default>() {...}

// would desugar to

fn maybe_default<T: Default>()
do
    T::Effect,
{...}

// instead of 

fn maybe_default<T: Default<Effect: const>>()
{...}

This approach has some appeal for consistency. const fn is already "maybe const" in the sense that its body may run at compilation time or runtime (unlike, say, a const expr) and there's a kind of inconsistency between the const in const fn (may be safe to call from a const block) and the const in T: const Default (must be safe to call from a const block).

But this approach gives rise to other contradictions and in particular conflicts with the "const safe" mental model. Remember that, in that mental model, const means "can use in a const block". Now consider this expanded example:

const fn maybe_default<T: const Default>() {
    const { T::default() } // <-- ERROR: T must be declared as "always const"
}

It is quite surprising indeed that T: const Default does NOT mean "can be used in a const block" but rather "can be used in the const fn".

For that reason, I believe this approach should be ruled out from further consideration.

Should we write ~const fn when there are ~const bounds?

There is a variation on the "side-effects" mental model that doesn't totally fit with this proposal. I'll call it the "const-maybe-const" mental model. In this model, things are

  • "const" if they "only do const things"
  • "maybe const" if they "might be declared to only do non-const things" and
  • "not const" if they are declared to do runtime things.

When you look at a function like

const fn maybe_default<T: ~const Default>(setting: Option<T>)

you might then conclude that the function is "const safe" because it says const fn. But in fact it is "maybe const", since there are ~const bounds.

One could remedy this by saying that instead of const fn, you should write ~const fn when there are ~const bounds:

~const fn maybe_default<T: ~const Default>(setting: Option<T>)

But this proposal, while not unworkable, has some notable downsides:

It feels clunky. The syntax feels clunkier the ~const fn at the start of the line catches my eye, at least, in a way that is noticeably heavier.

It creates syntax combinations that make no sense. The design also gives rise to combinations of syntax that don't make sense:

  • ~const fn foo() {} what should this mean? Is it equivalent to const fn?
  • const fn foo<T>() where T: ~const Default {...} what should this mean? If the user calls T::default, it has effects that are outside of const.

It's not a blocker to have the possibility of inconsistent syntax, we can just flag errors, but it is a "design smell". You can see this inconsistency in the desugaring, where there are two cases:

  • const fn desugars to do const always
  • ~const fn desugars to do const + ... where ... are the associated effects from the bounds

The key observation is that if there are no ~const bounds, then the two expansions are equivalent, which is why it feels like we are "stuttering" a bit by requiring ~const both at the beginning and in the where-clause list.

Conclusion: My conclusion is that leading with ~const (or some similar notation) is an additional bit of consistency that doesn't buy us anything. It is a concession to a "slightly wrong" mental model, and it'd be better to lean into the potential confusion and explain in terms of side effects (not the academic notion of effects, mind you). i.e., a const fn can only do const things in its body, but it can call ~const trait methods and those methods might do const things. That makes it "const-safe modulo the bounds"

Should we use the notation ~const or something else?

As I said, ~ has history in Rust and our experience has strongly been that unfamiliar sigils make people feel like things are harder to understand, overall. We explored a number of syntactic options:

  • ~const the original proposal. The ~ suggests "maybe" or "approximately".
  • (const)
    • Similar to how people use parentheses in English sometimes to denote variations, like "supports: (async) functions". But this is also kind of confusing, as a parenthetical usually means auxiliary information that is true but not important for the main point of the sentence.
    • Introduces potential ambiguities with () for grouping, e.g., impl (Foo) is legal today.
  • [const] meant to be similar to EBNF
    • Similar to EBNF notation for "optional"
    • No ambiguities unless we allow []
  • and many more

Discussion topic: Of these, I believe [const] hits the sweet spot concise, nonintrusive, uses familiar characters, and leverages existing conventions in EBNF and elsewhere. The main downside is that it's already used for arrays, which means at minimum we should avoid [] (empty effects) and, I think, suggests that we may want to be careful about something ambiguous like [T::Foo] is T::Foo an associated effect or an associated constant? I'm going to use [const] from here on out, but other options can be swapped fairly easily.

What about traits that want "always const" methods or other cases, is this proposal general enough?

The original proposal put constness entirely on traits but with other effects or effect-like things, we put it on individual methods. For example, just as we have traits with functions that are always async or never async, we may want traits with functions that are always const or never const:

trait Log {
    async fn process(&self); // always async
    fn name(&self) -> String; // never async
}

trait Computation {
    const fn bound(&self, input: u32) -> u32; // can always statically compute a bound
    fn true_computation(&self, input: u32) -> u32; // but the real computation can only be done at runtime
}

There are of course many more varieties, which can to be known as the "quadrants" because there are two axes (is the impl a const impl, and do the generic parameters on the method have const impls):

  • maybe-const a method, like Default::default, whose constness depends on the impl
  • always-const a method that is always const in all impls, whether they are const or not
  • never-const a method that is never const, even in const impls
  • always-maybe-const a method whose constness depends on its generic parameters and not on the impl
  • maybe-maybe-const a method whose constness depends on its generic parameters and on the impl
  • and so forth.

We went fairly far down this road, but I've personally backed off from it. The turning point for me was fee1-dead's write-up of learning curve concerns combined with the sheer syntactic overhead that was imposed on simple cases. For example:

[const] trait Default {
    [const] fn default();
}

impl<T> [const] Default for (T,)
where
    T: [const] Default,
{
    [const] fn default() {
        (T::default(),)
    }
}

I think this is a case where "less is more" repeating the [const] everywhere is rather overwhelming. In theory it makes all the connections explicit, but I think in practice it will simply be more confusing, as it's very hard to follow at a high-level without really understanding everything.

Also, this proposal included generality (const methods, etc) that would not be included in the initial implementation and stabilization. That's ok but not optimal. I'd rather align the RFC with what we will stabilize if that still forms a coherent set of functionality.

Conclusion: I believe that if we stabilize const traits (see the next question for the alternative), then we should start with a more minimal proposal similar to the original RFC. I'll include my proposal below.

Maybe should start with a more explicit notion of associated effects?

In the previous section we discussed the various "quadrants" that might be considered and how it seemed like that design was getting too complex for its own weight. One way to handle that is expose the "building blocks", putting the flexibility into user's hands without promising a slick package.

Specifically, we could design a syntax that maps very closely to our formalization. This hasn't seen as much syntactic exploration, but I'll put out a strawperson proposal here for what it might look like:

  • functions have "floor effects":
    • the "floor" of a fn is default
    • the "floor" of a const fn is const
    • we may add other floors in the future, e.g., pure fn
    • including the idea of a "floor" gives us room to revisit the "basic effects" in the future. We've learned in the past that we should be careful when we assume things like "this code has no effects at all" what seems innocuous in one context may be something worth tracking in another.
  • to add effects atop the floor, functions can be annotated with #[do(E)] where E is
    • const
    • default
    • an associated effect T::Effect or <T as Foo>::Effect
    • a defined target feature like avx512 or whatever
    • E + E
    • maybe other things in the future
  • traits can have associated effects with defaults
    • do Effect = E
  • trait bounds can include a limit T: Trait<Effect: E> on those bounds

Using these primitives we can write our usual examples:

trait Default {
    do Effect = default;

    #[do(Self::Effect)]
    const fn default();
}

impl Default for u32 {
    do Effect = const;

    const fn default() { 0 }
}

impl<T: Default> Default for (T,) {
    do Effect = T::Effect;

    #[do(T::Effect)]
    const fn default() {
        (T::default((),))
    }
}

// No changes needed on this impl due to defaults:
impl<T: Default> Default for Arc<T> {
    fn default() {
        Arc::new(T::default())
    }
}

and for SIMD:

trait SimdDescriptor {
    type Vector;

    do Effect;
    
    #[do(Self::Effect)]
    fn load(&self, data: &[f32]) -> Self::Vector;
}

impl SimdDescriptor for Avx512 {
    type Vector = Avx512Vec;
    do Effect = avx512;

    #[do(avx512)] // <-- do we also need target-feature? I don't see why.
    fn load(&self, data: &[f32]) -> Avx512Vec {
        ...
    }

    ...
}

[2]

Under this system, when a function is declared with the effect avx512, the compiler would take as a signal to use different codegen, so the explicit #[target_feature] annotation is not needed.

Effect masking

For target features, we would like some way to assert that some effects are allowed. We have precedent for this or at least something conceptually similar which is unsafe blocks. These are a way to say "this block of code takes actions that are not judged safe by borrow check, the code author takes responsibility for checking they are compatible". We could have some form of block that says "this block of code has side-effects that are not tracked in the fn signature, the code author takes responsibility for ensuring they don't cause problems". And indeed this operation, at least for target feature effects like avx512, must be unsafe, since the logic to check if a given target feature is safe to use is outside Rust's purview (and sometimes OS-dependent). Not all effects can be asserted: imagine we have an effect for something that can only happen at compilation time (comptime). That could not be "asserted" at runtime, it doesn't even make sense, we can't generate code for it.

What syntax shall we use for this? This hasn't received much exploration, I will propose here as a strawperson an annotated block:

if avx512_available() {
    #[unsafe do(avx512f)]
    {
        ...
    }
}

Note that this really wants the ability to scope unsafe to just the annotation.

Excluded: generic effects

I intentionally excluded generic effect parameters from this description. This ensures that all effects are "minimal", as in, they reflect exactly the things a function may do instead of the caller saying "I'd like you to allow yourself these effects", the callee is saying "here are the effects I have". This simplifies implementation and avoids the need to integrate effects into trait dispatch and other parts of the compiler. However, in the long run, we will likely have to consider effect generics to cover some cases, notably non-hard-coded effects on dyn and fn:

#[do(E)]
fn invoke<do E>(data: &dyn Display<Effect = E>) {
    data.to_string()
}

(Credit to TC for providing this example.)

Questions before us

This brings us to the major decisions for today:

  1. Should we focus on marker effects for now?
    • As I noted above, I think this is the right subset to start with. I don't expect a lot of controversy here, but it's included for completeness.
  2. Should we work "bottom-up", starting first with associated effect declarations for maximum flexibility, or should we work "top down", starting with the const traits proposal?
    • If we go bottom-up, we can expect a lengthy period of bikeshedding and impl exploration. It's also unclear who will drive this impl and design work, so progress may stall indefinitely.
    • If we go top-down, we run the risk of picking a design that doesn't scale in the ways we eventually want it to.
  3. If we do opt to top-down, what const proposal should we use?
  4. And, only if we settle all of those questions, let's bikeshed: [const] or some other alternative?
    • If we don't get here, though, I'd like to have people's "vibe checks" on [const] and to get an idea of who I should circle up with for a deeper dive.

Recommendation

My recommendations and reasoning:

Q1. Yes, we should focus on marker effects, for the reasons I gave. Most of the interesting questions come up, they are very strongly motivated needed, and it avoids some implementation complexities and general controversies that would slow progress.

Q2. We should do a maximally minimal "top-down" solution for const first and then explore a "bottom-up" approach to cover target features.

In particular, I think we should land a maximally minimal const traits proposal that isn't meant to cover target features and then start to explore how to layer on associated effects to cover target features.

My reasoning is that we will want a way for people to do const traits that doesn't require them to understand effects (so we're not adding complexity). I think we've explored the space well enough that we have a sense for what that should look like; and we've also got the impl ready to go and it will let us make incremental progress.

There is another, practical reason: not moving this work forward will be majorly demoralizing, both to implementors and users[3], and it's unclear that we will have anybody funded or motivated to do the work to explore the "bottom-up" proposal (particularly if we demoralize the people who've been working hard on the const proposal). I don't think this is a strong enough reason on its own to make a wrong decision for the language, but since I think that a dedicated const syntax is the right decision for the language, it's worth considering.

Q3 and Q4. I like [const] because it is visually familiar ([]), builds on the EBNF tradition, and "just reads well". I'll cover my specific proposal in the next section.

The "All That's Old Is New Again" proposal

I believe we should (a) introduce const generics and specifically the subset described in All That Is Old Is New Again, I concluded we should adopt a mental model of methods inheriting the effects declared on the trait. We can always regain flexibility by having the declare additional effects beyond that baseline. So then we have this:

const trait Default {
    fn default();
}

impl<T> const Default for (T,)
where
    T: [const] Default,
{
    fn default() {
        (T::default(),)
    }
}

which I feel is much more approachable. The explanation for users becomes:

  • const-safe mental model:
    • The function is const-safe if all of the [const] bounds are also const-safe.
    • So it is "const modulo bounds".
  • side effects mental model:
    • The side-effects allowed in the function are "const" (from the fn body) plus the side-effects from any [const] bounds in scope.
    • You can call the method from a const block if the total side-effects are limited to const.

I also think after this we should look at extending to cover target features, probably through a more explicit notation of associated effects. Certainly we'll want to explore that on the impl side.


Discussion

Attendance

  • People: jana, Niko Matsakis, Taylor, tiif, oli, Eric Holk, JoshT, TC, Jubilee, Tyler Mandry, Martin (@martinomburajr), scottmcm

Meeting roles

  • Driver: TC
  • Minutes: Tomas Sedovic

Vibe check

nikomatsakis

As the author, I gave my take. =)

tmandry

Generally I like the proposal. Starting with marker + intrinsic effects is what I think we should do. Solving the problem of const traits should be the priority.

If we start with the top-down const Trait, which I'm happy to do, I think we should still consider the implications for transformer effects like async. I added a discussion topic about this below. I would also feel a lot more comfortable if we can show our formal model can extend to those.

As for the syntax, my vibe is positive on [const]. I was also fine with (const). I'd be at least a little bit sad if we end up with ~const, but in a situation where we ruled out the alternatives for good (read: not nitpicky) reasons, I can live with it.

Josh

I agree with Niko's recommendation. In particular, I very much agree with the top-down approach. The language should have a dedicated const syntax, with minimal complexity.

I am unsure if we will want to go on to have a more complex effect system, and in particular whether we'll want to have user-defined effects. I agree with the observation that 'effect generics raises concerns of Rust "jumping the shark" when it comes to complexity'.

If we don't go on to have a more complex effect system, we shouldn't have the complex syntax of one. If we do go on to have a more complex effect system, we should still have a simpler sugar for a case as common and hopefully pervasive as const.

With a library team hat on, I do hope we have enough of effects to be able to write, for instance, a map function that encompasses try_map and async_map. With a lang team hat on, I'm uncertain if even that subset of an effect system would have a manageable complexity. But, I'm enthusiastically in support of people trying experiments and designs, with the idea of "keep complexity under control" as one driving principle. Rust has historically managed to take surprisingly complex concepts and present them in a fashion people can work with.

Finally, vibe check on the bikeshed: :+1: to [const], much better than (const). I'd also take ~const if that was everyone's preference; that's still better than (const). We regularly end up needing cross-pollination between type syntax and expression syntax, and I expect us to have more in the future; for that reason, (const) should mean the same as const. (There's a reason our one-tuples are (x,) rather than (x). Though I wouldn't want to see (const,) either.)

TC

The more we talk about this, the more I really do just want to start with something closer to the modeling here, Niko's second suggestion of the explicit associated effects notation while limiting what's allowed to match what's implementable today.

There are, I think, fewer questions and bikesheds along that axis than along the other. Maybe we will later want some elision for this, but doing it a bit more explicitly first will help us to build up the intuitions about what's needed there and how that should be done.

Probably I think about this as though we had tried to avoid adding explicit associated types and generic type parameters to Rust, initially, and instead tried to do everything in terms of impl Trait. We would have ended up with things like,

fn f(x: impl Tr) -> impl Tr { .. }

and we would have said to ourselves, "maybe the input impl Trait should always be connected to the output impl Trait, such that they're the same type." And we would have had to try to come up with all sort of elision rules for this, and I'm just not sure we would have gotten that right the first time. I'm happy we didn't do that and went with something more explicit first.

And so, on that note, even today, when we start talking about things like this,

fn f(x: impl [const] Tr) -> impl [const] Tr /* do [const]?? */ { .. }

it just starts being not totally obvious to me how all these elisions tie together. I really just want to name it.

As for how to approach this, I'd prefer to see an RFC that covers a lot of the ground. Particularly around the more explicit dimension, the language semantics seem relatively clear. We can then incrementally stabilize, starting first with what is closest to today's implementation, and following with what's needed to support the other top-down use cases that we want to support.

We did something like this for precise capturing with use<..>. The RFC covered the whole ground, even though many parts of it, e.g. precise capturing of types, were hard to implement, and we've been stabilizing incrementally.

  1. Should we focus on marker effects for now?

Yes.

  1. Should we work "bottom-up", starting first with associated effect declarations for maximum flexibility, or should we work "top down", starting with the const traits proposal?

I'd like the design to be bottom up and to stabilize use cases top-down.

  1. If we do opt to top-down, what const proposal should we use?

That still seems hard.

  1. And, only if we settle all of those questions, let's bikeshed: [const] or some other alternative?

The more we talk about it, the more concerns I have about (..) or [..] syntaxes due to the similarity of this problem to conditional trait bounds and the problems with these syntaxes in expression position.

Others

Jana

I love going for marker effects, in fact I don't think we should ever go further than it. However, as discussed below, I think it is good to keep the syntax flexible enough to put identifiers in since we can't perfectly predict the future. Even for things like target feature effects which are built in that seems wise.

I initially wanted to say: start bottom up, it is hard but we'll get the better feature in the end.
It's already too easy to work ourselves into a corner and we don't have so many choices anymore with current rust.
However, before we start we should have more of the feature worked out.

However:
I do like "max min" top down with const getting some precedence.
So we have a tiny bit of const, maybe not associated, then make associated work, and then go to more effects. Const traits are something we badly need.

A comment, use fewer attributes, have some plan to remove them in the future in favour of actual syntax. Having worked on them so much, I wish we did more to incorporate more of the language in the syntax of the language. No need to start with that, but at least think about what options we have there.

Other concerns I had, I asked in the discussion topics below.

workingjubilee

Regarding attributes: a lot of features have been bolted onto the attributes in Rust, partly because we call them "attributes" and allow them in kind of random positions, like C compilers does, since C23. However, because I suspect that attributes in the C compilers are handled quite a lot differently because they seem to be much more incorporated into certain syntactic structures, as opposed to being more like metadata attached to things. However, like Jana, I think this is going to be a bit of a dead end: I don't expect us to embed attributes as deeply into the language as C does, partly because we also use it as our alternative to a preprocessor (#[cfg] and such), whereas C has no conflict on that point because the preprocessor is technically its own byzantine sublanguage.

About demoralization, I literally spoke to someone the other day where they expressed that they don't think that Rust will ship const traits within 15 years. I told them to not be ridiculous, the language hasn't even been around that long. It would be much more fair to suggest we might not ship it within 10.

From my perspective, it really feels like the more top-down const trait approach is "ready to go", almost, once we hammer out a syntactic detail or two.

T-lang has become very fond of talking about its "north star" for this or that, but I have a question: what is your north star for an entirely bottom-up design? What does that enable? If it's "make sure it's something we can represent in the compiler" I am not sure if that concern accurately reflects the current tradeoffs from the perspective of T-compiler members? I know T-lang talks about managing regret from bad decisions, and instituting process to reduce that, but the flipside is that you can easily wind up exploring and spinning your wheels forever if you are not willing to commit.

Luca Versari

I like the bottom-up approach that Niko mentions at the bottom (because I expect this to be the easiest way to implement a good, general approach), but I don't necessarily think the stabilization approach should be bottom-up (this might chase off a bunch of bikeshedding, possibly).

In particular I also see the importance of working towards stabilizing things top-down, in part for motivation and in part because I would want to see feature-effects happen not-too-late :-)

Top-down vs. Bottom-up

TC brought up difficulties expressing certain effects/situations, full generic syntax

Josh: I think the things that would cause issues are exactly harder scenarios to which we might not want to extend this syntax.

TC: What you're describing here concerns me more, not less. I want to make sure what we have is scalable. If when we go to extend to to the places it makes logical sense we start getting scared that it's not obvious enough, then that gives me pause.

Josh: I am actually in favour of having a formalism underneeth. Where we maybe disagree is: should we stabilize anything that resemble the formalism? I'm all for having a formalism, just don't want to ship it right now.

oli: Exploring the formalism is something we definitely should do. But right now stabilising wouldn't give us anything I can do a PR that will do all the associated syntax and everything for the formalism within a week. But we won't actually gain anything we won't know if the formalism does anything better or supports more things.

oli: So I still think we should ship this current thing. We should block things like RPIT const bounds. Yes to building an alternative syntax to what we have, but we should ship what we have.

TC: My concern is about the way we express the formalism in the language and that's what we gain by being more explicit. We still need to get the formalism right; I agree that how we expose that formalism doesn't help us if we get it wrong.

Niko: Two things coming to my mind: one is presentation if we had this minimal version, I see const trait Default I'd be like "ok, that's not terrifying". If I see the do Effect I might be concerned. The other is: how certain we are we'll get past the basic subset. I'm sure we will don't know about the timeframe. But I'm wary of having the syntax we're exposing to everyone be do particularly if we don't go much beyond const generics.

Josh: I think this may depend on the assumption on where we'll end up eventually. If we start with the assumption that we will have generic effect system at some point then it makes sense that we need the formalism and syntax then surely we need this. If we start with trying to solve shipping const and possibly other things, then the idea "we have to prove we scale a generic effect system before we support scale" then

Tyler: I think that's a good observation. I've ran into a lot of things where we do run into limitations of the language, so I don't take comfort from the idea that "this example is too complicated for the language to express". TC's example made me think of conditional Sendness. I think formalisation is important. Making sure we have consistent model, and making sure we have concepts that compose well.

Eric Holk: It's worth thinking about whether we're going to the full generic effect system or the smaller-scale cost + things. The square bracket looks nice but it's really complex. If we add the do Effect later, we'll be adding two complex syntax additions to the language.

oli: I agree, it'd be great if we just had one. But I don't see us getting there quickly. And I think having a simple syntax for const will unlock so much. Going forward with RPITs and other things they're much less common and smaller.

TC: I want to acknowledge and express my appreciation for all the work oli's done on this over many years. It's been a considerable endeavor.

TC: With respect with what Josh mentioned, when I worry about Rust getting too complicated, I worry about that in the sense of C++ and not in the sense of StandardML. These are different kinds of things. StandardML is hard; it's high concept. But C++ is sprawling. It added a lot of special-case solutions rather than general mechanisms. Every organization that uses it has rules about what subset to use.

TC: For Rust, it's this C++ style of complexity that I think is the major risk for us. We have a lot of places where an effect system unifies the solution to problems: target features, const, panic freedom, scoped allocators, reactor hooks, etc. We could handle these all with one-off solutions, but, for me, it makes the language simpler if we aim for a unifying solution that is a bit more high concept.

Tyler: That's a useful distinction in the terms of different kinds of complexity. I want us to be careful about making mistakes. But Rust has editions and we can fix some of the mistakes over an edition. We have more degrees of freedom than C++ did and does.

Jubilee: Editions are great for fixing "we made a mistake in the syntax" and a lot of the discussion here is about syntax. A part of the reason I'm interested in const parameters is that it'd be very possible to experiment with modeling target features on top of const.

Niko: I appreciate your points, TC. Tyler, the point about editions is also good. That argues for keeping the syntax small the more limited we are in what we syntactically accept the easier it is for us to back away from it later.

Niko: I wonder what data we'd get from having const traits available. Do people have things they think they'd learn from it?

Josh: I think we're very likely to find that a quarter of the ecosystem will be marked as const. As a general "how do we make next steps thought", whether we end up stabilisning sugar for effect generics, we should star with what Niko's proposing here, and use that as a concept of what sugar we can support. And if people want to build the more general purpose and see how to express the sugar in that way. And we can build and stabilize the sugar in parallel. Each of these things should stand on their own merits and we shouldn't require one to demonstrate the other is viable.

Tyler: I think we'd learn a lot from stabilizing anything. Both of these points on the design space are highly useful.

TC: I agree with that.

Niko: I'll write a summary of the main points. TC: I liked your ML vs. C++ comparison.

(The meeting ended here.)


Effects included in the formalization

jana (verbally): The formalization includes the const and default and mentions effect unions, is all of that needed if we are just modeling const?

nikomatsakis: The formalization was done with the expectation of generalizing it to add'l effects (e.g., target features), but the initial set of effects is just looking at what is needed for the const traits RFC. That said, even there, you need to account for unions because of associated effects. For example:

fn foo<X: Default, Y: Default, Z: Default>()
do
    X::Effect + Y::Effect,
{
    // This should not compile because `Z::default` is not declared above.
    // We need some "open-ended" set of effects to account for that.
    (X::default(), Y::default(), Z::default())
}

Effect categorization - intrinsic

TC: In the doc, const is labeled as an "intrinsic" effect, but I think it's actually a marker one. Or more specifically, it's the absence of the marker effect runtime (and perhaps of other marker effects we haven't named), in the same way that the absence of (the marker effect) ndet, etc. makes for a total function.

nikomatsakis: Fair enough, comptime is the better example. I don't think it matters much, I debated if that was even a distinction worth including.

Effect categorization - transformer

TC: I'd call what the doc calls "transformer effects" instead "control flow effects" and say that certain transformations are part of how we implement these. In another language, these might be implemented with segmented stacks, e.g. In ours, we implement them with opaque types that "carry" a (typically coroutine-style) state machine that consumes resumptions and emits effects.

nikomatsakis: I don't love the transformer name. Maybe control-flow effects is better. The one point I didn't really get into in this doc in part because I wanted to rule those out-of-scope is the point about sets versus stacked transformations and how taking a set view on things makes the question of Iterator<Async> or Async<Iterator> just ill-posed, there are just two distinct effects that can occur ("yield to scheduler" or "yield to inner loop"). I think that's a subtle but very important point for avoiding some of the painful problems that occur to monads and so forth.

Target feature effects: fallbacks / polyfills

Jana: here it seems extra important that you can do something if the target feature is not enabled. Possibly prove this at runtime and have the effect available for some part of code. Some effects can be implicitly handled like at main

nikomatsakis: I think this is naturally covered by if/else, right?

if avx512_available() {
    #[unsafe do(avx512)] {
        // ... stuff that requires abx512
    }
} else {
    // ... stuff that doesn't
}

Jana: fair enough, though now you're forced to do unsafe while we could have a primitive for that

nikomatsakis: well, we can't or at least I don't want to start with a primitive because the tests for features can get quite involved. I do think eventually we might want some way to have users be able to supply code for testing if an effect is available or something though you could also do this by having a marker type representing the feature and a trait is_available() or something. Anyway, I agree it'd be nice, just not required for MVP. <- jana: totally!

Jana: fair enough. Some kind of function returning like a "proof" of effect availability.

Only const

Jana: This might be lack of context, but, was there ever a way worked out to represent "only const", i.e. never at runtime. This is relevant because we're still stuck with the situation that const adds a qualifier that removes features. This is an unavoidable corner we worked ourselves into because the default in rust is that a function does have effects.

nikomatsakis: That's what I called "comptime". I think it can be introduced into this framework without great difficulty but I've not put a lot of thought into it. It seems basically equivalent to the avx512 effect and so forth i.e., those change how the compiler generates code, but comptime also changes it by making it a hard error. =)

jana: nods

(Resolved) "Masking" effects vs "Introducing" effects

Josh: Trying to check the mental model here: it seems like you can view this operation either from the inside out as "masking" the effect from a block so it can be run without having the effect, or from the outside in as "introducing" the effect to make it available for code inside the block to use. Are those duals of each other the way they appear to be, or is there some fundamental reason this should be thought of as "masking" rather than "introducing"?

nikomatsakis: I think you can think of it either way. The main thing is that the effect does not "propagate" out from the block to the surrounding context (this is why I called it "masked"). I think that the idea of having a block that "makes the effect available" is better than some of the prior, more masking-oriented versions I thought of (e.g., transforming a T: avx512 Default to a T: Default), in terms of fitting the language better.

josh: Good to know that "introducing" works; thanks for clarifying. Another mental model might be "discharging" an effect, for that matter; that might make sense for unsafe or async or try.

nikomatsakis: Yes. I think that "introducing" and "discharging" map to the same underlying construct, so to speak, but are like different purposes to which it can be put to use. I do think the avx512 and unsafe map pretty closely: like, when a function has the effect avx512, it is essentially putting an extra burden on its caller to ensure that these instructons are available. Seems not unlike unsafe. (Side note: I know that Ralf objects to unsafe being considered an effect, but I don't entirely understand why unless it's that the real effect if like the things that you read/write or otherwise do that require justification; I can kind of see it but it also feels like a pedantic argument to me. I probably am missing something. Haven't dug in because I don't think it matters right now.)

[const] syntax

oli: one conflict we may have in the future is if we have non-keyword effect names. [nopanic] Trait will parse as "slice of nopanic type elements" followed by syntax error because slices don't have more types/traits/paths after them

jana: I do like a "delimited" syntax though, parens or brackets alike. square brackets might be quite similar to attributes too, not just array/slice types.

nikomatsakis: Currently we don't allow trait names in the same places as types without some "introduction", e.g., impl or dyn, correct? But we would be doubling down on that. I guess that for fn pointers also we need to consider it.

jana: Parens have a similar problem where (ident) will just parse as that ident, i.e. a parenthesized type.

nikomatsakis: yes. Of course we could also do [do avx512] or something to disambiguate. I think it's also entirely possible that we just don't wind up doing a lot more "floor" like effects.

jana: I like [do effect] and don't mind that it's a tiny bit more verbose. I think we might not, but it'd be a mistake not to plan for it since it's easy to work ourselves into a corner like that. Especially if we choose some sort of top-down model to developing it like you describe at the end of the text above.

Path to future generic effects?

Josh: Without going into detail on how, are you confident that in the model you could extend to generic effects (e.g. handling Result, or handling "maybe async") in the future?

nikomatsakis: You mean like a try<E> effect?

Josh: Well, two different kinds of "generic" apply here. There's the effect being generic (try<E>), and there's being generic over effects (map over a function that might be async).

nikomatsakis: I guess the short answer is yes.

nikomatsakis: Longer answer: the effects as given already have types in them, which is the major complication that something like try<E> brings over async. When it comes to async, I don't think the model has trouble with it, there's some user-facing decisions we have to make about how we do things, but I think this is compatible with various proposals that have been made (e.g., the do-like proposal I discussed at some point). One of the questions I'm interested in is whether a const trait Default, as written, can be extended to also permit (e.g., async Default), or if more is needed. I remember having insights around this question and I've forgotten what they were. The core idea being: when you declare a const trait, you're already essentially saying: "my base functions will only do const things, but I am also going to call these methods that may do other things", and I don't think it matters very much if those other things are "await to the scheduler" or "panic" (obviously there are impl differences to consider, but from the model perspective, I don't think it matters).

Catching the error of using const Trait when you meant [const] Trait?

Josh: It seems extremely likely to me that someone will write a bound of T: const Default when they meant to write T: [const] Default. What would a lint look like to catch this? Could we detect if your function body doesn't actually require const Default (e.g. it doesn't call Default in a const { ... } block)?

nikomatsakis: It seems very lintable.

oli: clippy already has a "this function could be const fn" lint. That duplicates some work from rustc checking function bodies, but it's pretty robust. Similarly we could add a lint to rustc to look for actual const uses of const Trait bounds, although the uses may be hidden. So we'd likely need to do sth where we change the bound and re-typeck the body, which is likely very expensive. So we'll have to do some experimentation. It's a similar issue to "do you need T: Debug on your function? it doesn't use that trait bound".

Scaling const fn syntax to other effects

tmandry: I generally agree we should start with marker + intrinsic effects (I've been saying so myself), but I also want to look ahead:

If const fn can have constness conditional on its bounds, would we ever want async fn to mean the same? If not, how can we resolve the ambiguity?

nikomatsakis: Solid question. Maybe it'd be a good idea to write a follow-up and spell it out. One question I'd like to dive into is "how much certainty do we need here" formal model makes sense, I don't want to force us to know the syntax in advance.

Bottom-up design (assoc effects)

oli: is this actually going to solve the problem TC is worried about? We can change the current syntax (or add a second syntax for it) to support associated effect bounds just for the cases we can support right now. But that doesn't actually guarantee we can support more things (even tho I strongly believe the current system is 100% forward compatible to it). The only way we'll know for sure is to actually implement associated effects inside the compiler. Otherwise we're just giving the appearance of associated effects working as intended, which obviously doesn't help anyone.

jubilee: Yeah, we have a working model for const trait bounds but I'm not sure it is as easily extensible to very-differently-shaped effects.

Connection to conditional bounds like Send

tmandry: I want to consider this too. The example TC gave

fn foo(x: impl [const] Foo) -> impl [const] Foo { ... }

makes me think of something like the following

fn foo<trait X: [Send]>(x: impl Foo<next(): X>)
-> impl Foo<next(): X>
{ ... }

Why do traits opt in?

tmandry: I'm trying to remember right now why traits need to opt in to constness instead of being able to drop const in front of any trait and implement that.

re: target features


  1. jubilee: Yes, Amanieu has a design in mind for adding whether SIMD attributes have been checked for availability to the core library which would be hypothetically possible to add without requiring the operating-system level support (and may help for cases like embedded where you are the operating-system level support, and can personally directly enable or disable a feature). ↩︎

  2. jubilee: Probably you do in fact want target_feature or some other qualifier for a few different reasons, because the identifiers for features are not guaranteed to be unique between different architectures as they are very fond of reusing random three-letter acronyms and AVX512 isn't just AVX512, it's actually AVX512F + AVX512BW + AVX512VL + AVX512 you get the idea. And that set of features "only" gives you "the ability to use AVX512 with all element granularity you might want." The non-guarantee for non-reuse kinda means that you only want to have strings, or you only want to have them be accessible as true with-path identifiers instead of magic keywords. ↩︎

  3. re: demoralization ↩︎