Trying to better understand the nuances of the orphan rules, I came across how easy it is for trait implementations to be – potentially – breaking changes…
I’m aware of other kinds of “soft”/accepted breaking changes, like w.r.t method resolutions, but the thing discussed below seems less “soft” to me.
For context, let’s discuss quickly/roughly the general case of how trait implementations can or cannot be breaking changes.
Of course, adding blanket implementations (to an existing trait) is considered a breaking change; though in my mind, the term “blanket implementation” mostly meant those kinds of fully generic implementations that only the crate defining the trait can write. The reason why it’s considered breaking is that downstream crates can have written trait implementations that would be in conflict with the new blanket implementation, whose addition causes such downstream crates to stop compiling successfully.
On the other hand, non-blanket trait implementations (added for an existing trait and an existing type) are usually not breaking changes. This is deliberate; trait implementations are usually considered additive, and adding new stuff is not a breaking change. This also manifests in the fact that when a type doesn’t implement a trait (both the type and the trait not local to the current crate), you can usually not rely on this; the compiler will behave more like it doesn’t know whether or not the implementation exists, and not like it knows it doesn’t exist.
Example:
use core::fmt::Display;
trait Foo {}
impl<T: Display> Foo for T {}
struct MyStruct;
// non-overlapping, can rely on "MyStruct: Display" being false,
// because MyStruct is local
impl Foo for MyStruct {}
// non-overlapping, but "Vec<()>: Display" is conservatively
// considered unknown instead of false
impl Foo for Vec<()> {}
// error message says:
// = note: upstream crates may add a new impl of trait `std::fmt::Display`
// for type `std::vec::Vec<()>` in future versions
Now here’s the problem case though where the rules enforced by the compiler do, in my mind, not really help as much as I’d hoped for for preventing breakage: I would not consider something like
impl<T> AsRef<T> for MyStruct<T> { … }
a blanket implementation; this kind of implementation can be done outside of the standard library (which defines AsRef
). Still, adding this implementation is a breaking change, in some sense of the word “breaking change”: Downstream crates can write
impl AsRef<MyDownstreamStruct> for upstream::MyStruct<MyDownstreamStruct>
which is an impl
that’s in conflict with the generic implementation above.
For further illustration, consider this playground.
So with this much information for context, here’s my questions
- is this kind of issue known / is there prior discussion?
- would you consider
impl<T> AsRef<T> for MyStruct<T> { … }
a “blanket implementation”? - is adding
impl<T> AsRef<T> for MyStruct<T> { … }
a “breaking change” that requires a major semver bump? If yes, this is probably widely unknown though, right? (Last time I checked the relevant guide, it didn’t really address trait implementations at all; not even blanket impls which are breaking changes most definitely.)
Further considerations:
-
The standard library did certainly add implementations of this form in the past, occasionally. For example
From<T> for Mutex<T>
was added in 1.24, whileMutex
andFrom
exist since 1.0. (If I understand the explanation here correctly, it might’ve been impossible to turn this kind of change into breakage before 1.41 though..) -
If neither the type nor the trait are from the standard library, e.g. the trait and type are from different third-party crates, then adding such an instance will quite commonly only happen after both the trait and the type were already defined. Furthermore such implementations are often feature-gated. However, crate features should be additive, in particular they shouldn’t be breaking changes, so hiding such an implementation behind a feature seems problematic. If anyone does know a good example here, please tell me…
I just searched for more examples both in the standard library and also for anything between third-party crates; and it turns out these cases are sufficiently rare that I’m not surprised there haven’t been any problems yet. In particular, traits commonly implemented behind feature gates, such as certain popular traits from serde
or rayon
don’t have these kinds of generic trait arguments at all anyways.