Hello, I've been reading the 2nd version of the Rust book. However, I'm stuck at chapter 18 because I have a hard time wrapping my head around concept of the trait objects and how they relate to Sized trait and the object safety.
I've been search forums, however I haven't found satisfying answers to my questions, so I hope someone can answer them for me here. So here they are.
Traits, like [T] where T is some Type are considered unsized types and it's not possible to have a direct instance of that type so they need to be "hidden" behind reference, Box or some other pointer type. So:
tells that all types that implement Foo must be of sized type, so when I write something like:
let x: &Foo;
It declares a reference (pointer) to type that is sized, right? So, why is the logic other way around? That by making the trait sized we are preventing the creation of pointers to that type and therefore we are preventing dynamic polymorphism.
Object safety that is derived from trait methods is something I (sort of) understand. Basically we can't have concrete types in the function signature because we don't know at compile time witch concrete type Self will be when the method gets called. Am I right on this?
How come something like this is legal? This wont create a compile time error:
trait Foo{};
struct Bar(Foo);
However, something like this is not (as expected) ?
[T] is called a slice - a dynamically sized type (DST). The T can be anything - it doesn’t have to be a trait. A [u8] is byte slice, for example.
Your list of unsized vs sized types above is correct.
Usually you write trait Foo: Sized if you’re relying the size of any implementing type to be known at compile time (the very definition of being Sized). So a method taking self can only be called if the type is Sized. Likewise, a method taking Self as a parameter has the same restriction - you must know its size statically (compile time) to pass it on the stack.
These days you don’t see Sized as a requirement on the trait as much. Instead, methods that need a Sized requirement are bound to require Sized but the rest of the trait isn’t. For example:
Foo is object safe but need_sized won’t be available to call on a trait object of that type.
There are several restrictions for a trait to be object safe - not using Self is but one of them. When you create a trait object, the underlying type is erased. So a method taking Self is invalid because by definition we don’t know that type anymore - all we know is it implements the trait but not its exact type (which is what Self is).
Bar itself becomes an unsized type (a DST). You don’t see these often. They’re valid to define but harder to actually use.
The latter is trying to put an unsized lvalue (named binding) on the stack - can’t be done because compiler doesn’t know how much space it occupies. So if you tried to do the same thing with Bar you’d get a compiler error for the same reason.
I can get into more details on any of these but cutting here as I’m on mobile . Feel free to ask more questions.
Hey, thanks for taking the time to answer my questions.
Yeah, sized vs unsized in terms of pointer wrapping is simple enough. When it comes to unisized traits, the easiest way to (sort of) understand it is in terms of method calls on trait objects:
trait Foo : Sized { // all types that implement foo will be sized, so
// self has to be sized
fn show(&self);
}
struct Bar{}
impl Foo for Bar { // Bar is now considered sized, at least when calling show()
fn show(&show) {
println!("42");
}
}
fn main() {
let x: &Foo = &Bar{};
x.show(); // Bam! Problem is here, right?
}
x.show() is actually (*x).show(), due to auto-dereferencing.
So, we are trying to grab the concrete type behind the x reference,
a trait Foo, so self in the show(&self) method call is unsized. That is in
conflict with the Foo trait signature that states that self have to be
sized in the show(&self) method call. Is this the right reasoning?
I still find it a bit confusing that it's possible to have struct fields
of the trait type. The reason this is possible is that the field is actually
a reference type or the struct binding is an implicit reference, right?
One last question, one of most confusing things in rust is the entire
"you can grab the reference thing". So, we can do this without any
warnings:
struct Foo {
x: i32;
}
impl SomeStruct {
fn foo(&self) -> i32 {
// self is the same thing as &self,
// so is &&self, so is &&&&&&self,...
(&&&&&&&&&self).x
}
}
I understand that this has to be done to make the borrowing system work,
but is there some kind of compiler switch that will print a warning when
we are doing this kind of thing?
The Sized requirement in Foo is unnecessary (based on the rest of the code) - it’s an arbitrary restriction you put that will prevent implementing Foo for unsized types and also prevents creating Foo trait objects.
The show(&self) method is, otherwise, perfectly fine to invoke even on Foo impls for unsized types because you have the unsized type behind a reference (ie &self).
Keep in mind how trait objects are represented - via fat pointers. A reference to a Sized type is what’s known as a thin pointer - it’s just an address where the data (object) resides. A fat pointer, in contrast, is actually two addresses: one to the data and another to the vtable for the implementation of that trait for the concrete type behind the trait object. But the key part here is that the real type is erased - you don’t know what the underlying type is when all you have is a trait object to it. So if you have a trait object, you can’t call a method that returns Self because you don’t know what Self is at that point. Likewise, you cannot call a method that takes Self as an argument for the same reason. But you can make those methods not callable on a trait object by requiring Self: Sized on just those methods, as mentioned in my previous reply. Any other object safe methods can still be called.
There’s another use of fat pointers - to reference slices. In that case, there’s one pointer to the base address of the slice and the other part of the pointer is the length of the slice.
The field must be the last one in the struct in this case. The entire struct becomes an unsized type. That means you can only work with it behind a fat pointer. This is essentially the same thing as a fat pointer to a slice. The base address pointer is the same but the length part is the static portion of the struct (ie other fields it has) + the dynamic size of the last unsized field.
I don’t think that’s needed for the borrow system to work. They’re just references to references to ... They’re distinct types in the type system. Maybe clippy warns about unneeded chains like that, I’ve not tried it. You don’t usually see anything beyond two levels of references in normal code.
Thanks for answering all that. So to recap, methods from sized traits and methods that violate object safety can't be dynamically dispatched. Trait object (trait types behind fat or regular pointer are unsized) so that's where the conflict with the sized trait comes from.
Is it fair to say if we make some trait sized, we will effectively prevent dynamic polymorphism on all it's methods?
And thanks for clearing that about struct fields as traits. It makes sense now. As for the references, the entire auto-deref thing did confuse me a bit. I'm too used to C-style thinking about the refs with *ptrs and **ptrs.
Yeah, sort of. A trait requiring Self: Sized cannot be turned into a trait object, irrespective of whether its methods would allow for it otherwise. If a trait doesn't require Self: Sized then it can be turned into a trait object provided that its methods meet the criteria for object safety. Any method that does not meet the criteria can be selectively excluded by putting a where Self: Sized bound on just that method, allowing trait object formation. In this case, those methods with a where Self: Sized bound will not be available via the trait object.
Trait objects are always behind a fat pointer. The fat pointer itself is sized, but the trait (on its own) is an unsized type. It's like str and [u8] being unsized as types - you need to put them behind a fat pointer (&str or &[u8]) to give them size.
Yes, it's fair to say that. Since you won't be able to form trait objects against that trait, you won't get dynamic dispatch.
Yeah, the compiler automatically inserts as many derefs as necessary when the period operator is used. That's why your (&&&&&&&self).x example gets the value of x. This comes into play in more pedestrian cases: &self.foo_field is as-if you wrote (*self).foo_field - the compiler puts the derefs there for you automatically (hence the name auto deref).