Pre-RFC: repr(C) for traits

Presently there is no well-defined way of representing a dynamic dispatch object with a stable layout (Aside from defining the layout yourself). This means that rust is not a suitable language for writing runtime modular apis, requiring the use of a language like C++ for automatic martialling of these apis, or a manual COM-Like structure for the VTable. This can be inefficient for development. I propose an overload of the #[repr(C)] attrbute, to apply to traits, which will allow for the use of trait objects (and all kinds of well-behaved pointers to such trait objects) across module bounderies, and potentially (with some careful specification) accross languages.

Applying repr(C) to a trait has a few effects. First, all trait methods that do not have an explicit abi specification will be declared as though with extern"C". Second, pointers to all trait objects which contains at most one non-marker trait requirement, and that trait is a repr(C) trait, will be stablely guaranteed to conform to the layout as defined by the TraitObject structure (which is presently only unstable, but could be stablized under this with a narrowed guarantee). A marker trait is defined as any trait which neither requires nor provides any functions and has no supertraits which are not marker traits (Send, Sync, and Unpin are marker traits, but Any is not, nor is Copy (it requires Clone)).

Additionally, the vtable layout for such pointers is defined here as well, and is layout compatible with the following exposition-only repr(C) structure:

#[repr(C)]
struct VTable{
    size: usize,
    align: usize,
    destroy: Option<unsafe extern"C" fn (*c_void)->()>,
    dealloc: Option<unsafe extern"C" fn (*c_void,usize)->()>,
    vfns...: Option<unsafe? extern"C" fn(*c_void,<fn_signature>...)-><return-type>>
};

Where vfns is the virtual function list for the non-marker trait, in declaration order.

The destroy and dealloc members perform the operations of drop_in_place and a deallocation respectively, but restore the lost type information. The dealloc operation is to help support similar constructs in other languages to things like Box<T> which is a smart pointer, that is layed out exactly like a raw pointer (With aliasing requirements). Because of this, I recommend that the creation of a Box<dyn Trait> fill the dealloc item in the VTable, and the drop operation of a Box<dyn Trait> call the dealloc item (Both when Trait is a repr(C) trait). This allows for Boxes of repr(C) traits to soundly cross dso bounderies, which may have a different System or "Global" allocator (because of linking), as well as potentially allowing similarily specified constructs in different languages which are defined in the same manner here.

Questions I'd like to resolve before submitting as an RFC:

  • Should this overload the repr(C) attribute, or should a new attribute be created for this purpose. C does not have a concept of virtual dispatch, and the closest is the Itanium C++ ABI, which would require more effort to make applicable to rust traits. In current practice, repr(C) is more of "Guarantee a stable, well specified layout forever" then a "do exactly what C does", when you consider the UCG rules reguarding rust's enums.
  • Cross language dispatch is definately a goal here, and I am working on a different language which provides the exact same feature set. However, whether or not it should be a primary point of this RFC is unresolved presently.

Unresolved questions which can be resolved now or during the RFC process:

  • Reguarding marker traits, presently in this trait objects which only have marker trait requirements would be subject to the layout rules. Would this be something that would be fine to specify, or should that limit be lifted?
  • Would it be reasonable to specify that Drop is a marker trait, despite requiring the drop item (as drop is covered by the destroy item in the VTable)
  • Should all of the functions in the trait be automatically extern"C", or should it just be the VTable entries.
  • How should composed traits work w/ respect to repr(C) traits.
  • Should it be a warning or error to put repr(C) on a trait that is not object-safe?
9 Likes

Just a few notes. This is not a full review, just surface level observations and suggestions.

  • Initially I didn't like the use of #[repr(C)], because (platform) C doesn't have an analogue to point to and say "that's the repr". Even for #[repr(C)] enum, it's the "obvious" C repr of struct { tag, union { payloads } }. But the more I think about it, the more this layout for the vtable (or something close) seems like the obvious C choice (for a split vtable).
  • The #[repr(C)] is applying to the vtable and not really applying to the trait itself. This could be confusing, but on a bit of thought, seems the only reasonable interpretation.
  • I strongly don't like implicitly changing the default from extern "Rust" fn to extern "C" fn. Probably a #[repr(C)] trait should just require explicit extern on all of its methods.
    • vtable entries should be the same as the actual methods. As you've alluded to, #[repr(C)] is useful even in pure Rust, so it shouldn't preclude extern "Rust" vfns.
  • At least for a first pass, it should be an error to use #[repr(C)] trait for a non-object-safe trait. (As it has no meaning.)
  • On destroy/dealloc:
    • destroy should just be called drop_in_place.
    • dealloc (if it even should exist) probably shouldn't take layout information, as it already has it available in the vtable.
    • I don't think dealloc should exist. You can have dyn Trait as part of a larger object or &mut dyn Trait, and in these situations dealloc doesn't make any sense. Whoever gives you a pointer needs to give you a way to free that pointer already.
      • So instead of vptr->dealloc(ptr) you have box_dealloc_dyn(ptr, vptr).
  • All of the virtual method pointers need to be unsafe as they take a pointer argument and (necessarily) assume it's valid.
  • Why are your virtual member fn pointers Options? I would think they should be required to exist.
  • The required drop_in_place vptr is probably fine to implicitly be extern "C" because a) it has to be, to be callable from non-this-Rust and b) single-pointer-argument-void-return extern "C" fn calling convention is almost certainly optimal (for indirect calls) and the same ABI across most if not all function calling conventions (including Rust's) (disclaimer: did not check). (It just passes the pointer argument in the first conventional argument register.)
  • Drop should never be used as a trait bound, because it does not do what you might expect. (All things are droppable!) (If/When a Drop bound is retconned to mean "statically known to have nontrivial drop", then it might be useful and worth discussing.)
  • An initial design might just enumerate the "well known" marker traits of Send, Sync as allowed supertraits and leave it at that.
8 Likes

I was also thinking that we should probably allow the other ABI strings somehow. Especially the "C unwind" that might be added soon. Either we should force you to write it out on all the methods, or the attribute should include a string argument, or we support both options.

Unfortunately, I believe this also kinda breaks the argument for #[repr(C)] being reasonably intuitive here. Once non-"C" ABIs are involved, #[repr(C)] is just misleading, and #[repr(C, abi="Q")] just looks vaguely self-contradictory. Perhaps it's time to stop using "the C language" as if it was synonymous with "stable layout you can safely use in FFI" and bikeshed some alternatives. Maybe #[stable_vtable_layout(abi="C unwind")]?

I mean, nothing prevents C from using non-platform-C calling conventions and ABIs. It's just a lot more difficult to do so (as you effectively have to map it to the C ABI or fall back onto vendor intrinsics and inline asm).

The #[repr(C)] is "shallow" in that it only impacts the layout of the vtable and not the contents.

The parallel would be #[repr(C)] enum, which... chose the opposite direction, in that the enum payload is also #[repr(C)]. But in that case, there's also both the factors that the repr can't be specified for just the payload anyway, and you can get back #[repr(Rust)] by just using a newtype variant.

#[repr(C)] trait has the opposite situation: the extern "ABI" of the functions has a place to be specified, and there isn't a way of turning a "C" default back into whatever you want.

I think requiring an explicit extern "ABI" on all the fn declarations is a decent cop-out; we can always add a default back in later if it turns out there is a meaningful default here. I just think having a disconnect between the externness of the method as declared and in the vtable is a huge potential footgun.

Hopefully this works the way I think it will.

| CAD97 Regular
June 19 |

  • | - |

Just a few notes. This is not a full review, just surface level observations and suggestions.

  • Initially I didn't like the use of #[repr(C)], because (platform) C doesn't have an analogue to point to and say "that's the repr". Even for #[repr(C)] enum, it's the "obvious" C repr of struct { tag, union { payloads } }. But the more I think about it, the more this layout for the vtable (or something close) seems like the obvious C choice (for a split vtable).
  • The #[repr(C)] is applying to the vtable and not really applying to the trait itself. This could be confusing, but on a bit of thought, seems the only reasonable interpretation.
  • I strongly don't like implicitly changing the default from extern "Rust" fn to extern "C" fn. Probably a #[repr(C)] trait should just require explicit extern on all of its methods.
    • vtable entries should be the same as the actual methods. As you've alluded to, #[repr(C)] is useful even in pure Rust, so it shouldn't preclude extern "Rust" vfns.

extern"Rust" isn't useful, with this though, as it's neither stable nor specified, so it would be unsound to call across dynamic modules. Perhaps lifting the requirement there and only imposing it on the VTable entries is prundant.

  • At least for a first pass, it should be an error to use #[repr(C)] trait for a non-object-safe trait. (As it has no meaning.)

Seems reasonable. In a similar proposal for a different language I believe I specified that the layout(C) annotation (the equivalent) shall not be applied to a trait which does allow dynamic object types (equivalent of trait object types).

  • On destroy/dealloc:
    • destroy should just be called drop_in_place.

The name is related to the above similar proposal as well as a library I'm writing that does similar things.

    • dealloc (if it even should exist) probably shouldn't take layout information, as it already has it available in the vtable.
    • I don't think dealloc should exist. You can have dyn Trait as part of a larger object or &mut dyn Trait, and in these situations dealloc doesn't make any sense. Whoever gives you a pointer needs to give you a way to free that pointer already.
      • So instead of vptr->dealloc(ptr) you have box_dealloc_dyn(ptr, vptr).

It has less meaning in rust, and more meaning in cross language ffi. It means that if a different language provided a UniquePtr to an api that becomes a Box in rust, that box can be dropped soundly, because it knows how to drop the pointer. Taking the size is somewhat a historic choice.

  • All of the virtual method pointers need to be unsafe as they take a pointer argument and (necessarily) assume it's valid.

The struct is for exposition only. Such a structure would not exist in any sense of the word, so the declaration of safe vs. unsafe is entirely superfluous.

  • Why are your virtual member fn pointers Options? I would think they should be required to exist.

Reasonable Point, I didn't consider that. I was just typing it and I had the options in my brain because destroy/dealloc

  • The required drop_in_place vptr is probably fine to implicitly be extern "C" because a) it has to be, to be callable from non-this-Rust and b) single-pointer-argument-void-return extern "C" fn calling convention is almost certainly optimal (for indirect calls) and the same ABI across most if not all function calling conventions (including Rust's) (disclaimer: did not check). (It just passes the pointer argument in the first conventional argument register.)

All virtual functions are intended to be callable from not-this-Rust, as well as even not-Rust

  • Drop should never be used as a trait bound, because it does not do what you might expect. (All things are droppable!) (If/When a Drop bound is retconned to mean "statically known to have nontrivial drop", then it might be useful and worth discussing.)
  • An initial design might just enumerate the "well known" marker traits of Send, Sync as allowed supertraits and leave it at that.

Leaving it open to other marker traits would be useful, and consistent with the proposal I mentioned above. It allows for custom Marker traits which have api significance, hense why I gave a broader definition of what it is.

But it's still useful within a statically linked system using only one version of rustc. The [Raw]Waker system for futures is using a manually created vtable of extern "Rust" function pointers, after all! If this system had existed when that API were designed, perhaps it might've been able to use it (but probably not, due to future extensibility concerns).

Rust should use Rust names, not names from an undisclosed other language :upside_down_face:

Then that probably still shouldn't be a Box in Rust, but its own type.

Also, wouldn't that mean that Box<dyn Trait> and cxx::UniquePtr<dyn Trait> would have to use different vtables, even for the same type? Shouldn't the vtable only depend on the pointee type and not the pointer type('s expected deallocation routine)?

This also continues to ignore (or at least brush aside) non-owning pointer types.

But it's still good to be accurate.

It would exist in memory, wouldn't it? That counts as existing.

Even if the example of the vtable layout is nonnormative, it should be as correct as possible, because people will copy and use it as their definition of the structure in memory.

At least drop_in_place has no reason to be nullable imho. Just make trivial drops use a no-op function. (How do current vtables handle drop_in_place? Do !needs_drop::<T>() have a null entry or a no-op entry?)

I don't disagree here, I just think limiting it to the well-known marker traits initially can simplify the design.

Tbh, the restriction should be identical to the one for additional marker trait bounds on a trait object. (E.g. dyn Clone + Any isn't valid but dyn Any + Send + Sync is.)

The idea would be that a Box would be usable as rust's equivalent to a uniquely owned, allocated smart pointer to a dynamic object type. It is already specified that a Box has the same layout as a NonNull, and other languages that both presently exist and will exist which have a similar type with the same layout guarantees and could send or receive this type to an api implemented in a foreign language.

That's fair. I could probably add Note - The drop_in_place operation is equivalent to a destroy operation in similar languages - End Note.

The nullability of the destructor operation is from my similar apis and abis. Also, on many architectures, a null check is faster than a function call and immediate return. Hense why I have always made trivial operations (not limited to destructor, also copy and move constructors and assignment operators in C++) be reduced to a null pointer, it's simply more efficient in the trivial case. It would be perfectly valid to insert a no-op entry, but it would also be valid to have a null entry.

Possibly, It seems like that may be solvable by the #[stable_vtable] version. The purpose of my initial proposal was cross-module and cross-language ffi in a semi-safe manner.

I've seen at least one proposal around for a well-defined vtable format, along these lines. (It wasn't a proposal for matching some existing format, just a proposal for standardizing Rust's.) This seems like it would align with that, where that was the equivalent of a repr(Rust) vtable, and this would be a repr(C) vtable.

I think there'd be value in this, and I'd be happy to see this happen, as long as the result can actually work for people using things like COM, C++, or UEFI.

How would the interact with future extensions to trait objects, such as upcasting or multi-trait objects?

The initial support just wouldn't.

As specified above, #[repr(C)] traits are restricted to a single non-marker trait, so those future extensions would specify how (and if) they interact with the specified vtable layout along with the unstable Rust one.

We're already moving towards allowing allocator-parametric Box<Pointee, Allocator>. Rather than stick dealloc in the vtable, you'd use Box<dyn Trait, CxxDelete>.

I have a hard time imagining a concrete use case that legitimately has to handle unique pointers that need to deallocate with separate routines in a dynamic manner.

CxxDelete would be hard to specify, because C++ allows type-specific deleters. std::default_delete simply invokes delete ptr;. If ptr is of a class type which has an overloaded operator delete(void*) that won't necessarily be aware of it (virtual destructors also make that fun). Generally speaking, there is precident for including the required deallocation function in the vtable. I'm actually basing the vtable layout in this proposal off of https://github.com/ModItAll/Framework/blob/726957eda4f02cc6c9d2cb3033d438cc2c1115cf/include/Framework.h#L7..L17, which is part of a framework designed to marshell objects between independent modules in well-behaved ways.

The main point is I can get a Box<dyn Trait> from a callback, which means that the callback wants me to manage the object. I shouldn't have to care that it comes in from C++, or Laser (the language I'm working on that supports this), or someone manually crafting a Virtual Dispatch Object (which can be done, carefully). That's really my goal with cross-language compatibility. I don't care where it comes from, as long as it is correct.

2 Likes

The fact remains, though, that changing Box<_> to use an allocator other than Global is a breaking change. It is specified that

fn dealloc_box<T: ?Sized>(it: Box<T>) {
    let layout = Layout::for_value(&*it);
    let ptr = Box::into_raw(it);
    Global.dealloc(ptr, layout);
}

is a correct way to deallocate a box. You cannot change that to require inspecting a trait object if it's a trait object.

If a callback gives you a rust std::boxed::Box<T>, it is saying that it's allocated using rust std::alloc::Global. There's no way around that, it's a specified and stable guarantee that the standard library gives us.;


And again, how do you propose to handle there being more than one way to dealloc a dyn Trait? There's only one vtable for each (implementer, trait) pair.

I can write

struct Container {
    data: SomeData,
    trailing: dyn SomeTrait,
}

How would Box<Container> be handled? (Does Container get its own vtable for SomeTrait, even though it doesn't implement the trait? What if it does?)

Hmm... The problem with a special type, even a generalized one, is that Box is very magic. It's also currently impossible to be generic over trait objects (and only trait objects), which means that each unique pointer type would have to either be unsound, or specific to a single trait (neither of which are desired). I know there are discussions about a FunctionPointer trait over on the zulip, perhaps a similar TraitObject could be used here (though it would have to be generic over specifically repr(C) traits, likely requring magic).

Presently it is not. The layout specification is only for pointers to trait objects, it says nothing about pointers to structures containing trait objects. It may be possible to explore the use on repr(C) structs containing covered trait object types. In which case, there is a reason why dealloc takes a usize.

I am not much of a fan of #[repr(C)] for enums with payloads either. Having the discriminant as a separate field that precedes the payloads is just customary, it’s not in any way inherent in the language. (If you consider e.g. tagged pointers to be a form of discriminated unions, it follows it’s not even a universal custom.)

I would have preferred something like #[repr(simple_stable_ffi_v0)] instead. But I’m not sure how feasible it would be to rename now.

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