Pre-RFC: Thinness as a property of the data rather than the trait

I think that the discussion happening in the topic about Extended enums and thin traits blog post should get its own discussion thread to not high-jack the attention of a blog post which also talks about several other interesting topics. I feel like this discussion may end up in a rfc (I am new to this process), or maybe not, let me know.

Thin traits constitute a useful optimization tool. A pointer to a trait in rust today (&Trait, Box, etc.) are fat pointers which means they are actually double-pointers (one to the object and a one to its vtable). In some cases, the memory overhead of storing fat pointers can be problematic.

In order to have thin pointers to a trait object, we must have the guarantee that the actual structure that implements the trait starts with a vtable pointer (Ă  la C++). This all has already been very well explained by aturon, nmatsakis and others so I won't go through that again here, but I wanted to draw some attention on two things:

  • Thin pointers are actually a property of the data (struct and pointer) and not the trait.
  • Thin pointers are motivated by optimizations focused on memory overhead/layout (so again, data and not traits).

I think that rust needs thin pointers, however I have a few issues with the current direction they are taking, namely:

  • Thinness is expressed on the trait instead of the data (struct and pointer)
  • The memory layout of a structure is implicitly modified by whether or not the structure implements a trait that is marked as thin.
  • The decision of the thinness of a pointer to a trait is made by the person who declares the trait rather than the user of the trait, even though thin pointers are an optimization that is useful to the user of the trait.
  • Somewhat less important to me, one can't have thin and fat pointer to the same trait, which is an artificial limitation of making thinness a property of the trait.
  • If I make a thin version of an existing "fat" Trait, the there is no automatic coercion between them (please correct me if I am wrong)

Here is my proposal:

Thinness is a property of the structures and pointers. I don't want to focus too much on the syntax because I don't want it to distract peoples attention, but let's say that thin pointers are written as:

thin_foo: &thin Foo

or

thin_foo: &Thin< Foo>

... or some other notation. I will use the first one in the discussion but the important idea here is not the syntax, but the fact that thinness is expressed on the pointer just like mutability.

Thinness also have to be opted into by structures that thin pointers point to, because they need to store a vtable pointer. Again, syntax can be debated as a separate matter:

struct Bar {
   virtual Foo,
   name: String,
}

or

struct Bar impl Foo { name: String }

...or whatever reads best. The idea here is that a structs that bundles a virtual pointer, declares it explicitly.

The same applies to Box< T>, Rc< T>, etc, just like the mut keyword.

Thin pointers can be coerced into fat pointers. A fat pointer is a pointer to the object and a pointer to its vtable, and luckily we always know where to find the vtable in an object pointed to by a thin pointer.

What do we gain from expressing thinness this way? in short no surprise related to memory layout and

  • I can understand the memory layout of a struct by reading its definition. I cannot overstate how important this is to me and to people who need rely on the memory layout of their data (which should be anyone who really cares about performance and have to think in term of cache lines).
  • The memory layout of a struct will not change without touching the struct definition or the definition of its members (it's easy to add impl Foo for Bar without looking into whether the trait Foo is marked thin or not. In a large project with a lot of contributors like Gecko (or servo but I can't relate as much), this kind of oversight can happen very easily and it is problematic).
  • If a struct implements a trait from a 3rd party library, the memory layout of the struct will not change without me noticing if the thinness of the third party trait changes.
  • Similarly, the memory overhead of a pointer is evident by looking at the pointer itself, without having to look for the trait's definition (which ties into being able to know the memory layout of a struct containing a pointers by looking at the struct).
  • Declaring trait (the collection of methods that something has to implement to interact with something else) is independent to how we refer to the trait. Orthogonality is nice, and even though I personally care about this less than I care about the parts about knowing memory layout, it is worth noting.
  • Thin pointers are an optimization for the user of the trait, who can be a different person than the one who defined the Trait. There is no reason to force the decision of thinness to be made by the person who made the trait.
  • I can have thin and fat pointers to the same trait.
  • I can implement a standard trait with thinness that I think is right for my use case.

Memory layout is important for performance. Rust is one of the rare languages that give you full control over memory layout. In fact, thin pointers themselves are motivated by memory layout kind of optimizations. I believe that any language feature that makes it harder to understand the layout of your data is making rust lose something important. Even in C++ you can't add a virtual pointer by accident without changing something in the class definition or the one of its parents. It is not only about memory overhead, but also knowing that a structure can be placed in shared memory and read by another process. In rust, data (struct blocks) and logic (trait and impl blocks) are nicely decoupled, and it would be good that it remains the case.

Some potential objections (and my answers):

  • Isn't having &Trait and &thin trait going to split the trait system and create incompatible pointers? Mut already has this kind of issue.
  • My answer to this question is, how much does having &Trait and &ThinVersionOfTrait splits up the trait system? They are in fact the same thing, but &thin Trait offers the possibility of to coerce a &thin Trait into a &Trait, so I believe my proposal actually splits the trait system less than its alternative.
  • This means I need to specify thinness in more places and as every programmers I am lazy and just want to type thin once in the trait definition.
  • Yes, precisely! And this kind of laziness causes unexpected regressions. My proposal forces you to know what you are doing and it's a good thing. It doesn't force you to write thin if you don't want to use thin pointers, so if you don't want to type thin all over the place, then don't, and at least you will know that you are using fat pointers. Since thin is an opt-in optimization, saving a few key strokes is a terrible argument.
  • This adds syntax and I don't want to deal with the concept of thin pointers because my program is fast enough already.
  • Since thin pointer are opt-in, you don't have to deal with them unless you need them. If you don't want to have to write &thin Trait, then always writing &Trait will work with any Trait and any incoming data because thin pointers can be coerced into fat ones. The exception is If you need to pass a thin pointer to a 3rd party library. In this case the library is imposing this constraint on you, and for a reason since it made the choice to opt into it, and what the library's choice should not change what your code does without you noticing.

What do you think? I am mostly interested in the idea that thinness is expressed as a property of the data, instead of being expressed as a property of the trait. The question of the syntax only matters if some people agree about the above.

1 Like

I would prefer thinness to be a property of the trait object, rather than the pointer type. This is better because you can then use it with any pointer type along with its impls (Rc<virtual Element>) and put it behind a type alias (type ElementT = virtual Element). [mut T is not really a type].

The disadvantage is that this is somewhat ugly (but somewhat less so with a type alias).

Isn’t this essentially the “fat objects” proposal? I’ve always thought that’s the right direction.

My proposal is just an alteration of nmatsakis's latest blog post about thin traits. I didn't know about the fat object proposal, but I like it a lot. It covers thin pointers in a way that fulfils the things I care about and goes even further by expressing the system in terms of lower level composable building blocks, while my proposal is has less ambition and just makes sure the expression of thin pointers is done "at the right place" and keeps us from having data layout implicitly changed without touching the struct definition.

So, yeah, I really like the fat object proposal, thanks for mentioning it. I don't know for sure how far it went, but it looks like it has been rejected for ergonomics purposes? It is hard to tell from the few words that I can find in the meeting minutes and some comments here and there. I strongly recommend the concerned parties to keep evaluating the fat object proposal, or something that builds on top of it or goes in the same direction (a direction that expresses thinness in the data rather than the trait at the very least for the reasons I mentioned).

First, I think this proposal is somewhat incomplete (in a way that the original “fat objects” was not). Thinness is not just a property of trait objects – when we allocate the underlying data, we also need to specify the vtable there. That is, in Rust, you don’t (in Rust) allocate trait objects directly. First you allocate a value of a known type (e.g. &Struct) and then this is coerced to a trait object (e.g., &Trait). We couldn’t permit you to coerce an &Struct to some &thin Trait, even if Struct: Trait, because the &Struct doesn’t have the vtable for Trait. So you need two forms, one that is like &thin<Trait> Struct (a Struct preceded by the vtable for a Trait) and &thin<Trait> (underlying type is unknown). The original “fat object” proposal called these &Fat<Trait,Struct> and &Fat<Trait>.

Personally, I still feel a lack of motivation. If the concern is just that the presence of a vtable in a struct is not explicit enough, I think there are other ways to address that (we can e.g. require some form of reciprocal annotation on Struct that indicates it is tied to Trait, or adopt something lke inherent traits). I’d like to see some classic OO pitfall that you think this will sidestep, for example, or some important pattern that will become much easier.

I also don’t think that fat objects are more expressive than thin traits: I think you can use coding patterns to achieve all the flexibility that fat objects offer, if it should be required (put another way, fat objects and thin traits are duals of one another). For example, I described earlier how to “repackage” some foreign trait into a thin trait, so that you can (locally) create a dense graph of objects or something. Another thing one can do with fat objects is to take a common struct and make thin objects of different traits. But you can also do this (via composition) under the thin trait proposal.

Given that the two are duals, it seems like the question boils down to ergonomics. The thin trait proposal optimizes for an OO-like point-of-view, in which structs and traits are tied together. It works quite well for that. I also think this makes it somewhat easier to teach and explain, since the pair of a “struct+thin-trait” is pretty close to an OO class. Moreover, the fact that, if you DON’T care about object layout, you can basically ignore thin-ness altogether is very appealing. That is, you don’t really have to know that #[repr(thin)] means if you are reading code, you still know what it does.

In contrast, the fat object makes it easier to mix-and-match structs and traits willy nilly (though, as I discuss above, that is also possible with thin traits). But I think, in practice, that flexibility will not be particularly useful. And it comes at a great toll in everyday ergonomics. You need type aliases for just about any use, and you can’t really read code without encountering these &thin Trait types. I guess I find that Fat<> is a nice factoring of thin objects from a theoretical point-of-view, but lacking from a practical POV.

I am reminded (for some reason) of the concept of a resource: in olden days, we didn’t have the Drop trait, but instead had resources, which were just a nominal type that introduced a destructor. Eventually we moved towards the Drop trait. I think overall this was a good move, since it makes the language feel smaller (though when you drill into the details, it is in some ways more complex). Now you only have to think about structs (and, yes, some structs have destructors). Similarly, with thin traits, you really just have structs and traits, but some traits are thin. Obviously it’s an illusion, in some sense, but I do think it’s important to keep the number of core concepts small, and to structure things so that you can easily layer the new concepts with relative ease.

Anyway, sorry if this post comes off as harsh. I really do like the fat objects idea intellectually, and I find it to be an interesting refactoring. I’m just concerned about how it will feel in practice, and I’m interested in more practical motivations and use cases.

Yes indeed, my proposal is more a call to twist the thin trait proposal, than a full fledged RFC yet. Everything that you said in the blog post applies unless amended in my proposal.

I think that a major difference between my proposal and Fat traits, is that Fat traits even separate the struct definition from the possibility of having virtual dispatch. That is, you can have a struct Gizmo { x: u32 } and if you want virtual dispatch you create a Fat< Gizmo, Trait>. In my proposal, the virtual function pointer is already part of the structure with something like: struct Gizmo virtual Trait { x: u32 }. So it is closer to your blog post than the Fat trait in that respect, except that the presence of a vtable pointer in the structure is explicitly declared in the struct's definition.

So coercion rules can be put in place because you know that Gizmos do have vtable pointers. the Fat object proposal, however, while it offers some flexibility, does not provide coercion between thin and fat pointers.

The implicitness of the presence of a vtable is indeed a big concern for me. If we have a reciprocal annotation on the struct, then things already get better. Now why would this annotation need to be reciprocal? since the important part is shoving a vtable in a struct, why annotate the trait at all? is it just in order to not have to annotate the pointers? I may be missing something important here so please let me know. I feel like if thin pointers are the niche optimization that some people say they are, then the burden of annotating pointers (Rc< virtual Gizmo>, etc) when opting into thin-ness does not seem to be an issue, and it is only a burden for people who make the decision of opting into it.

A typical example of implicit change that causes side effects that break things without people noticing in C++ is calling a virtual function from a destructor. One might think it's a different issue, but I believe that it belongs to the same higher level group of issues with languages allowing me to make changes in a certain place of the code which has a bad side effect on an other area of the code where an assumption was made. The assumption that was made was, "this function is non-virtual so I can call it in this place which is in the destructor or in something called by the destructor" and somewhere else in the code, someone added the virtual keyword in front of the function definition because to solve a different problem he decided that it was a good idea, but didn't check all of the destructors to verify that it was not going to cause problems. The initial assumption is then broken, subtle bugs happen thanks to the subtle rules of virtual dispatch in a destructor in C++, but the code is still legal and compiles fine. I find bugs like this one every now and then in Gecko. There is no memory safety issues here, just code that doesn't do what you expected.

If a struct needs to fit exactly in a cache line, for some performance reason, things that may modify the layout of your structure without you noticing will make it easy for your program to regress. That's a bug. Not a memory safety bug, but nor was the virtual function call in the destructor. If you need to put your structure in shared memory, it is important to be able to tell that the structure does not hold pointers, otherwise implicitly adding a vtable will cause actual memory safety problems.

Same thing with ffi, although I guess we can make sure the compiler errors if a struct is #[repr(C)] and gets a vtable implicitly added.

Really my proposal works exactly like what is described in the blog post, except that what affects a structure is explicitly stated in the struct. So the design patterns it can or can't allow should be the same. I am just trying to avoid surprising bugs caused by side effects.

I would be sufficiently happy with something where thin-ness is at least explicitly expressed in the struct even if it is also marked on the trait, I suppose.

I see this differently. If you don't care about thin-ness, just don't use thin pointers. If you are forced to see thin in your code, it is because you decided to opt into it (so you in fact do care), or you are reading someone else's code and you can keep ignore it, or a library forces you to pass a thin trait as an argument and you are forced to opt-into thin-ness. Granted the latter forces you to care about a bit more than you wanted to, but if caring is going to save you from the kind of side effect bugs I described, I believe it is a good thing.

Great things could emerge from this kind of flexibility. Perhaps the flexibility comes with risks (let me know), and Rust doesn't want to be the language that takes the risks and ventures into these new experiments. That's a perfectly respectable position, but to have a practical point of view, we'd need to try it (or find a language that did) first.

Not at all! I haven't perceived your post as harsh and I thank you for taking the time to explain your view in detail. What motivated me to open this thread was not the fat object proposal (even though I think it is an interesting one), but really avoiding side-effects on data layout, so I hope we can continue a more focused discussion here and open a new topic about fat objects if people want to argue about it some more.

I haven't read your entire reply yet, but I see that I skimmed your proposal too quickly -- I totally missed the part that linked the struct to a trait. Sorry, my bad!

It might simplify matters to use interface method tables instead of classical v-tables. A single pointer can simultaneously be a valid IMT for any number of traits, so it wouldn't be necessary to choose one trait at object initialization time.

I definitely agree.

If I can make a link with specialization (in particular the new partial keyword), I'm thinking about something like:

// equivalent to an abstract class implementing an interface
partial struct AbstractBar impl Foo {
    // no data here
}

// equivalent to an concrete class inheriting from an abstract class
struct Bar : AbstractBar {
    name: String,
    id: u64;
    
    // implement Foo
    fun do_something(&self) { ... }
}

And now what about using &AbstractBar instead of &thin Foo? The important thing above is not in the precise details of the syntax. But in the fact there may be no need for a thin keyword or for a new kind of reference to a trait but rather for a new kind of items you can reference and call just like a trait. AbstractBar is a struct (possibly empty except the implicit virtual table pointer) with an interface for a trait (possibly partially implemented). It behave like a trait if you want (it can be used as bound for generic and behind a reference with dynamic linkage) and like a struct if you need (you may access its internal data with statically known offset). And of course a thin pointer of type &AbstractBar can naturally be converted to a fat pointer of type &Foo and a &Bar can be converted to a &AbstractBar.

I think one can then naturally extend the similarity with abstract classes: partial structs may extend other partial structs, possibly implementing more traits ...

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