Problems with "dyn* Trait"

(NOT A CONTRIBUTION)

I read @nikomatsakis's post about "dyn* Trait" and I wanted to share some problems with this idea. I feel like we discussed something very similar several years ago, so I was surprised to see this post without trying to address these problems, which I remember were brought up then.

  • &dyn Trait , for example, would become dyn* Trait + ‘_

This is the big problem with this proposal. Given a trait object &dyn Trait or &mut dyn Trait there are two operant lifetimes, which have different implications for the moves and borrows that are allowed for this type. In particular, if you have &'a mut dyn Trait + 'b, you are covariant in 'a and invariant in 'b. It is also important because you may need to constrain other lifetimes in your signature to be the same as either 'a or 'b, but obviously not both (those other lifetimes could be invariant also, for example: fn(&'b mut Type<'a>, &'c dyn Trait + 'a).

This means that given the signature of a trait object which is a reference, you need to be able to specify the reference lifetime separately from the lifetime of the trait object, and that reference needs to specify whether its mutable or not. This muddling with "trait views" (which seems like just more complexity to patch over the weaknesses of this proposal) doesn't solve the problem.

Let's say the final syntax does somehow have all of these knobs, but instead of composing reference types with an unsized trait object type (what we have now) you've got 3 (ref, mut ref, by value) new types with syntax for separately specifying these lifetimes (the by value one doesn't need it). How do they implement deref? What is the target type? And if they don't implement Deref, how do you Pin them, or use any other abstraction built on top of Deref?

As far as I can tell they would have to deref targeting the old dyn Trait types. So you aren't even getting rid of that type. You'll have to have both systems forever. You can't just shift over at an edition boundary.

The new types would have these two advantages, as far as I can tell:

  • You can pack objects that are less than a word in size into the pointer type.
  • You can pass self by-value as an argument and return type, thereby making more traits dyn safe.

I'm not very hyped about the first point, but I think the second of these is a very intriguing goal. I want to note that people already do create "trait objects" that implement dyn unsafe traits and so forth - they do so using Any downcasting or a special clone method they make implementers provide or the erased_serde bridge trait trick. People also do all kinds of representational tricks with their custom trait objects (anyhow using a thin pointer for example), and certainly can do small object optimisations like this if they really want to.

The problem that I see is that these things have to be done bespoke each time you want to do them, and that requires advanced understanding of unsafe code and the trait system and possibly exploitation of currently underspecified behavior. So rather than bake some aspects of them into a new type, I would ask: why aren't the tricks people do to create custom trait objects libraries that anyone can pull down and compose with their own trait? What abstractions would be needed to make that possible? I'm not sure there's an answer, but to me this seems like a more compelling avenue to find parsimonious additions to the language to support these use cases.

8 Likes

What does “NOT A CONTRIBUTION” mean?

Also, perhaps consider including a link to the post you're discussing here?

3 Likes

(NOT A CONTRIBUTION)

Required by my employer. Here is Niko's blog post: Baby Steps

Not a problem, but an alternative direction we can take here.

We can make dyn Trait a sized type:

  • allow various owning pointers [of certain layout] to be coerced to an owned dyn Trait type
  • this notion can be extended to allow arbitrary unsized types, so that [T] type is sized, an actual storage is determined at creation site.

This overlaps the unsized values RFC (meaning of let a: dyn Trait = ... would differ) I think of introducing a kind of explicit alloca* function in std::mem solving the following:

  • alloca is not always avaliable - tie this to an std on different platforms
  • it would be forbidden to use in async contexts with clear error messages (yet it can reside in plain function called by an async... not a problem**)
  • justifying current special case of unsized locals made from Box es.

This way, the only places we deal with ?Sized types are data structure decalarations and storage for these.

* fn alloca<T: ?Sized>(val: T) -> Unique<T> - allocate a memory and place a value on the current stack frame.
A value given to the function is passed by pointer, the function allocates a memory using an alloca, writes the value into allocated value, and returns a value by pointer.
This function is, in fact, compiler provided, and is #[inline(always)].
This goes into a separate #[feature]

** if a futures need to store only the state crossing the await (yield for generators) point and given there's not way data of functions that a future calls inbetween yields is captured by the future, no memory allocated by alloca would ever be in scope of future\generator and thus there is no way it could be needed to store it in state.

Edit: alloca side note.

How does this differ from the unsized rvalues RFC? It seems pretty much the same to me. And how does it solve that RFC's problems, like allocas in loops and returning unsized values?

My proposal is to make alloca an explicit operation performed by a special function.

This doesn't support returning unsized values with alloca at all. Instead, some implicit coercions of Box and likes to a value carrying an owning pointer + vtable with drop fn. ptr, as it is described in dyn* niko's blog post.

The two motivation points from unsized locals RFC were:

  1. to support passing unsized values to functions;
  2. to support allocating objects on stack in performance sensitive cases;

To support the 1 case we do explicit alloca for dynamic data, take a pointer to this data, and construct a dyn* object (which I really hope could be generalized to support also [T], etc):

let data: Box<dyn Trait> = ...;
let alloca = std::mem::alloca(*data); // `*data` is a `dyn*` trait object
//here, alloca reside on the stack, yet it has been created from trait object, and it also has a `dyn* Trait` type

In fact, I belive it'd be better if we changed the meaning of dyn to mean what is dyn* means.

Second motivation point is handled by std::mem::alloca (which I'm also proposing) function alone.

TODO: figure out this as self type

I had a similar thought, with a very rough sketch of one possible such abstraction: https://www.reddit.com/r/rust/comments/tqwn4b/dyn_can_we_make_dyn_sized/i2lp687/

That is, one way to look at this is that dyn* is folding the pointer type (which may be "no pointer") back behind the dynamic dispatch, to get a sort of pointer type polymorphism. This is what makes more traits dyn* safe- it lets you pick a strategy for passing and returning Self and associated types at construction time.

So rather than hanging this functionality off of dyn, we might introduce a new pointer-like type to compose with dyn (but also usable for other purposes- that's mainly where my Reddit post goes). This type would have to expose the intersection of the APIs of the pointer types it supports: Sized, a lifetime parameter, Deref, !Copy, drop glue, etc.

I'm not really sure either how much of this would need to go into the language vs libraries, but it feels like a useful starting point that's narrower in scope than "custom DSTs."

3 Likes

I have another concern with this proposal: "pointer-sized" is not clearly defined at type-checking time.

Is Box<T> pointer-sized? Sure. But Box<T> is actually Box<T, Global>, and so it's pointer-sized only because Global is a ZST. What with custom allocators? And even if we decide that we special-case Box, we still have to deal with generic allocators (I expect a lot of ICEs to be caused by this). And what with other types? This seems to open the door to more post-monomorphization errors, a la generic_const_exprs. I really don't like that.

4 Likes

Most of this comment is just nits.

Probably irrelevant to this topic, but this has never been true.

Box<dyn Trait> bounds are more nuanced than that.

While true from a subtype perspective, Unsize coercion can make 'b act covariantly (search for "Interaction with object coercion"). [1] This doesn't matter when the lifetimes are intertwined with others in the signature, as you note.

How would that be phased in considering these currently-non-overlapping implementations?

impl<T /* : Sized */> Trait for [T] {}
impl<T /* : Sized */> Trait for T {}
impl Trait for dyn Display {}

  1. And maybe the coercion makes the subtype distinction meaningless. ↩︎

2 Likes

Please no, the last thing Box needs is more magic.

3 Likes

Making dyn Trait a sized type renders impl Trait for dyn Trait useless, since the notion of dyn is changed.
While no easy answer, I think here we should just use specialization rules extended to say smth. like "impl Trait for dyn Trait type is even more general than impl<T> Trait for T". This way we gonna use methods from impl if we basically have no other impl in existence.
Also, involvement of an impl for dyn Trait require implicit unsizing of a concrete type (so the method resolution can make code from impl Trait dyn Trait to work), which we don't want at all, I guess.
Alternative is to make such impls do nothing, as bounds in generics type aliases currently do.

Wouldn't this mean this feature would be blocked until a sound specialization design is found?

Actually, yes. The problems arise when chosing between impl<'a> Trait for dyn Trait + 'a and impl Trait for dyn Trait (+ 'static) (is that 'static bound implied when no other one is specified?).

And after that I'm in strong favor of not searching for methods in impl Trait for dyn Trait:

  • first, it avoids conflicts from here (this topic).
  • second, it allows to auto generate impls of Trait for dyn Trait, because the latter is now actually an owning pointer to a DST value.

But such transition also require an edition.

What about associated types though?

trait Foo {
    type Bar;
}

impl<T> Foo for T {
    type Bar = ();
}

trait Baz {}
impl Foo for dyn Baz {
    type Bar = i32;
}

fn test<T>(_bar: <T as Foo>::Bar) {
    let _bar: () = _bar;
}

fn oops() {
    // <dyn Baz as Foo>::Bar == i32, but this is not what `test` expects
    test::<dyn Baz>(0i32);
}

This is unsound usage of specialization (from generic impl (first), to concrete one(second)).
Associated types shouldn't be specializable, because even if we make some kind of sanitization mechanism, this'd still be introducing too much action-at-a-distance problems in codebases.

As a hack in sake of backward compatibility, we can make impls of shape impl Trait for dyn Trait {} to not consider dyn Trait sized =). Given impls of more complex shape (such as impl Tr for (u8,dyn Tr)) are probably very rare, so a crater run will judge.

And then we can make dyn Trait a Sized type, so that:

struct S(u8,dyn Trait); //becomes Sized
//struct S(u8,[u8]); //can also become Sized, given this mechanism extended to slices.

fn f(a: dyn Trait); //no loger requires `unsized_locals` feature
fn f() -> dyn Trait; //becomes possible (but not zero-cost) to make this work, function `f` should itself allocate an object, and coerce it

Are there any ways to do dispatch on whether a type is Sized or not?

For my own edification: is dyn* Trait essentially equivalent to the canonical C++ object layout (i.e., a vtable ptr prepended to the actual object), with an upper bound on object size? Or am I misunderstanding the proposal?

dyn* Trait would still be two pointers big, (ptr, &vtable). The difference with &dyn Trait is that it owns the pointee ("&move") and that it also can inline pointer sized values with a special vtable.

(NOT A CONTRIBUTION)

I don't believe its intended that this type owns the pointee necessarily - Niko refers to &dyn Debug as being dyn* Debug + '_. Presumably it only owns the pointee if its static. This means though that you can't have the equivalent of Box<dyn Debug + 'a>. Its unclear how the distinction between a copiable and not copiable (& vs &mut) trait object reference was supposed to be made though, possibly it was that dyn* &Debug + '_ was copiable whereas dyn* Debug + '_ was not. Niko would have to say.

And this isn't even getting into things like Arc<dyn Foo>. These are just more good reasons why the composition between the pointer type and the trait object type makes a ton of sense.

1 Like

I am not familiar with the "bridge trait trick" you're referencing in erased_serde and want to check it out, but we have been working on abstractions for some of the patterns you described

The first one is a generalized implementation of the thin pointer logic from anyhow. The second one is an abstraction similar to Any for passing arbitrary types out of trait objects which lets you provide generic wrapper methods on trait objects like fn get_context<T>(&dyn Error) -> Option<T> getter we're looking to add to Error. When I poked around for a minute inside of erased_serde I saw a mention of another related typetag library, and I'm very curious to know if there's any similarities between that API and the typetags that underpin the Provider API: Add the Provider api to core::any by nrc · Pull Request #91970 · rust-lang/rust · GitHub

1 Like