Changelog
- Mention control flow in item/type position alternative approach
- Mention the specialization-based alternative approach
- Reorganized to put more in the “future extensions” section
- Add @comex’s design for implementing tuples for traits
- Mention that current design is restricted to generic parameters only, discuss headroom for extension to type parameters
- Formatted into multiple sections
- Added more examples to the motivation section
- Added “exhaustive impl” proposal from @binomial0
Motivation
Consider the following code:
struct Bool<const B: bool>;
trait Trait {}
impl Trait for Bool<{true }> {}
impl Trait for Bool<{false}> {}
In current Rust, Trait
is not implemented for Bool
, i.e. using a Bool
where T: Trait
is expected will fail. I believe that this behavior is surprising, and that there should be a way to implement a trait for a const-generic type by implementing it for all values of its generic parameter.
Of course, it can be argued that this language feature is rarely necessary and can often be emulated by using other existing language constructs. For example, the above trait implementation can be trivially rewritten as:
impl<const B: bool> Trait for Bool<{B}> {}
However, this is not always the case. For example, the original motivation for this proposal is that the following trait implementation cannot be rewritten as a single impl block:
// Pick a type among two choices according to a const bool value
trait Select<T, F> { type Out; }
impl<T, F> Select<T, F> for Bool<{true }> { type Out = T; }
impl<T, F> Select<T, F> for Bool<{false}> { type Out = F; }
Further, if the trait implementation strongly diverges from one value of the const generic parameter to another, packing all implementations into a single impl block can lead to less readable code.
For example, it can be argued that this kind of branchy impl block…
trait ComplexTrait {
const FOO: u32;
fn bar();
}
impl<const B: bool> Trait for Bool<{B}> {
const FOO: u32 = if B { some_const_fn() } else { some_other_const_fn() };
fn bar() { if B { some_code() } else { some_other_code() } }
}
…is easier to read as a pair of disjoint impl blocks…
impl Trait for Bool<{true}> {
const FOO: u32 = some_const_fn();
fn bar() { some_code() }
}
impl Trait for Bool<{false}> {
const FOO: u32 = some_other_const_fn();
fn bar() { some_other_code() }
}
…and this readability issue will only become more acute as the complexity of the const generic parameter grows, for example if people try to use an enum
with many variants as a const generic parameter.
Proposed design space
The simplest way to allow the above code would be to perform exhaustiveness checking on the const generic impls and declare the trait to be implemented for the type if it is implemented for all values of its const generic parameters (and other type parameters).
However, if this is felt to be too implicit, a more explicit “impl match” construct could be introduced instead. Here’s a mockup for your bikeshedding pleasure:
impl<const B: bool> Trait for Bool<{B}> match B {
true => { /* impl of Trait for Bool<{true}> goes here */ },
false => { /* impl of Trait for Bool<{false}> goes here */ },
}
Personally, I prefer more “implicit” options, because “explicit” syntax (as done above at least) is quite noisy, induces some rightward drift, may complicate parsing, and does not resolve the ergonomic problem of the compiler behaving in a surprising way on the code at the start of this post.
But I know that some of us care a lot about being explicit, so I’m open to discussing such options as well. After all, there may be nicer explicit syntaxes that I haven’t thought about, or more complex use cases that can only be handled using an explicit syntax (think match guards).
As an interesting middle ground between the fully-implicit form and the fully-explicit form, @binomial0 made the case that users should explicitly assert that they believe the trait was implemented for all variants of the const generic type:
exhaustive impl Trait for Bool;
This would allow the compiler to report an error in cases where not all values of the const parameter were actually covered, much like match
is able to tell us when not all values of an enum
are covered today.
The compiler could also trivially lint about lack of an “exhaustive impl” statement when it’s clear that the code’s author has simply forgotten to add one.
Future extensions
Exhaustive impls for type parameters
As currently proposed, this feature is only usable for exhaustive enumeration of const parameters of a generic type. It would not be possible to use it in order to exhaustively implement a trait for all possible type parameters of a generic type.
To understand why, consider that the reason why this proposal makes sense at all is that some const generic parameters, by construction, only accept a finite set of values, which can therefore be enumerated in a finite set of impl
blocks. This is trivially true of exhaustive enumerations (bool
and any enum
not marked #[non_exhaustive]
), and somewhat less trivially true of other types such as machine integers.
The same cannot be said of type parameters, however. Unbounded type parameters, such as Vec<T>
, trivially accept an infinite number of types and are therefore not suitable for the trait implementation strategy presented here. Less obviously, trait bounds cannot be used to restrict the set of types which a generic type accepts as parameters to a finite set, because it is always possible to roll new implementations of a trait.
One solution would be sealed traits, which can be partially emulated today using pub(crate)
traits. This would allow only the current crate to break an exhaustive impl
by adding new trait implementations, which could be considered acceptable much like the possibility of breakage induced by adding a new enum
variant is generally considered acceptable as long as only the crate providing the enum
can do it (and takes care to either mark the enum
#[non_exhaustive]
or tag a new major semver version when it changed it).
I believe, however, that if sealed traits were to become so important as to affect how trait impl
blocks can be written, they should probably cease to be library-based constructs and receive first-class syntaxic support from the language.
Exhaustive impls for associated const bounds
@comex also proposed using exhaustive disjoint impls based on associated consts (rather than const parameters as discussed above) as part of a strategy for implementing traits for tuples using a recursive head/tail + empty case design:
enum TupleKind {
Empty,
NonEmpty
}
trait Tuple {
const KIND: TupleKind;
}
impl<T> Foo for T where T: Tuple<KIND=TupleKind::Empty> { ... }
impl<T> Foo for T where T: Tuple<KIND=TupleKind::NonEmpty> { ... }
Any language feature aiming to resolve the above problem should probably also allow for this, given that the difference between const parameter values and associated const bounds is irrelevant to the problem at hand.
However, it should be noted that enabling this particular use case will require further language changes, as Trait<CONST=value>
associated const bounds are not valid in Rust today.
So unless we want to resolve that problem at the same time (which yields a bigger RFC, and thus reduces the odds of acceptance), we should only consider this as a future extension that any proposed syntax must support, and not as an issue that we want to directly address today.
Alternatives
Const generics specialization
As @gnzlbg points out, the proposed functionality can be approximated using const generics specialization:
struct Bool<const B: bool>;
trait Trait {}
default impl<const B: bool> Trait for Bool<B> {}
impl Trait for Bool<{true }> {}
impl Trait for Bool<{false}> {}
One remarkable property of this approach, which can be either an advantage or disadvantage depending on the use case, is that Rust will continue to consider the trait impl to be correct if the type used as a const generic parameter is extended (for example, if a new variant is added to an enum
type).
This can improve forward compatibility, or it can cause the codebase to silently break without a compiler error, depending on whether the use case allows a sensible default impl or not.
A net drawback of this approach is that a default implementation of the trait must be written even when there is no real need for it. For example, in the above case, a bool
can not and will never be anything else than true
or false
, so the default impl
is unnecessary boilerplate that will need to be filled up with dummy values, types, and unreachable!()
statements for no good reason.
Control flow in impl or type position
A completely different way to approach this issue would be to allow some degree of control flow based on const
values in type or item position. For example, the above Select
functionality could be implemented like this…
trait Select<T, F> { type Out; }
impl<T, F, const B: bool> Select<T, F> for Bool<{B}> {
type Out = if B { T } else { F };
}
…or even, given permissive enough syntax, by avoiding traits entirely like this:
type Select<T, F, const B: bool> = if B { T } else { F };
…not to mention that we could also handle completely unrelated use cases like this:
match SOME_ENUM_CONST => {
Enum::VariantA => fn foo() { /* does something */ },
Enum::VariantB => fn foo() { /* does something else */ }
}
Such “meta-control flow” would be an extremely powerful metaprogramming tool. It would have the potential to completely supersede existing functionality of perfectible ergonomics like #[cfg()]
. But it would also be incredibly dangerous.
If this functionality is not appropriately restricted, we could end up in scenarios where a library changing the value of a public const could massively affect (and possibly break) other libraries. It would also potentially have major implications in the design of rustc
and the place that const evaluation takes in the compilation process.
This is a much more complex feature to design, with many more trade-offs and hidden implications and I don’t think the Rust community is ready to face this proposal in this “fallow year”. And speaking personally, I certainly am not ready to design and propose that language feature.
Your turn
Do you think that it would make sense to turn this idea into an RFC? Did you see oversights in the above post, or do you have further thoughts on the topic to add? Feel free to comment!