Pre-RFC: Revamped const_trait_impl aka RFC 2632

Author's Note: This is copied from RFC 2632 by @oli-obk, but changed to match a discussion on zulip (archive) where we settled on something that is better than the original proposal. This is to encourage more discussions around the new syntax and also acts as a scaffold to reopen the RFC with its contents changed to match the new syntax.


Summary

Allow impl const Trait for trait impls where all method impls are checked as const fn.

Introducea new syntax for trait bounds: T: ~const Trait must be satisfied with const impls when called in a const context. This allows the body of the const fn to call trait methods on the generic parameters.

Motivation

Without this RFC one can declare const fns with generic parameters that have trait bounds, but one is not able to call trait methods on the generic parameters, because we can't enforce the trait methods to be const.

Guide-level explanation

You can mark trait implementations as having only const fn methods. Instead of adding a const modifier to all methods of a trait impl, the modifier is added to the trait of the impl block:

struct MyInt(i8);
impl const Add for MyInt {
    fn add(self, other: Self) -> Self {
        MyInt(self.0 + other.0)
    }
}

You cannot implement both const Add and Add for any type, since the const Add impl is used as a regular impl outside of const contexts. Inside a const context, you can now call this method, even via its corresponding operator:

const FOO: MyInt = MyInt(42).add(MyInt(33));
const BAR: MyInt = MyInt(42) + MyInt(33);

You can also call methods of generic parameters of a const function when they are bounded with ~const. For example, the ~const Add trait bound can be used to call Add::add or + on the arguments with that bound.

const fn triple_add<T: ~const Add<Output=T>>(a: T, b: T, c: T) -> T {
    a + b + c
}

The obligation is passed to the caller of your triple_add function to supply a type which has a const Add impl.

To ensure associated types' bounds require impl consts for the type used for the type, one must also use ~const on the bound:

trait Foo {
    type Bar: ~const Add; // means "when in a const impl, requires Bar to have a const Add impl"
}
impl const Foo for A {
    type Bar = B; // B must have an `impl const Add for B`
}

If an associated type has no bounds in the trait, there are no restrictions to what types may be used for it. If the bounds are not annotated with ~const, then it does not require impl consts for those types.

Generic bounds

The above section skimmed over a few topics for brevity. First of all, impl const items can also have generic parameters and thus bounds on these parameters, and these bounds should also be marked with ~const when you need all bounds to only be substituted with types that have impl const items for all the bounds. Thus the T in the following impl requires that when MyType<T> is used in a const context, T needs to have an impl const Add for Foo.

impl<T: ~const Add> const Add for MyType<T> {
    /* some code here */
}
const FOO: MyType<u32> = ...;
const BAR: MyType<u32> = FOO + FOO; // only legal because `u32: const Add`

Furthermore, if MyType is used outside a const context, there are no constness requirements on the bounds for types substituted for T.

Drop

A notable use case of impl const is defining Drop impls. Since const evaluation has no side effects, there is no simple example that showcases const Drop in any useful way. Instead we create a Drop impl that has user visible side effects:

let mut x = 42;
SomeDropType(&mut x);
// x is now 41

struct SomeDropType<'a>(&'mut u32);
impl const Drop for SomeDropType {
    fn drop(&mut self) {
        *self.0 -= 1;
    }
}

You are now allowed to actually let a value of SomeDropType get dropped within a constant evaluation. This means that

(SomeDropType(&mut 69), 42).1

is now allowed, because we can prove that everything from the creation of the value to the destruction is const evaluable.

const Drop in generic code

To be able to know that a T can be dropped in a const fn, T: ~const Drop will be treated specially. In non-const functions this would make no difference, but const fn adding such a bound would allow dropping values of type T inside the const function. Additionally it would forbid calling a const fn with a T: ~const Drop bound with types that have non-const Drop impls (or have a field that has a non-const Drop impl).

struct Foo;
impl Drop for Foo { fn drop(&mut self) {} }
struct Bar;
impl const Drop for Bar { fn drop(&mut self) {} }
struct Boo;
// cannot call with `T == Foo`, because of missing `const Drop` impl
// `Bar` and `Boo` are ok
const fn foo<T: ~const Drop>(t: T) {}

Note that one cannot implement const Drop for structs with fields with just a regular Drop impl. While from a language perspective nothing speaks against that, this would be very surprising for users. Additionally it would make const Drop pretty useless.

struct Foo;
impl Drop for Foo { fn drop(&mut self) {} }
struct Bar(Foo);
impl const Drop for Bar { fn drop(&mut self) {} } // not ok
// cannot call with `T == Foo`, because of missing `const Drop` impl
const fn foo<T: ~const Drop>(t: T) {
    // Let t run out of scope and get dropped.
    // Would not be ok if `T` is `Bar`,
    // because the drop glue would drop `Bar`'s `Foo` field after the `Bar::drop` had been called.
    // This function is therefore not accepted by the compiler.
}

Runtime uses don't have const restrictions

impl const blocks are treated as if the constness is a generic parameter (see also effect systems in the alternatives).

E.g.

impl<T: ~const Add> const Add for Foo<T> {
    fn add(self, other: Self) -> Self {
        Foo(self.0 + other.0)
    }
}
#[derive(Debug)]
struct Bar;
impl Add for Bar {
    fn add(self, other: Self) -> Self {
        println!("hello from the otter side: {:?}", other);
        self
    }
}
impl Neg for Bar {
    fn neg(self) -> Self {
        self
    }
}

allows calling Foo(Bar) + Foo(Bar) even though that is most definitely not const, because Bar only has an impl Add for Bar and not an impl const Add for Bar. Expressed in some sort of effect system syntax (neither effect syntax nor effect semantics are proposed by this RFC, the following is just for demonstration purposes):

impl<c: constness, T: const(c) Add> const(c) Add for Foo<T> {
    const(c) fn add(self, other: Self) -> Self {
        Foo(self.0 + other.0)
    }
}

In this scheme on can see that if the c parameter is set to const, the T parameter requires a const Add bound, and creates a const Add impl for Foo<T> which then has a const fn add method. On the other hand, if c is "may or may not be const", we get a regular impl without any constness anywhere. For regular impls one can still pass a T which has a const Add impl, but that won't cause any constness for Foo<T>.

This goes in hand with the current scheme for const functions, which may also be called at runtime with runtime arguments, but are checked for soundness as if they were called in a const context. E.g. the following function may be called as add(Bar, Bar) at runtime.

const fn add<T: ~const Neg, U: ~const Add<T>>(a: T, b: U) -> T {
    -a + b
}

Using the same effect syntax from above:

<c: constness> const(c) fn add<T: const(c) Neg, U: const(c) Add<T>>(a: T, b: U) -> T {
    -a + b
}

Here the value of c decides both whether the add function is const and whether its parameter T has a const Add impl. Since both use the same constness variable, T is guaranteed to have a const Add if add is const.

This feature could have been added in the future in a backwards compatible manner, but without it the use of const impls is very restricted for the generic types of the standard library due to backwards compatibility. Changing an impl to only allow generic types which have a const impl for their bounds would break situations like the one described above.

const default method bodies

Trait methods can have default bodies for methods that are used if the method is not mentioned in an impl. This has several uses, most notably

  • reducing code repetition between impls that are all the same
  • adding new methods is not a breaking change if they also have a default body

In order to keep both advantages in the presence of impl consts, we need a way to declare the method default body as being const. The exact syntax for doing so is left as an open question to be decided during the implementation and following final comment period. For now one can add the placeholder #[default_method_body_is_const] attribute to the method.

trait Foo {
    #[default_method_body_is_const]
    fn bar() {}
}

While we could use const fn bar() {} as a syntax, that would conflict with future work ideas like const trait methods or const trait declarations.

Reference-level explanation

The implementation of this RFC is (in contrast to some of its alternatives) mostly changes around the syntax of the language (allowing const modifiers in a few places) and ensuring that lowering to HIR and MIR keeps track of that. The miri engine already fully supports calling methods on generic bounds, there's just no way of declaring them. Checking methods for constness is already implemented for inherent methods. The implementation will have to extend those checks to also run on methods of impl const items.

Precedence

A bound with multiple traits only ever binds the const to the next trait, so ~const Foo + Bar only means that one has a const Foo impl and a regular Bar impl. If both bounds are supposed to be ~const, one needs to write ~const Foo + ~const Bar. More complex bounds might need parentheses.

Implementation instructions

(deleted)

Const type theory

(deleted)

Drawbacks

  • It is not a fully general design that supports every possible use case, but it covers the most common cases. See also the alternatives.
  • It becomes a breaking change to add a new method to a trait, even if that method has a default impl. One needs to provide a const default impl to not make the change a breaking change.
  • It becomes a breaking change to add a field (even a private one) that has a Drop impl which is not const Drop (or which has such a field).

Rationale and alternatives

ConstDrop trait to opt into const-droppability

(deleted)

Effect system

A fully powered effect system can allow us to do fine grained constness propagation (or no propagation where undesirable). This is out of scope in the near future and this RFC is forward compatible to have its background impl be an effect system.

Fine grained const annotations

One could annotate methods instead of impls, allowing just marking some method impls as const fn. This would require some sort of "const bounds" in generic functions that can be applied to specific methods. E.g. where <T as Add>::add: const or something of the sort. This design is more complex than the current one and we'd probably want the current one as sugar anyway.

Require const bounds everywhere

(replaced with ~const bounds)

Infer all the things

We can just throw all this complexity out the door and allow calling any method on generic parameters without an extra annotation iff that method satisfies const fn. So we'd still annotate methods in trait impls, but we would not block calling a function on whether the generic parameters fulfill some sort of constness rules. Instead we'd catch this during const evaluation.

This is strictly the least restrictive and generic variant, but is a semver hazard as changing a const fn's body to suddenly call a method that it did not before can break users of the function.

Future work

This design is explicitly forward compatible to all future extensions the author could think about. Notable mentions (see also the alternatives section):

  • an effect system with a "notconst" effect
  • const trait bounds on non-const functions allowing the use of the generic parameter in constant expressions in the body of the function or maybe even for array lenghts in the signature of the function
  • fine grained bounds for single methods and their bounds (e.g. stating that a single method is const)

It might also be desirable to make the automatic Fn* impls on function types and pointers const. This change should probably go in hand with allowing const fn pointers on const functions that support being called (in contrast to regular function pointers).

Deriving impl const

#[derive(Clone)]
pub struct Foo(Bar);

struct Bar;

impl const Clone for Bar {
    fn clone(&self) -> Self { Bar }
}

could theoretically have a scheme inferring Foo's Clone impl to be const. If some time later the impl const Clone for Bar (a private type) is changed to just impl, Foo's Clone impl would suddenly stop being const, without any visible change to the API. This should not be allowed for the same reason as why we're not inferring const on functions: changes to private things should not affect the constness of public things, because that is not compatible with semver.

One possible solution is to require an explicit const in the derive:

#[derive(const Clone)]
pub struct Foo(Bar);

struct Bar;

impl const Clone for Bar {
    fn clone(&self) -> Self { Bar }
}

which would generate a impl const Clone for Foo block which would fail to compile if any of Foo's fields (so just Bar in this example) are not implementing Clone via impl const. The obligation is now on the crate author to keep the public API semver compatible, but they can't accidentally fail to uphold that obligation by changing private things.

RPIT (Return position impl trait)

const fn foo() -> impl Bar { /* code here */ }

does not allow us to call any methods on the result of a call to foo, if we are in a const context. It seems like a natural extension to this RFC to allow

const fn foo() -> impl const Bar { /* code here */ }

which requires that the function only returns types with impl const Bar blocks.

Specialization

Impl specialization is still unstable. There should be a separate RFC for declaring how const impl blocks and specialization interact. For now one may not have both default and const modifiers on impl blocks.

const trait methods

This RFC does not touch trait methods at all, all traits are defined as they would be defined without const functions existing. A future extension could allow

trait Foo {
    const fn a() -> i32;
    fn b() -> i32;
}

Where all trait impls must provide a const function for a, allowing

const fn foo<T: Foo>() -> i32 {
    T::a()
}

even though T is not bounded by the ~const modifier.

The author of this RFC believes this feature to be unnecessary, since one can get the same effect by splitting the trait into its const and nonconst parts:

trait FooA {
    fn a() -> i32;
}
trait FooB {
    fn b() -> i32;
}
const fn foo<T: ~const FooA + FooB>() -> i32 {
    T::a()
}

Impls of the two traits can then decide constness of either impl at their leasure.

const traits

A further extension could be const trait declarations, which desugar to all methods being const:

const trait V {
    fn foo(C) -> D;
    fn bar(E) -> F;
}
// ...desugars to...
trait V {
    const fn foo(C) -> D;
    const fn bar(E) -> F;
}

const function pointers and dyn Trait

See the original RFC for more details.

explicit const bounds

const on the bounds (e.g. T: const Trait) requires an impl const Trait for any types used to replace T. This allows const trait bounds on any (even non-const) functions, e.g. in

fn foo<T: const Bar>() -> i32 {
    const FOO: i32 = T::bar();
    FOO
}

Which, once const items and array lengths inside of functions can make use of the generics of the function, would allow the above function to actually exist.

Unresolved questions

Resolve syntax for making default method bodies const

The syntax for specifying that a trait method's default body is const is left unspecified and uses the #[default_method_body_is_const] attribute as the placeholder syntax.

Resolve keyword order of impl const Trait

There are two possible ways to write the keywords const and impl:

  • const impl Trait for Type
  • impl const Trait for Type

The RFC favors the latter, as it mirrors the fact that trait bounds can be const. The constness is not part of the impl block, but of how the trait is treated. This is in contrast to unsafe impl Trait for Type, where the unsafe is irrelevant to users of the type.

Resolve syntax of ~const

This RFC would introduce another sigil if we decide to use ~const. ?const would not be a good option as giving the meaning of ~const to ?const makes it inconsistent with the meaning of ?Sized.


Closing Note: There are two fundemantal changes to the original RFC:

  • Semantics Change: Previously, T: Trait was inferred to be a const bound and ?const was the syntax to opt-out the inferred bound. In this RFC T: Trait will not be changed at all, we add a new modifier T: (whatever) Trait to signify a const bound. (note that it is not strictly a const bound, but a "const-if-const" bound. If the const fn is used in runtime, then those bounds are treated as normal bounds.)

  • Syntax Change: We use ~const instead of ?const to signify the "const-if-const"ness. Please comment if you can come up with something better than ~const, this is definitely up for bikeshed.

10 Likes

I’m wondering about the interaction with supertraits.

I think there’s a semver hazard with default methods. Previously I could have a trait

trait Foo {
    fn foo(&self) -> i32;
}

and then, in a future version of the crate defining this trait, change it to

trait Foo {
    fn foo(&self) -> i32;
    fn bar(&self) -> Box<i32> {
        println!("boxing!");
        Box::new(self.foo())
    }
}

with a minor version bump.

Now, with const Trait, a user of my trait could’ve implemented

impl const Foo for SomeType {
    fn foo(&self) -> i32 { 42 }
}

and that implementation becomes illegal after the minor version bump (because the default method body isn’t const), i.e. it’s a breaking change after all.

Feels like it may be a good idea to require every trait that can support const-implementation to be explicitly marked?


Edit: Just noticed that that’s listed as a drawback

  • It becomes a breaking change to add a new method to a trait, even if that method has a default impl. One needs to provide a const default impl to not make the change a breaking change.

I don’t think this is “just” a drawback. I’d question whether it isn’t a breaking change for rustc itself to turn new things into breaking changes like that. Especially considering that one of the main purposes of default implemented methods is the ability to introduce them breakage-free. I strongly feel like only explicitly marked traits should support const Trait implementations.

9 Likes

The final RFC should probably include at least a nod to why the previous ?const design is rejected in favor of ~const. I think I recall some of the reasons, but it's good to document them.

6 Likes

It might be a good idea, but I think I should also be allowed to const-impl any trait from a crate. So would it become a warning to const-impl a trait that is not explicitly marked as "const-implable"?

Hmm, I don’t feel like a warning is enough in order to avoid semver hazards.

In order to const-impl a trait that doesn’t support it by itself, you might be able to help out by defining your own subtrait. Especially in combination with a fine-grained way of specifying constness per method, for a trait

trait Foo {
    fn bar(&self);
    fn baz(&self);
}

you could write a subtrait

// the ~const means that the type supports `const ConstFoo`
// implementations and `const ConstFoo` / `~const ConstFoo` bounds
~const trait ConstFoo: Foo
where
    <Self as Foo>::bar: ~const,
    <Self as Foo>::baz: ~const,
{}
/* this supertrait bound and where clause is supposed to mean that
     * Types that implement `ConstFoo` also have to implement `Foo`
     * Types that implement `const ConstFoo` also have to implement `Foo`
       (not `const Foo`, which isn’t allowed
       when `Foo` isn’t a `~const trait`)
       And in this implementation, the methods `bar` and `baz`
       must be `const fn`.
*/

~const impl<T: Foo + ?Sized> ConstFoo for T
where
    <T as Foo>::bar: ~const,
    <T as Foo>::baz: ~const,
{}
/* this implementation is supposed to mean:
     * Implement `ConstFoo` for every type that implements `Foo`
     * Implement `const ConstFoo` for every type that implements `Foo` such
       that both methods `bar` and `baz` are `const fn`
*/

Now, Foo doesn’t support const impl, but any implementation

impl Foo for SomeType {
    const fn foo(&self) { … }
    const fn bar(&self) { … }
}

would have the effect that SomeType: const ConstFoo.

Foo could still add new methods in the future. If these methods don’t have const-bodied default implementations, then they aren’t available in ConstFoo. If they do have const-body default implementations, then ConstFoo can be updated to include those methods without breakage.


If Foo does eventually get updated to be a ~const trait (i.e. opt into supporting const Foo), then the existing ConstFoo could be eliminated without breakage, as long as an implementation like the one above, i.e.

impl Foo for SomeType {
    const fn foo(&self) { … }
    const fn bar(&self) { … }
}

on a ~const trait Foo is equivalent to a

const impl Foo for SomeType {
    fn foo(&self) { … }
    fn bar(&self) { … }
}

implementation. Then after the upstream change to Foo to make it a ~const trait, we could change ConstFoo to be a pub use Foo as ConstFoo;


All the above is speculative, possibly assuming features not intended in this pre-RFC (perhaps not even in the future work) and assuming I’m not missing any obvious problems. Disclaimer: I haven’t even read the pre-RFC completely in detail yet.

A possible (but uncomfy) choice to allow nonconst default methods to still be added is to allow and require them to be provided as e.g. ?const fn where the function is not necessarily const even in a const Trait context.

While this keeps it possible to add a nonconst default method backwards compatibility, it changes the method of doing so, which still isn't great.

Only specifying specific items on a trait as being const seems the only way around being able to add unanotated nonconst default impld methods without breaking impl const Trait consumers.

The "whole trait const" approach could still be supported with the trait opting in to only ever adding new const capable default methods.

1 Like

It took me a bit to understand this, but here's a very succinct explanation:

if some crate did this as a minor version update before that rustc version was released. The release of that rustc version made this update introduce a breaking change

basically the problem is that you can be on an older version of a crate when you bump rustc, and then bumping that crate breaks your code

5 Likes

Is there a reason that the default for bounds in const fns isn't conditional constness?

I'd rather write

const fn foo<T: Add>(l: T, r: T) -> T {
    l + r
}

than write ~const Trait bounds all over the place.

In here, foo requires T: const Add(T implements const Add) when called in const and static items, and T: Add(T maybe implements non-const Add) when called in runtime contexts.

When foo is called inside a const fn bar<T, it requires that you write either T: const Add(T implements const Add) or T: Add(T implements Add, and the function is const conditional on the constness of that impl), the second propagates the conditional constness of the bound to the caller .

I can see that there's an argument about it being more consistent with non-const functions to require ~const Trait, but it should be acknowledged that this comes at the cost of making const fns more verbose to write than non-const fns

6 Likes

This was the original RFC, with ?const to opt out of the implicit ~const:

I believe the issue with that approach is that (in edge cases) IIRC it's accidentally currently possible to have and use a useful trait bound in const fn, and defaulting to only allowing const Trait impls would break behavior there.

I don't recall the actual examples, thus my request to clarify why the ?const approach was rejected.

6 Likes

The main reason is to make it consistent with future extensions. In the future we might have dyn const traits as well as const fn pointers. We cannot infer those types to be const as that would break a lot of existing code, for example:

pub struct S<T>(fn(T) -> T);
impl<T> S<T> {
    pub const new(f: fn(T) -> T) -> Self { // `f` shouldn't be required to be a const fn pointer.
        Self(f)
    }
}

But with this modified proposal, we make it an explicit opt-in via ~const. This makes people think before requiring a const bound (so it also prevents people who don't know how this feature works from writing overly-restrictive bounds).

1 Like

Also note that we already failed to prevent trait bounds:

struct Foo<T>(T);

trait Trait {
    const USIZE: usize;
}

impl<T> Foo<T>
where
    Self: Trait,
{
    /// Gets the usize value of this type
    pub const fn to_usize(&self) -> usize {
        <Self as Trait>::USIZE
    }
}

so at this point we have no option but to go with an explicit scheme like this pre-rfc suggests.

3 Likes

Free-standing

/// Gets the usize value of this type
pub const fn to_usize_1<T>() -> usize
where
    Foo<T>: Trait,
{
    <Foo<T> as Trait>::USIZE
}

works as-well.

Looks like this approach can indeed quite generally circumvent the existing restrictions:

pub trait Trait {
    const USIZE: usize;
}

/* disallowed

pub const fn to_usize_1<T>() -> usize
where
    T: Trait,
{
    <T as Trait>::USIZE
}

*/

// workaround
pub struct Wrapper<T>(T);
impl<T: Trait> Trait for Wrapper<T> {
    const USIZE: usize = T::USIZE;
}
pub const fn to_usize_2<T>() -> usize
where
    Wrapper<T>: Trait,
{
    <Wrapper<T> as Trait>::USIZE
}

and let’s use it, for good measure

impl Trait for Foo {
    const USIZE: usize = 42;
}
struct Foo;

fn main() {
    const C: usize = to_usize_2::<Foo>();
    dbg!(C);
}

Your code example seems to work since the introduction of const fn with 1.31 on Dec. 6, 2018. @oli-obk is there an existing issue describing this oversight? If we keep this accidental stabilization around, going further and removing all of the existing restrictions on T: Trait in const fn seems quite reasonable, too.

I think it is #83452.

1 Like

Yes, that is essentially what this PreRFC paves the way for. We should be careful when swapping the logic like this to make sure we don't accidentally stabilize something else. So if we do this RFC unstably then stabilizing nonconst bounds will become as simple as removing a check

An idea for a different syntax for T: ~const Trait: embrace the future extension to a effect system:
use T: const<> Trait as shorthand for the future effect system's syntax of: T: const<C> Trait.
alternatively, if the effect system will just use boolean expressions, use T: const() Trait as shorthand for stuff like T: const(A && B) Trait

3 Likes

I think I would prefer a syntax like impl<T: ~const Add> ~const Add for MyType<T>. After all, both of these occurrences of const are "synchronized", one must be present if and only if the other one is. So it seems strange that one of them would have a leading tilde and the other would not. This was a major source of confusion for me already in the previous ?const version: it looks like we are defining something here that always is a const Add implementation, but in fact we do not!

Basically I feel like everything that is const(c) in the explicit desugaring should become ~const. That would make the desugaring very easy and consistent.

On the other hand, for consistency we should then probably also write ~const fn foo<T: ~const Add>. Not sure how I feel about that.

6 Likes

I think I would prefer a syntax like impl<T: ~const Add> ~const Add for MyType<T> . After all, both of these occurrences of const are "synchronized", one must be present if and only if the other one is.

But what is the ~ really doing in the syntax? Could this be replaced by

impl<T: const Add> const Add for MyType<T> {
    /* some code here */
}

instead?

No. Because what about const dyn Trait and const fn(u8) -> i8 in the future? It makes sense because const is "always const", and ~const is "not always const, depends".

Just a note, as an entousist, but non expert Rust user. ?Trait is already what I consider the most complicated part of Rust syntax. I understand what it does, and why it exists, but I always need to stop each time I read them. Adding `~const would double down on the sigil soup. I think that the cost of adding an additional sigil should be weighted very carefully.

Re-using existing ones, like const<> or const() feels better (if this is really the shorthand of another more complex future notation), but by themselves those notations aren't very clear either. Unfortunately I don't have anything to suggest.

10 Likes