obi1kenobi / cargo-semver-checks

Scan your Rust crate for semver violations.
Apache License 2.0
1.03k stars 61 forks source link

Breakage related to `where Self: Sized` bounds on trait associated types and methods #786

Open obi1kenobi opened 1 month ago

obi1kenobi commented 1 month ago

Rust appears to allow trait items (so far, types and methods, in the future possibly also constants) to be excluded from trait objects by applying Self: Sized bounds like so:

pub trait Example {
    type Item where Self: Sized;

    fn method(&self) -> Self::Item where Self: Sized;
}

As far as I can tell, it is not required to repeat where Self: Sized on the items of trait implementations, so the breakage has to come from attempts to call or use the defined functionality, rather than being immediately caused by a definition lacking a where Self: Sized bound.

SemVer hazards:

Adding where Self: Sized to a trait method is breaking

pub trait Example {
    // If you uncomment the bound below:
    fn method(&mut self) -> Option<i64> // where Self: Sized;
}

fn demo(value: &mut dyn Example) {
    // then you get the following error on the next line:
    value.method();
    // error: the `method` method cannot be invoked on a trait object
    //   --> src/lib.rs:20:11
    //    |
    // 2  |     fn method(&mut self) -> Option<i64> where Self: Sized;
    //    |                                                     ----- this has a `Sized` requirement
    // ...
    // 20 |     value.method();
    //    |           ^^^^^^
}

playground

Caveat: if the method is made non-callable from outside its own crate (i.e. the trait is partially-sealed as described in this post), then this is not a major breaking change since no external crate could have called the method and been broken by its signature change.

This is currently not expressible in a Trustfall query, so it cannot be linted without further work on the query schema.

obi1kenobi commented 1 month ago

cc @ehuss this might be of interest — it appears to be a new set of SemVer hazards that might bear mentioning in the cargo reference's SemVer section.

ehuss commented 1 month ago

Thanks for the ping! Just to clarify, is this just a variant of trait-object-safety? That section explicitly says "adding an item", but could be extended to be "adding, or changing an existing item to be non-object-safe"?

Also, one of the todo items in https://github.com/rust-lang/cargo/issues/8736 is to clarify what changes are allowed to a trait item. The old RFC just said no "non-trivial" changes, but didn't define what "trivial" meant. I think adding or removing trait bounds would by default be non-trivial, though I can see where there could be some subtle exceptions.

obi1kenobi commented 1 month ago

Just to clarify, is this just a variant of trait-object-safety?

I don't believe so. Even with the Self: Sized bound on trait Example's method, the trait itself remains object safe.

In other words, the following code compiles just fine: playground

pub trait Example {
    fn method(&mut self) -> Option<i64> where Self: Sized;
}

fn demo(value: &mut dyn Example) {}

Any other trait items that do not require Self: Sized would continue to be usable, so this could be extended to a realistic, non-trivial example as well.

Also, one of the todo items in https://github.com/rust-lang/cargo/issues/8736 is to clarify what changes are allowed to a trait item. The old RFC just said no "non-trivial" changes, but didn't define what "trivial" meant. I think adding or removing trait bounds would by default be non-trivial, though I can see where there could be some subtle exceptions.

The edge cases are annoyingly difficult to pin down. I've tried and given up several times — it's a couple-week-long rabbit hole minimum, in my estimation.

For example, partially-sealed traits complicate everything. Methods that cannot be called from outside the trait's crate, or methods that cannot be overridden from outside the trait's crate, both make it quite complex to determine when changes to bounds could possibly affect a downstream crate.

This is one of the things I'd love to sit down and properly work out if I can find some funding to make it my full-time job for a few months. If the Rust project or Foundation might be willing to sponsor that work, I'd be happy to do it and even work out how to make the rules machine-checkable in cargo-semver-checks as well.

QuineDot commented 1 month ago

As requested. These examples don't change object safety but are still breaking changes.

Example 1: Removing Self: Sized from an associated type is a breaking change.

// Now that you can omit `where Self: Sized` associated types from `dyn Trait`,
// it is a breaking change to remove `Self: Sized` from associated types of
// object-safe traits
pub trait Trait {
    type Assoc where Self: Sized;
}

// If the `Self: Sized` bound on `Assoc` is removed, this must change to be
// `dyn Trait<Assoc = SomeConcreteType>`
pub fn example(_: &dyn Trait) {}

Example 2: removing Self: Sized from self: Self receiver methods is a breaking change.

pub trait Trait {
    fn consume(self) where Self: Sized;
    fn other(&self) {}
}

// Check that it's object safe
pub fn example(_: &dyn Trait) {}

// How do you implement `Trait` for non-sized types?
impl Trait for str {
    // We can't just omit the method (yet)...
    // https://github.com/rust-lang/rfcs/issues/2829

    // This errors because you can't pass unsized types by value...
    // fn consume(self) {}

    // This errors becuase trivial bounds isn't implemented yet...
    // https://github.com/rust-lang/rust/issues/48214
    // fn consume(self) where Self: Sized {}

    // But this workaround for trivial bounds works
    // (you still can't call the method)
    fn consume(self) where for<'a> Self: Sized {}
}

// But if you remove `where Self: Sized` from the trait, you will break the
// implementation because it is "no longer as general" as the trait.
//
// N.b. the reference says that a "receiver type of `Self` (i.e. `self`)
// implies [`where Self: Sized`]", but as this example demonstrates, that is
// not true.  There is some other special carve-out that leaves traits that
// have methods with `self` receivers object safe.
//
// https://doc.rust-lang.org/nightly/reference/items/traits.html#object-safety
//
// I.e. also comment out `impl Trait for str` and the `&dyn Trait` will still
// compile.

(As far as I know, there is currently no way to implement a trait with a self: Self receiver that lacks a where Self: Sized bound for unsized types. Perhaps suggesting the where Self: Sized bound should be a lint...)

obi1kenobi commented 1 month ago

Amazing, thank you! A few follow-up questions to make sure I understand all the nuances and can implement the lints accurately:

QuineDot commented 1 month ago