Pre-RFC: Revamped const_trait_impl aka RFC 2632

What if ~const fn simply implied ~const on all generic parameters?

So

// Always const, whether or not the impl of Bar for T is.
// No way to override this behavior
const fn foo_1<T: Bar>() 

// const only if the Bar impl is const
~const fn foo_2<T: Bar>()

// const only if the Bar impl is const,
// but independently of whether the Quux impl is
~const fn foo_3<T: Bar, U: ?const Quux>()  

This avoids semver hazards, as ~const fn isn't currently valid Rust. It also minimizes the amount of ~const that needs to be written.

3 Likes

False. foo() ought to be executed once in this scenario. If it is reevaluated at different compilation stages it is a compiler bug.

The other claims about memory soundness and restrictions are hand wavy and equally false as you fail to consider other implementation strategies besides the one currently chosen.

Again, you take a very strict theoretical definition that isn't enforceable nor is it useful in practice. It is always possible to add another step of indirection such as code generation or pre-build step. E.g a build.rs that introduces randomness. There are valid use cases to "violate" bit perfect artefact reproduction.

The claim that calling external libraries requires disabling ASLR isn't substantiated either. This is an orthogonal design question about the interface with the interpreter. This is a separate FFI concern, and there's no difference between a const function "foo" that I've embedded in my source code and an identical one loaded from a pre-compiled "compiler plugin". Rust already supports this as procedural macros. Are you seriously claiming that the compiler must disable ASLR if I use those?!

What if two different crates which don't depend on each other execute it and then one crate depends on both of them?

With proc macros the exact address of values generally doesn't leak into the final executable. For const fn it likely will.

1 Like

That's a red herring if I ever saw one. Clearly, we were talking before about the same function call evaluated more than once by the compiler. Now you are talking about separate function calls - obviously the same function call cannot exist in two different locations.

A corollary of this is : What if I have a const iterator and call it twice? Would you mandate it to always return the same value? Do we forbid const iterators?

Again, you're making assumptions based off of a specific implementation you had in mind. Leaking addresses of values sounds like a poor choice anyway.

I thought there's a sandbox mechanism to isolate at least the proc macros execution. This is a good idea and I would have expected that it'll be extended to the interpreter as well.

As I've already said, the compiler should be isolated, and I've repeatedly mentioned wasm/wasi as an already available solution for this.
Even putting security concerns aside, sandboxing still gives many benefits: it protects from bugs and crashes and the capability based model allows for better reasoning about resources.

This isn't a new concern, it's the very assumption the const feature has been built upon.

If you want an example of a situation where it would appear in the wild:

// crate A
const fn get_size() -> usize {
  // ...
}

// crate B
fn foo() -> [u8, A::get_size()] {
  // ...
}

// crate C
fn bar(array: [u8, A::get_size()]) {
  // ...
}

// crate D
C::bar(B::foo());

The short version is that const values can't be used in type information soundly unless every const function is guaranteed to return deterministic values, which means const functions can't use certain language features.

No, because the iterator next() method has the following signature:

fn next(&mut self) -> Option<Self::Item>

So the type system "understands" (or will understand, once mut references in const functions are stabilized) that the method mutates the iterator.

However, if you create the same iterator from two different places in the code and call each once, the type system expects that the first call should return the same value for both iterators.

So 0..10 can be a const iterator, but read_file() can't.

7 Likes

That's exactly right. What I'm saying is that this assumption is wrong.

Let's discuss your example: There was a decision (a trade-off!) here to assume that the return types of foo & bar are identical. Another choice here is to consider the reification as a distinct separate step. That means, we first evaluate get_size() and then consider the (return) types with the actual values, say [u8, 42] for foo.

The choice to do the former stems from the desire to flatten if you will the multiple phases of computation here to avoid cross compilation concerns.

Again, this is not a better model. This violates the principle of least surprise since otherwise in rust:

let foo = get_size();
let bar = get_size();

Is not guaranteed to produce foo == bar. So we have divergence instead of consistency.

That doesn't work if foo and bar have a const generic they pass to get_size (for example A::get_size(FOO)) and then a function with a const generic passes it to foo and bar. The function calling foo and bar is typechecked without knowing the concrete value of the const generic, so it isn't possible to know that A::get_size(FOO) evaluates to say 42 when typechecking this function. The only way for this function to be able to pass the typechecker is to require that both A::get_size(FOO) calls evaluate to the same value for any given const generic value FOO.

2 Likes

I think we should avoid having this discussion in a thread about const traits. If you think a (major) change in the current way Rust handles compile-time computed constants would be beneficial, please open a new thread and dedicate discussion to that thread. Thanks!

3 Likes

How deep does it imply? Will it imply, for example:

trait Bar {}
trait Foo {
    type B: Bar;
}
~const fn a<T: Foo<B = u8>> {} // implies `u8: ~const Bar` or `u8: Bar` bound?

This can become even more confusing and complicated, as there are four types of bounds now:

  • T: Trait in const fn: means non-const trait bound
  • T: Trait in ~const fn: means const-if-const trait bound
  • T: ~const Trait: explicit const-if-const bound, redundant in ~const fn
  • T: ?const Trait: explicit non-const bound, redundant in const fn
1 Like

The intention is that T: ~const Trait would no longer be valid in any context; const fn means a function that is always unconditionally const. And "const-if-const" semantics would propagate to associated types in bounds on ~const fns.

I don't think there is a point in doing this, especially if it introduces two concepts at once: ~const fns and ?const bounds.

It also requires a lot of changes for marker trait bounds:

trait Foo {
    type A: Sized;
    type B: Copy;
    type C: MyMarker;
}

If people try to start implementing const Foo they will find it hard because all the associated types require a const version of the marker traits that were never intended to be implemented as const.

If you could special case and avoid this type of change for Sized/Copy bounds, you can't do it for MyMarker because it is user defined.

Hmmm, that is a good point. Maybe instead only imply const-if-constness for explicitly mentioned types?

trait Bar {}
trait Foo {
    type Assoc: Bar;
}

// const only if Foo impl is const,
// but independently of whether Bar impl for <T as Foo>::B is
~const fn a<T: Foo>()

// const only if Foo impl is const,
// and Bar impl for <T as Foo>::B is as well
~const fn b<A: Bar, T: Foo<Assoc = A>>()

Also, independently of this, maybe marker traits should have "always const" semantics. This would fit well with RFC 1268

Edit: another alternative

// const only if Foo impl is const,
// and so is Bar impl for <T as Foo>::Assoc
~const fn a<T: Foo>()

// const only if Foo impl is const,
// but indepedently of whether Bar impl for <T as Foo>::Assoc is
~const fn b<T: Foo<Assoc: ?const Bar>>()

Edit: one more possibility

// const only if Foo impl is const,
// and so is Bar impl for <T as Foo>::Assoc
~const fn a<T: Foo>()

// const only if Foo impl is const,
// but indepedently of whether Bar impl for <T as Foo>::Assoc is
~const fn b<T: Foo>() where <T as Foo>::Assoc: ?const Bar

I am not sure the const Trait bound without the ~ will have so much usage. Wouldn't it be possible to make const Trait in bounds behave as it is currently planned for ~const Trait.

2 Likes

~const Trait will definitely be more common in libraries, but every use terminates with a const Trait use at some point, because it needs to be actually used in a const (type or associated) context at some point.

It's a similar argument imho to whether cargo new should default to --lib or --bin; there's clearly more libs on crates-io than bins, but for every lib there should be more than one bin using it.

The same logic to ~const Trait: for every ~const API, there's some API that uses it as const (otherwise it'd not exist and be ~const), and likely more than one.

The wrinkle here is of course transitivity (you can have libs only used by other libs, which are then used by a bin; you can have ~const APIs only used by other ~const APIs which are then used by a const context). However, the law of big numbers suggests that in the asymptote we will have more terminal cases (bin, const) than nonterminal cases (lib, ~const).

There's another wrinkle specific to ~const, though: not every const context needs to explicitly write const Trait. If concrete types are used rather than generics, we have a terminal case using const Trait without ever writing const Trait in the source code.

Either way, though, we do need a way to write both ~const Trait, const Trait, and ?const Trait.[1] This proposal gives the current "I don't care" (: ?const Trait) the default : Trait, the "always" to : const Trait, and the conditional to : ~const Trait. Any counterproposal needs to provide for all three to be writable.

Personally, I'm happy to get it implemented with any syntax, because the semantics and implementation will have some cooking to be done before stabilization. I expect ~const to be the .await discussion of const evaluation, and happily await the relevant teams' discussion and decision in the matter.


  1. IIRC believe the original proposal was actually to have : Trait mean : ~const Trait (const if const), have : ?const Trait to opt out, and : const Trait then means it's always const, but which can't work without breaking changes because trait bounds on const fn were accidentally not entirely prevented (and the workaround is being used).

    (Which I still personally like as an idea and am unclear on why that was discarded as an over-edition-addition (perhaps it was explained and I just forgot?). It's even consistent into non-const fn, as const-if-const is just not-const in that position.) ↩ī¸Ž

2 Likes

I just have three questions to add to the discussion at this point, and no answers yet to propose:

  • How do the tradeoffs differ between const Trait as a feature and a theoretical async Trait, doing roughly the same transformation for the async modifier as for the const modifier?
  • Can the ~const Trait semantics be theoretically applied for a ~async Trait feature?
    • Note linking the two: const fn is conditionally const; is async fn conceivably conditionally async, or is it always async?[1]
  • Are the differences between const and async different enough that we should have trait Iterator, trait const Iterator, and trait AsyncIterator (and maybe trait const AsyncIterator? Is const async a meaningful concept? Is async const?); or are they similar enough that we should have trait Iterator, trait const Iterator, and trait async Iterator (and maybe trait const async Iterator/async const Iterator)?

  1. For a bit of rambling: I think we've decided that async is different enough from const because const doesn't change the call syntax, but async does (and both have a context requirement). const is fundamentally a different kind of function, whereas async is ultimately a normal function returning an impl Future, which is then invoked differently from impl FnMut. I still agree that "explicit await, implicit async" was the correct design for Rust (though I've long since forgotten my exact reasoning), but "implicit await, explicit async" (NB: explicit async is exactly a closure) is still a fascinating alternative design choice, and one I think makes async much closer to const, specifically because the call syntax is identical. In a world of "implicit await, explicit async," all of the arguments for const Trait and related features apply without any change to async Trait. And in the extreme, you end up where all code should be conditionally const and async. ↩ī¸Ž

2 Likes

I think there was a bit of discussion around consistency for const fn pointers, const dyn Trait, and const impl Trait types.

  1. If we inferred ~const bound for the next edition for const fns, and use ?const as an opt-out, firstly we must provide an escape hatch for people who want to use non-const trait bounds in const fn. Therefore ?const must be stable for the next edition (do we have enough time to stabilize const traits before the next edition?)

  2. Inconsistent for future features (const fn pointers, const dyn Trait, and const impl Trait). We cannot infer ~const for those types as const FOO: fn() = || {}; is already legal. So do we require every fn() type to be declared as ?const fn()? That'd require even more changes than the current proposal.

FWIW, that trait bounds are allowed in const fn at all wasn't intended. Try the simple way today, and it says "trait bounds on const fn are unstable."

Remember, it's dyn (const Trait) and impl (const Trait)

In my head, fn() is different enough because it isn't a trait type, and you can just use impl ~const Fn() if you want const agnosticism. Similarly I don't see the need to have dyn ~const Trait, but understand the desire to support it the same way impl ~const Trait is handled, and understand the problems if they behave differently w.r.t. const.

But I do have a follow up question: is this allowed? What does it mean?

struct Foo {
    item: &dyn ~const Trait,
}

If it's not allowed, why? Presumably &dyn const Trait would be allowed there...

I assumed that ~const Trait would only be allowed on trait bounds, except impl ~const Trait, which also desugars to a trait bound:

~const fn foo(bar: impl ~const Bar) {}
// desugars to
~const fn foo<B: ~const Bar>(bar: B) {}

But I just realized that the ~const Trait syntax also appears when implementing a trait as const:

impl<T: ~const Default> ~const Default for MyType<T> {}

Which I think is inconsistent; the following would make more sense to me:

~const impl<T: ~const Default> Default for MyType<T> {}

Because Default is not a "const trait" (does that even exist?). It is the implementation of the trait that is const, not the trait itself.

I think the idea is that basically Default and const Default are two separate traits (but they are related of course, the latter implying the former). Then impl const Default for MyType makes sense. And impl<T: ~const Default> ~const Default for MyType<T> is basically syntactic sugar for

impl<C: constness, T: const(C) Default> const(C) Default for MyType<T> {}

which spells out impl<T: Default> Default for MyType<T> and impl<T: const Default> const Default for MyType<T> without repeating oneself.

That's also why ~const does not make sense in type definitions (to answer @CAD97 's question) -- it is sugar for implicitly referring to an unnamed C: constness parameter so that we can write an impl of the const and non-const trait at the same time.

3 Likes

I think there should be a crater run for uses like this before attempting to gate all trait bounds in const fn. The snippet below already works on stable. If we were to gate uses like this behind a feature, and ?const is not stable, it becomes extremely hard to stabilize.

trait Foo {
    const A: usize;
}

trait Hack {
    const A: usize;
}

impl<T: Foo> Hack for (T,) {
    const A: usize = <T as Foo>::A;
}

const fn oops<T>() -> usize where (T,): Hack {
    <(T,) as Hack>::A
}

Yes, I misspoke

It is allowed, and it is supposed to mean when creating a trait object at compile time we must ensure the actual instance implements const Trait. Therefore we can call this trait object's methods at compile time. It behaves like dyn Trait when in runtime.