Pre-RFC: Object-safe traits with associated types


#1

I’m posting this hear for preliminary feedback in case there’s some obvious reason why this is impossible that I’m not seeing.

Summary

Currently, traits that have an associated type are not object-safe. This RFC proposes to relax this rule for traits whose associated types have a trait bound that is itself object safe.

Example

trait MyTrait {
    type AssType: Bound;
}

Under the new rule, the above trait could be made into a trait object where <dyn MyTrait>::AssType == dyn Bound assuming Bound is itself object-safe.

For now, we also require that the associated type is only used un-nested as a method return type in the trait definition, and that it has exactly one non-marker trait bound. Some of these restrictions could be lifted in the future (see future extensions).

Motivation

On their blog, @withoutboats describes a plan to make traits with async methods object-safe.

Since every async fn returns a different future type, there’s only one way to dynamically dispatch an async fn: dynamically dispatch the future type! That is, the returned future from any async method called on a trait object would be Box<dyn Future>. The compiler, when generating the vtables for this trait, would generate the necessary shim as well to heap allocate the returned future.

I have two objections to this. Firstly, automatically Box-ing the result type gives Box (which is ostensibly a library type) a very special place in the language. It also implicitly allocates, something Rust has always managaged to avoid in the past.

Secondly and more importantly, this is a specific application of a more general and more generally-useful rule which could apply to associated types other than the return types of async methods.

Rust should instead pursue the more general rule. The downside of this is that it will require implementing ?Sized return types before we can allow traits with async methods to be object-safe.

Guide-level explanation

Suppose we have a trait with an associated type used as a return type.

trait MyTrait {
    type AssType: Bound;

    fn foo(&self) -> Self::AssType;
}

We can use this trait as a trait object as such:

let t: Box<dyn MyTrait> = ... ;
let a: Box<dyn Bound> = box t.foo();

Similarly, suppose we have a trait with an async method:

trait MyAsyncTrait {
    async fn bar();
}

We can call this method on a dyn MyAsyncTrait and explicitly box the result to obtain a boxed future.

let t: Box<dyn MyAsyncTrait> = ... ;
let f: Box<dyn Future<Item = ()>> = box t.bar();

Reference-level explanation

The compiler, when generating the vtable for this trait, would also generate the necessary shims to unsize the associated type return values into trait objects.

Future extensions

There are plans to eventually support trait objects of multiple traits, eg. dyn Debug + Display. Once this is supported we can remove the limitation that requires the associated type to have exactly one non-marker trait bound.

Rather than requiring the associated type to only appear un-nested in return position, we could also allow some other forms. For example:

trait MyTrait {
    type AssType: Bound;

    fn foo() -> Box<Self::AssType>;
}

This could be supported since we allow the conversion Box<T: Bound> -> Box<dyn Bound>.

More generally, there other kinds of “unsizing” that we could support but don’t currently. eg.

  • Result<T: Bound, E> -> Result<dyn Bound, E>.
  • Foo<T: Bound> -> Foo<dyn Bound> where struct Foo<T> { ..., last: T }

#2

On second thought (immediately after posting) this is impossible to apply to async methods since we require the returned future to be Sized. Otherwise we’d have to either require explicit boxing when calling an async method on a generic type, or otherwise have dyn MyAsyncTrait : !MyAsyncTrait. So I guess boxing is unavoidable :confused:

Still, we could keep the rest of this RFC and have it apply to explicit (non-async) associated types.


#3

(Because I have want to post this on every time this is brought up because the comparison is useful:)

How does this compare to erased-serde’s approach? Would this make serde::De/Serialize object-safe?


If we’re able to directly return unsized types, I don’t think there’s any reason an async fn has to return a Sized Future anymore, is there? I’m really not certain. Allowing alloca suddenly makes a lot of things possible that weren’t and I barely understand unsized as they work today.


#4

On its face this seems to require unsized return types, which we don’t have and aren’t getting any time soon. The specific example here superficially looks like it could avoid some of the hard problems of unsized return values with some to-be-specified restrictions on how the return value can be used (e.g., only fed directly to box, no storing in a local or temporary, which in particular means &t.bar() won’t be allowed). Then we wouldn’t have to put anything in the caller’s stack space and could just pass bar a “write return value to this space” pointer (like we already want to for Sized return values).

However, even that would require the caller to know how large the return type will be, which… seems more feasible here than for general unsized return values, but still requires both adding something to the vtable and special casing box <method returning erased assoc type>. Not to mention that we don’t even have reliable placement for Sized return types, and box is still unstable for the forseeable future.