Summary
Distinguish between !Trait
and ?Trait
to enable mutually exclusive traits within the constraint that implementing traits should be a backwards compatible change. With these two type-trait relations distinguished, allow negative impls of all traits as well as negative bounds, introducing mutual exclusion to the type system. This enables users to encode additional logic in the type system & to provide multiple coherent blanket implementations for parameterized types with mutually exclusive trait bounds, without the backwards compat problems that have hampered past proposals.
Motivation
Trait coherence rules ensure that the compiler will predictably associate a particular definition with any invocation of a method or item associated with that trait. Without coherence rules, the behavior of a Rust program could easily become disastrously non-deterministic.
However, coherence rules are currently very conservative in what they allow to be implemented. Other RFCs may allow for multiple overlapping implementations through developing an order of precedence for specialized implementations. This RFC addresses the limitation from another angle. By introducing a concept of mutually exclusive traits, it becomes possible to declare that two traits must not both be implemented by a single type, causing parameters bound by each of those traits to be non-overlapping.
This is concretely useful; imagine a system with the traits Edible
, Poisonous
and Consumable
; though no trait should be both Edible
and Poisonous
, it is not currently possible to implement Consumable
for T: Edible
and T: Poisonous
because it cannot be guaranteed that a type will not implement both. With mutual exclusivity, this becomes possible.
trait Edible: !Poisonous {
...
}
trait Poisonous: !Edible {
...
}
trait Consumable {
...
}
impl<T> Consumable for T where T: Edible {
...
}
impl<T> Consumable for T where T: Poisonous {
...
}
Though this example is fairly trivial, this has a big impact on the ability of clients to compose multiple libraries together. Bounded blanket implementations are almost never possible under the current trait rules. Currently, if two libraries share an upstream library, both can extend the behavior of types in that library by defining new traits for that type. With this proposal, if the upstream library provides mutually exclusive traits, downstream library A could implement a type for all T
bounded by one of those traits, and downstream library B could implement that trait for a new type, a client of both libraries can now call methods of the trait in library A on the type in library B.
A second benefit somewhat less concrete, because it has to do with what you cannot do. By encoding mutual exclusivity in the type system, programmers can guarantee that they will never by some accident create a type which implements both traits. Essentially, a greater portion of program logic is implemented in a manner which is statically analyzable. For example, the num
crate currently defines both Signed
and Unsigned
, but nothing prevents a type from implementing both traits. Clients of num
cannot safely rely on an Unsigned
bound to guarantee that a type is not Signed
, even though that is the entire purpose of the trait (which is currently a marker trait).
Detailed design
Trait, !Trait, and ?Trait
An earlier RFC attempting to codify negative reasoning ran aground on the problem of backwards compatibility. If it is possible to bound a type parameter or trait by the non-implementation of another trait, then that non-implementation becomes a semantic expression, and it would be backwards incompatible to āeliminateā the non-implementation by implementing that trait. This backwards incompatibility hazard is a serious one, and applied to the implicit negative reasoning of certain non-overlapping implementations.
The solution to this problem which would enable mutual exclusivity is to hold the relation between types and traits to be in three states, not two:
-
T: Trait
: The typeT
implements the traitTrait
. -
T: !Trait
: The typeT
does not implement the traitTrait
. -
T: ?Trait
: The typeT
may or may not implement the traitTrait
.
By default, the relation between any T
and any Trait
is T: ?Trait
. Only with an implementation of Trait
or !Trait
which matches T
does this change. This table documents the three relations and how they are described:
| | ?Trait | Trait | !Trait |
|---------------|-------------------|----------------------|--------------------|
| Specific impl | by default | impl Trait for T | impl !Trait for T |
| | impl ?Trait for T | | |
|---------------|-------------------|----------------------|--------------------|
| Default impl | by default | impl Trait for .. | impl !Trait for .. |
|---------------|-------------------|----------------------|--------------------|
| Bounds | by default | where T: Trait | where T: !Trait |
| | where T: ?Sized | by default for Sized | |
Implementing ?Trait and !Trait
?Trait
and !Trait
act as if they were marker traits implicitly defined with Trait
. They define no methods and have no associated items. They are imported wherever Trait
is imported.
However, ?Trait
can only be implemented for types for which a default impl has been defined (e.g. Send
and Sync
), and cannot be default implād itself. Traits without default impls are implemented ?Trait
by default anyway, and any such implementation would be a redundancy. In general, the ?Trait
syntax will be very uncommon.
The purpose of implementing !Trait
for T
is to make a forwards compatible guarantee that T
can not and will not implement Trait
. This makes the negative reasoning explicit and no longer a hazard for backwards compatibility.
Bounding by !Trait
Bounding by !Trait
under this RFC is different from in the prior negative bounds RFC; this bound is only met if the trait explicitly implements !Trait
. As mentioned prior, this avoids the hazards that implicit negative reasoning introduces.
The primary purpose of bounding by !Trait
is that allows two traits to be declared mutually exclusive.
An argument could be made in a later RFC for a sugar in which implementing a trait bound by !Trait
implicitly implements !Trait
as well. This RFC does not propose to introduce that sugar.
Clarification of default impl rules
If a default impl of Trait
exists, these rules are used to determine the relation between T
and Trait
:
- If
Trait
,?Trait
, or!Trait
is implemented forT
itself, that impl defines its relation. - If one of its members impls a trait which is different from the default impl, the relation is
T: ?Trait
. - Otherwise, it implements whatever relation the default impl defined.
(As it happens this is true for the implicit impl ?Trait for ..
of trait with no default impl.)
Drawbacks
This adds rules to Rustās coherence system. Adding rules necessarily makes the language less accessible, and is always a drawback. There is a trade off at play here between easiness and expressiveness.
It may be difficult to grok the difference between !Trait
and ?Trait
. The reason for this difference only becomes clear with an understanding of the factors at play in the coherence system.
The impl !Trait for T
syntax overlaps with the syntax of existing negative impls for types with default impls. For each existing negative impl, it will need to be determined whether that type should impl !Trait
or ?Trait
(that is, whether or not the non-implementation is intended to be a reliable guarantee). That said, it is a backwards compatible change that will not cause any regressions (existing code will only become a stronger statement about the relation).
Alternatives
Sibling proposal: !Trait by default
There is an alternative scheme which has some advantages and disadvantages compared to the one proposed in the main RFC. I am not completely certain that the main proposal is a better proposal than this one.
Under this alternative, types would impl !Trait
by default, and a default impl of ?Trait
would be necessary to make that not the case. The table for such a proposal would look like this:
| | ?Trait | Trait | !Trait |
|---------------|--------------------|----------------------|-------------------|
| Specific impl | impl ?Trait for T | impl Trait for T | by default |
| | | | impl !Trait for T |
|---------------|--------------------|----------------------|-------------------|
| Default impl | impl ?Trait for .. | impl Trait for .. | by default |
|---------------|--------------------|----------------------|-------------------|
| Bounds | by default | where T: Trait | where T: !Trait |
| | except: ?Sized | by default for Sized | |
The trade off here is between these two desirable, incompabilte features:
- A: Adding new impls is backwards compatible.
-
B: implās for
T: Trait
do not overlap with types that donāt implTrait
.
The primary proposal prefers A whereas the alternative proposal prefers B. Both, however, make the other possible for specific traits in which the alternative makes more sense. I have preferred the primary proposal because it seems to me that problems presented by not having A will only appear over time, whereas problems presented by not having B will appear rapidly.
That is, you will only discover it is important to you to be able to add new impls of traits for stable types after you have already stabilized your types, whereas you will discover that you want your trait to be exclusive from types that donāt explicitly implement that trait as you are building out its implementation. Thus, enabling A by default avoids the sadness of discovering too late that you want to be able to add impls later.
Other alternatives
Allowing negative bounds without distinguishing between !Trait
and ?Trait
remains an alternative, though it presents a backwards compatibility hazard.
And as always, doing nothing is an alternative.
Not an alternative: specialization
As a side-note, this RFC does not overlap with proposals for trait specialization. Mutual exclusion is not a situation that can be modeled with specialization, and does not model specialization. Put in terms of sets, types which implement mutually exclusive traits are disjoint sets, whereas specialization allows a distinct implementation for a subset of the types which implement the trait.
Conceptually, they are connected only in that they expand what is allowed by Rustās coherence system compared to the present, but they have separate use cases.
Unresolved questions
How mutual exclusion will be applied to the types and traits provided by Rustās std library and other related libraries is not covered by this RFC, and should be the subject of a separate RFC.