Explanation on fn(self: Box<Self>) for trait objects

I was wondering if I could get some reasoning as to the intricacies of using fn(self: Box<Self>) in a trait for use as a trait object.

I recently learned about the self: Box<Self> notation from here and here, which allows consuming self when using boxed trait objects.

In the example below (also on the playground), the trait function fn works(self: Box<Self>); will allow compilation, whilst the same function signature (fails) but implemented inside the trait itself will fail compilation. I would like to know why that is the case?

trait Test {
    fn foo(self);
    
    fn works(self: Box<Self>);
    
    fn fails(self: Box<Self>)
    where
        Self: Sized
    {
        self.foo()    
    }
}

struct Concrete;

impl Test for Concrete {
    fn foo(self) { () }
    fn works(self: Box<Self>) { () }
}

fn main() {
    let concrete: Box<dyn Test> = Box::new(Concrete);
    concrete.works();
    concrete.fails(); // compilation error
}
2 Likes

The problem is with Self: Sized bound. Since dyn Trait is always unsized, this annotation effectively removes the method from any trait object.

4 Likes

Is there an elegant solve such that an implementor of Test only has to implement foo?

See: GitHub - archshift/dynstack: A stack for rust trait objects that minimizes allocations

It allows dynamic allocation of objects on the stack.

I'm sure someone can clarify much better than I the circumstances under which this is unadvisable.

Not yet, it requires two-step partial implementations of a trait, which in turn requires the specialization experimental feature:

#![feature(specialization)]

trait Test {
    fn foo (self)
    ;
    
    fn works (self: Box<Self>)
    ;
}

default impl<T> Test for T {
    fn works (self: Box<Self>)
    {
        (*self) // move `mem::size_of::<Self>()` bytes from the heap into a local parameter
            .foo()
    }
}

struct Foo;

impl Test for Foo {
    fn foo (self)
    {
        println!("<Foo as Test>::foo()");
    }
}

The reason a Box<Self>-receiver-based method is a valid method for a trait object is that the dyn Trait type is !Sized, meaning that there can be values of different sizes all having the same type dyn Trait, so unlike most Sized types, Rust cannot know statically / at compile time how big a value obj of type dyn Trait may be

  • As a more illustrative example, Rust knows that a value x of type [u8; 4] is always 4 bytes long, a value y of type [u8; N] is always N-bytes long, but a value z of type [u8] could have any kind of length.

And it so happens that CPU architectures require1 static / compile-time knowledge of the size of a value for it to be used as a function parameter (or return value, or even a function local). That's why to see a x: T, be it as a function parameter, a variable or a return value, then necessarily T : Sized.
So having self: dyn Trait is just not possible yet.

There is, however, a way to magically ensure a ?Sized (i.e., a type that may or may not be Sized) type T becomes Sized: (pointer) indirection.

By having a PtrTo<T> pointer, with some additional (runtime) "metadata" attached when T : !Sized (such pair makes what is called a fat pointer), such as the length of a slice, or a pointer to the static table of function pointers to the methods of a trait (a vtable), then the size is indeed fixed: size of the pointer + size of the metadata.

Example

diagram

  • Playground

  • As you can see, even if x and y point to slices of different lengths, they are both equal-sized "fat" pointers: an address + a length each.

  • A similar thing happens with dyn Trait, which may be a type-erased Foo : Trait of size 3, or a type-erased Bar : Trait of size 4096. In the current implementation of trait objects, the "fat" pointer's metadata is a pointer to a struct containing the size, alignment, destructor, and the (object-safe) methods of Trait.

So, we have seen that a a method using self: Self as a receiver is not usable when Self : ?Sized ("Self may not have a size known at compile-time"), so that's a situation where a Self : Sized bound may be added. But since dyn Trait : !Sized, adding such a bound automatically makes that method unusable for a trait object. Hence the idea of using indirection.

With &self (i.e., self: &Self) and &mut self (i.e., self: &mut Self) the borrowing contains the necessary indirection. But for ownership we need an owning pointer, such as Box<Self>, Arc<Self>, etc. preferably with exclusive ownership since that's what our original self: Self expressed; this leads to using mainly Box as the pointer type to wrap Self receivers with.


In your case, you did get all this right with the .works() method, except that you did not have the obvious generic derivation of a Box<Self>-receiver method from a Self-receiver method. So you've tried writing the default implementation within the trait definition, except that:

  • to be able to (*self)... (move out of the Box), you need Self : Sized, so you needed a Self : Sized bound for the generic implementation;

  • the method signature within the API of the trait must be trait-object compatible, so you cannot have a Self : Sized bound there.

This contradiction shows that a trait's definition cannot always be used to define some restricted default implementations (in this case restricted to Self : Sized), and that's what default impl will be used for.

In the meantime you will need to write that "obvious" implementation for each impl of the trait, although you can use macros to do that for you:

struct Foo;

impl_Test! {
    impl Test for Foo {
        fn foo (self)
        {
            println!("<Foo as Test>::foo()");
        }
    }
}

1 There is an RFC for unsized locals, but such implementations rely on runtime stack-allocations which in practice come with a bunch of drawbacks that are rarely worth it...

10 Likes

I'll stress this for any future visitor, you must dereference self otherwise the compiler will recursively loop.

Thanks @Yandros, very comprehensive answer.

4 Likes

This topic was automatically closed 90 days after the last reply. New replies are no longer allowed.