Opting out of GAT/Associated-Type dyn-usability

While updating my dyn Trait tour to note that RPITIT stabilized, I ran across another change that had flown under my radar:[1] as of Rust 1.72, you can opt associated types and GATs out of dyn-usability. This is a mini-tutorial/exploration about that.[2]

Opting out with Self: Sized

If you're very aware of dyn Trait, you're probably aware that the mechanism to opt out of a method being dyn-dispatchable is to put a Self: Sized bound on the method.

trait WishThisTraitWasDynSafe {
    fn foo(&self);
    // Methods with type generics are not `dyn`-safe
    fn bar<T>(&self, _: T);
}
trait ThisTraitIsDynSafe {
    fn foo(&self);
    // The `Self: Sized` bound makes it unusable by `dyn _`
    // (and any other `Sized` implementor) and thus this
    // trait as a whole is `dyn`-safe (but this method is
    // not `dyn`-dispatchable; you can't call it from `dyn _`).
    fn bar<T>(&self, _: T) where Self: Sized;
}

GATs also make a trait non-dyn-safe...

trait AddedGatAndLostDynSafe {
    type Gat<T>; // :-(
    fn foo(&self);
    fn bar<T>(&self, _: T) where Self: Sized;
}

...but with the new feature, you can make the trait dyn-safe again by opting GATs out of dyn-usability in the same manner:

trait OptOutOfDynUsability {
    type Gat<T> where Self: Sized;
    fn foo(&self);
    fn bar<T>(&self, _: T) where Self: Sized;
}
fn _dyn_safe_again_and_do_not_need_to_specify_gat_equality() {
    // No `Trait<Gat = ..>` required!
    let _: &dyn OptOutOfDynUsability = &();
}

This does mean that any methods utilizing the GAT can't be dyn-dispatchable, though.[3]

trait OptOutWithMethod {
    type Gat<T> where Self: Sized;
    fn foo(&self);
    fn bar<T>(&self, _: T) where Self: Sized;
    fn quz<T>(&self, _: T) -> Self::Gat<T>
    where
        // This is required due to the bound on the GAT
        Self: Sized,
    ;
}

It works for non-generic associated types too

When GATs were stabilized in 1.65, we got the ability to add where clauses to non-generic associated types instead of just bounds on the type directly.[4] And that applies to this dyn-usability opt-out too. This means you can have a dyn-safe trait with an associated type, and not have to mention the associated type in the dyn Trait!

trait AssocOptOut {
    // We can opt `Foo` out of `dyn`-usability
    type Foo where Self: Sized;
    // But we have to opt out any methods that use it
    // from `dyn`-dispatchability
    fn foo(&self) -> Self::Foo where Self: Sized;
}

impl AssocOptOut for i32 {
    type Foo = ();
    fn foo(&self) -> Self::Foo {}
}

impl AssocOptOut for u64 {
    type Foo = f32;
    fn foo(&self) -> Self::Foo { 0.0 }
}

Which also means you can erase base types that define different associated types to the same dyn Trait type.

fn make_use_of_assoc_opt_out() {
    // No need for `dyn AssocOptOut<Foo = ()>`!
    let mut a: &dyn AssocOptOut = &0_i32;

    // No need for associated type equality between base types!
    a = &0_u64;

    // This fails because the type is not defined
    // (`dyn AssocOptOut` is not `Sized`)
    // let _: <dyn AssocOptOut as AssocOptOut>::Foo = todo!();
}

And the main limitation again is that you lose dyn-dispatchability on any methods that use the associated type.

You can still specify the associated type if you want

You can still specify a non-dyn-usable associated type in dyn Trait if you want. However, just like things worked before this feature, this results in different dyn Trait<_ = ...> types that differ from each other when the specified associated types differ.

pub fn but_i_want_a_specific_foo_grumpy_face() {
    let mut a: &dyn AssocOptOut<Foo = ()> = &0_i32;

    // This fails because the associate types don't match
    // (`u64` can't coerce to a `dyn AssocOptOut<Foo = ()>`)
    a = &0_u64;

    // You can't just ditch the associated type equality either
    let b: &dyn AssocOptOut = a;
}

This does result in a warning:

warning: unnecessary associated type bound for not object safe associated type
  --> src/lib.rs:96:33
   |
96 |     let mut a: &dyn AssocOptOut<Foo = ()> = &0_i32;
   |                                 ^^^^^^^^ help: remove this bound
   |
   = note: this associated type has a `where Self: Sized` bound. Thus, while the associated type can be specified, it cannot be used in any way, because trait objects are not `Sized`.
   = note: `#[warn(unused_associated_type_bounds)]` on by default

However, this warning isn't completely correct. This "type" can't be used, because it's not actually defined:[5]

<dyn AssocOptOut<Foo = ()> as AssocOptOut>::Foo

But the type in the equality bound can certainly be used!

impl<T: Default> AssocOptOut for Box<dyn AssocOptOut<Foo = T>> {
    type Foo = T;
    fn foo(&self) -> Self::Foo {
        T::default()
    }
}

When I initially found this, it seemed like an oversight: usually Rust language development takes a "let's be conservative" approach.[6] But now I realize that this had to be a warning in order to not be a breaking change: There was a window[7] where you could add where Self: Sized to associated types but were still required to mention them in dyn Trait.

So now I think the utility just wasn't anticipated. Well... potential utility I should say. I admit I haven't thought up any amazing examples of utilizing this ability. But then, I only stumbled across this today :slight_smile:.

An any rate, I feel this demonstrates that specifying the associated type is conceivably useful. Fortunately, you can disable the warning. I think it should be reworded at a minimum.

Specifying GAT equality

The analogous syntax for GATs gives us "partial GAT equality" in dyn Trait:

pub trait LifetimeGat {
    type Gat<'a> where Self: Sized;
}

impl LifetimeGat for String {
    type Gat<'a> = &'a str;
}

fn partial_specification<'a>() {
    let s = String::new();
    let _: &dyn LifetimeGat<Gat<'a> = &'a str> = &s;
    let _: &dyn LifetimeGat<Gat<'static> = &'static str> = &s;
}

In fact, although this syntax is still not supported:

let _: &dyn LifetimeGat<for<'b> Gat<'b> = &'b str> = &s;

The new feature also gives us higher-ranked GAT equality in dyn Trait, if we just move the binder:

fn hr_gat_equality() {
    let s = String::new();
    let _: &dyn for<'b> LifetimeGat<Gat<'b> = &'b str> = &s;
}

However, only if the GAT is non-dyn-usable. As with the non-generic associated type example, I'm putting this in the "conceivably useful but I haven't thought up an awesome use case yet" pile.

That's all I've got for now

Hope it was at least interesting! I'm still mulling over the ability to specify non-dyn-usable associated types and GATs. If you think up any neat use cases, please let me know below.

Here's a playground with most the sample code in case someone wants to play around with it.


  1. They were in the release notes but not the announcement. Maybe I'm biased but I think it deserves more attention! ↩︎

  2. also added to my tour, but I thought it was notable enough to make a post here too ↩︎

  3. Maybe this limitation is why it wasn't considered a big deal? ↩︎

  4. See "what is stabilized" here. ↩︎

  5. or well-formed, if you prefer ↩︎

  6. which I agree with ↩︎

  7. 1.65..1.72 ↩︎

21 Likes

Thank you for this very interesting post :pray:

Still quite contrived, but I'll just leave this here so I can find it later (if nothing else).

As an aside, it feels like a lot of these features that aren’t dyn-safe could still be useful for other unsized types, like [T]. Has there been any discussion about splitting the Sized marker into its two constituent parts:

  1. Can we manipulate values directly, or do we need pointer indirection?
  2. Do we have to stuff this information into a vtable somehow?

?

1 Like

I :100: agree. Using Self: Sized to mean NonDyn is an overly restrictive hack.[1]

The closest recent activity I can think of that is related are extern-focused RFCs to create some MetaSized or DynSized as a Sized supertrait or such. These two I think:[2]

There's very strong resistance against another implicit bound like Sized, so one of the big questions is how to handle that. Well, being a supertrait of Sized is part of the answer, but I mean things like

// Do we really want to require this for "all types"?
impl<T: ?Sized + ?MetaSized + ?NotDyn>
// Probably not, so I guess `?Sized` removes supertraits too

// But then you need this various places...
impl<T: ?Sized + MetaSized>
impl<T: ?Sized + NotDyn>

// ...unless explicitly requiring a supertrait implies ?Sized
impl<T: MetaSized /* not necessarily Sized */ >
impl<T: NotDyn /* not necessarily Sized */ >

// In which case maybe we just want a supertrait of all types
// So instead of `T: ?Sized` you can write
impl<T: AnyType>

// Counter-argument: now I have to memorize every Sized supertrait

I've also implicitly assumed a strict heirarchy between Sized and AnyType.[3] Things are even messier if you want to allow disjoint subsets.


  1. There's also some hack around self receivers being special cased ↩︎

  2. but I don't have time to reread them right now to be certain ↩︎

  3. Sized: MetaSized: NotDyn: AnyType or something ↩︎

1 Like

This is great, thank you!

I'm guessing there's a reason, but it seems odd to be opt-out to avoid compiler error rather than explicitly declared (like mut).

trait HasObjectSafeMethods {
    dyn fn safe(&self);
    fn not_safe<T>(&self, _: T);
}

Depends on your point of view, I guess. I don't know what the original reasoning was off-hand.

In the Rust we got, having your trait be object safe is a leaky property, like the variance or auto-traits of a struct. It's a breaking change to suddenly become non-object safe, e.g. by adding a defaulted but generic method without the opt-out. So from that point of view, it would be better if you had to opt in to things being dyn-usable or dyn-dispatchable, or even object safety in general.

But if you had to opt into it, we'd probably have a lot less traits that were useful as trait objects, because libraries would probably only opt into it if they built the trait with type erasure in mind, and might be more hesitant to opt in when asked to do so.


Whatever the mechanism is, you have to be able to handle this situation:

// or however "any implementor at all" is specified in this world
//     vvvvvvvvvvvvvvv
fn foo<AnyImpl: ?Sized + HasObjectSafeMethods>(obj: &AnyImpl) {
    // This can't be callable for `AnyImpl = dyn HasObjectSafeMethods`.
    // So assuming `dyn HasObjectSafeMethods` implements the trait,
    // this code must not compile
    obj.not_safe(());
}

// Thus you need *some* way to declare it's callable on a generic...
// (Today we use `Sized` and `Sized` is implicit unless you opt out)
fn bar<MostImpls: NotDyn + HasObjectsSafeMethods>(obj: &MostImpls) {
    // 👍
    obj.not_safe(());
}    

So the lack of a dyn marker in that world would presumably still be sugar for where Self: NotDyn or something.

And presumably this would be uncallable:[1]

trait Trait {
    dyn fn method(&self) where Self: NotDyn;
}

This also highlights that opt-in is a poor fit with the Sized hacks we have today, because then opt-in would be required to call the methods for any non-Sized type (str, [T], ...).


  1. which is fine in my book; I'm ok with trivially unsatisfiable bounds, especially if they warn ↩︎

Thanks for this writeup, it's excellent! TIL about where Self: Sized bounds to opt out parts of a trait from dyn-usability.

As maintainer of cargo-semver-checks, I've been trying to work out the SemVer implications of adding/removing where Self: Sized bounds on trait associated types and methods. This is something I'd like us to be able to lint for, and I could use some help!

So far, I've figured out that that adding where Self: Sized to a method is generally a major breaking change (see link below for details).

I haven't figured out a way to trigger breakage by removing where Self: Sized from a trait method. I also haven't figured out a way to trigger breakage by adding or removing the bound from a trait associated type — at least not without also needing to change a trait method's bounds and cause the breakage that way.

If you can construct a piece of code that breaks as a result of those bound changes, I'd love it if you could post it here: Breakage related to `where Self: Sized` bounds on trait associated functions (methods) · Issue #786 · obi1kenobi/cargo-semver-checks · GitHub

Thanks again!

The low-hanging fruit is removing where Self: Sized in such a way that causes a trait to go from object safe to not-object safe.

// If there's a `dyn Trait1` anywhere, removing the bound will break it
pub trait Trait1 {
    fn foo<T: Clone>(&self) where Self: Sized;
}

where Self: Sized on the trait itself is the same as Trait: Sized, i.e. it's a supertrait bound that will be an implied bound everywhere T: Trait is used. T: Sized is usually an implicit bound anyway, but removing that could break odd code that uses T: Trait + ?Sized but relies on Trait implying Sized anyway.

Playground with both examples.


I'll try to remember to add these and any other I think of to the issue later (or feel free to just copy them yourself).

2 Likes

Thank you, I appreciate it!

cargo-semver-checks already can catch object-safety changes with existing lints, so I'm especially interested in other kinds of breakage. For example, the example I already added to the linked issue keeps the trait object-safe but still causes a breaking change:

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();
    //    |           ^^^^^^
}

This breakage is interesting because it wouldn't be caught by any existing lint, including the existing object-safety lints.