Relaxing object safety rules by making trait objects generic

Edit: This turned out not to make any sense.

I read @huon's posts on object safety yesterday. It took some time to wrap my head around everything (as seems to be the case for many people), but I now agree with all the necessary restrictions given the current implementation. However, it seems to me that, with a bit of a change in the design of trait objects, we could lift one or two of the rules.

The first/main one would be the "References Self" rule. (Code excerpts from the article.) To recap, if a trait has a method that refers to Self twice, like so:

trait Foo {
    fn method(&self, other: &Self);
}

then it is not object-safe, because if we would implement it as follows

impl<'a> Foo for Foo+'a {
    fn method(&self, other: &(Foo+'a))
        (self.vtable.method)(self.data, /* what goes here? */)
    }
}

we cannot rely on other.data to have the same type as self.data. But let us try to redefine the trait object type as it is represented in std::raw:

pub struct Foo<T: Foo> {
    pub data: *mut T,
    pub vtable: *mut (),
}

Every trait object now encodes the type of the data it points to in its type. (I don't know a lot about the internals, so if the *mut T doesn't make sense here, throw in some PhantomData instead.) Therefore, the implicit implementation would look like this:

impl<'a, T: Foo> Foo for Foo<T>+'a {
    fn method(&self, other: &(Foo<T>+'a))
        (self.vtable.method)(self.data, other.data)
    }
}

We can now safely use other.data since it has the same type as self.data as per the function signature -- we shift the burden of proof on the caller. (&x as &Foo).method(&y as &Foo) would become (&x as &Foo<X>).method(&y as Foo<Y>) (where X and Y are the types of x and y, respectively), and the compiler can easily verify if X == Y.

Now we have introduced another type parameter T here. That would mean that we'd have to monomorphize the impl for every possible type that implements Foo, which is in general unbounded. However, neither the structure of the trait object nor the vtable object change: all possible types for T generate exactly the same code, so in practice, we only need the type parameter only for the function signature. Unless I'm missing something, this is therefore a possible implementation of trait objects that lifts this restriction; the proposed changes only affect the type-checker and do not touch the actual memory representation.

While the change doesn't look backwards-compatible syntax-wise, I think that in all valid cases, the type parameter can be inferred. I think the "Static method" rule can also be lifted at least partially, but that builds on this system, so I guess someone better verify that this makes sense first.

2 Likes

I'll admit I've not thought too deeply about your proposal, but I wanted to see how you feel about the fact that fn method(&self, other: &Self) where Self: Sized; allows formation of trait objects (assuming one of the other rules doesn't prevent it). Are there further substantial use cases where you want this function to work on unsized impls?

If I understand you correctly, you think that the type Foo, which used to be unsized, is now somehow sized? That is not the case, but I can see how I insinuated that given that I described a struct Foo<T: Foo> -- this was actually meant to be a substitute for std::raw::TraitObject which describes the memory layout of &Foo (and others). So I actually meant to describe struct &Foo<T: Foo> (which, of course, is not legal rust).

However, I just realized that I embarassingly forgot the reason trait objects exist at all, which is aptly named "type erasure" -- whereas my proposal is the exact opposite, including the actual type in the signature. That would however make it impossible to e. g. have a Vec<&Foo> populated by different types implementing Foo. So the whole thing is probably moot :slight_smile:

I meant that if you change your original trait definition, which is

trait Foo {
    fn method(&self, other: &Self);
}

to

trait Foo {
    fn method(&self, other: &Self) where Self: Sized;
}

then Foo is object safe. Now of course with the sole method being not object safe the trait is pretty useless, but if you had other object safe methods on there, then you'd be able to call them on the trait object.

Ah, yeah, of course that makes the trait object safe. Actually, I was not trying to solve any specific problem -- I just read that the compiler cannot verify that the two types match, and thought, "maybe we can work around that". But the workaround I came up with was to get rid of dynamic dispatch altogether. There probably doesn't exist a use case where you would need the method to work un unsized impls, so the Sized bound should be enough in practice.