RFC: Exhaustive traits. Traits that enable cross trait casting between trait objects.#3885
RFC: Exhaustive traits. Traits that enable cross trait casting between trait objects.#3885izagawd wants to merge 14 commits intorust-lang:masterfrom
Conversation
…haustive' trait is static edited comment to be less vague
|
We could avoid Rule 1 by building the vtable lookup table externally in As |
|
I made another RFC: #3888. If that lands, rather than having every trait object being forced to store metadata for casting, there could be a trait Castable: 'static {
const self EXHAUSTABLE_IMPLEMENTATIONS: &'static [(TypeId, TraitVTable)];
}which could have a blanket implementation of: impl<T: 'static> Castable for T {
const self EXHAUSTABLE_IMPLEMENTATIONS: &'static [(TypeId, TraitVTable)] = std::intrinsics::exhausive_implementations::<T>();
}Which would make the metadata for casting opt in Though this will require a good chunk of changes to the It would be ideal to make this Of course we can do compiler magic, and make the |
|
To me "exhaustive" suggests that the trait can only be implemented within the defining crate (like sealed). I'm not really sure what a better name is though. |
|
@tmccombs I would use a better name than |
|
Appears intertrait already provides a much cleaner solution, by using the linkme. At present linkme needs linker support, but one could bypass the linker, using only |
|
@burdges i tried I also tried out
|
|
Interesting thanks. Appears 4 years since their last version, and the owning blockchain has no updates since May 2023, so bitrot. It nevertheless shows that rule 1 maybe excessively restrictive. Anyways I definitely agree that generic statics or similar ala #3888 sound useful. |
I agree, but I cannot find a way around this due to how separate compilation works. I would gladly remove that rule if it were soundly possible |
|
Is it possible for sidecasting from trait A to trait B to make a subtrait of both, downcast to that subtrait, then upcast to B? I don't think it's that farfetched but I only have vague surface level of the compiler so I don't really know. I just figured I should make sure it's considered. |
|
Yes, but only for types defined downstream of both traits, by Rule 1. If you have a &dyn A coming from a &u64 under the hood, then you cannot cast it to a &dyn B. And Box was not directly addressed.
There are degrees of separate compilation here: We've large projects using codegen-units=1 for various reasons, by far the worst failure of separate compilation, but really quite standard. By comparison, intertrait only needs some append only structure that's compiled late. Imho intertrait does not go far enough, and should reprocess those append only structures into efficently indexable families. Around this, I wonder if intertrait failed because it depends upon lto or another linker flag? Anyways, all these crates for casting dyn Trait track the vtables for the different pointer types seperately. As they check the underlying type, their reason for doing would be unstability of the Pointer layout, aka soundness bitrot. I'd suggest two preliminary changes: First, Rust should commit to all smart pointer types using the same vtables. This is probably already the case. Second, Rust should exposes unsafe methods that manipulate the vtable pointer, optionally safe ones too. This would prevent soundness bitrot in external crates that cast dyn Traits, and allow wider adoption. |
|
So the current proposal does not involve linker tricks? The non-linker-trick version would essentially allow doing error provides by adding subtraits of |
|
@rustbot label +I-lang-nominated I am nominating this for lang team discussion. Me and the libs team are trying to close our story for We would prefer not to have 2 ways of doing |
This comment was marked as resolved.
This comment was marked as resolved.
|
@traviscross @joshtriplett and I had a brief discussion about this RFC. We all seemed excited about this work, but all felt that it likely should start as a lang experiment rather than as an RFC. On that note, we would need to find a lang champion for this. Probably the easiest/best path forward on that is to open a channel in #t-lang on zulip. |
|
@izagawd Regarding on of the
I was having a short conversation with @programmerjake on zulip about just kinda an idea I had initially unrelated to this RFC. I have quoted part of the conversion below. Anyways he brought up this RFC, and it seems like it could also be useful. Essentially the short of it is some special bound I am just assuming it was called trait MyExhaustive: crate {
// items...
}
|
|
Imho, rust should seriously consider "mild" linker tricks, not only here, but reducing repeated codegen, etc. At least to me, Rule 1 seems way too heavy handed when seemingly simple alternatives exist. Also, rust should remain open & friendly to linker tricks, so if one adds features like this then one should think about how they interact with better alternatives. At the same time, if this gives a way to resolve common situations before linker phase, then okay whatever. |
|
@Keith-Cancel the blanket implementation, but only for within the defining crate sounds interesting, but how about this scenario? impl<T: MyTrait + crate, U> CrateExtrenalTrait<U> for T {
// impl...
}this could lead to concrete types implementing an infinite amount of as for using: trait MyExhaustive: crate {
// items...
}this implies that if I make a trait say: trait Foo: MyExhaustive{}that |
@izagawd Like your already allowed to do that with an instance of a crate local type and an external trait with a generic parameter: Rust Playground
Like whether the crate local restriction is expressed as a bound or your attribute, if Conceptually to me there are two parts to this RFC, basically a crate local trait bound, and the cross crate casting. |
I am aware that we are allowed to do that, but how |
@izagawd This can be worked around instead of treating the trait type ids as a flat id in your exhaustive map, if they composed of two parts. Basically one half identifies the trait, the other half the generic, and instead of just plain equality we change equality to: Might be easier to explain with just some code: Rust Play Ground --EDIT- |
that still doesn't work for: impl<T: MyTrait + crate, U> SomeTrait<U> for T {
// impl...
}because |
@programmerjake I don't see what you mean though this non-flat id does not work for That aside, I was busy earlier today after posting that, but was still thinking about it. I realized a couple things first my eq logic would need changed these are not bit-field ids so that trick does not quite work. The BIGGER issue I noticed was deeper levels of generics: impl<T: MyTrait + crate, U> SomeTrait<(u32, U)> for T {}
impl<T: MyTrait + crate, U> SomeTrait<(U, u32)> for T {}
impl<T: MyTrait + crate, U, V> SomeTrait<(U, V)> for T {}This can get arbitrarily wide or deep. It also made wonder how we would even generate IDs deterministically in the CONCRETE case. Since even if every type was explicitly spelled the type expression of that generic could arbitrarily long or deep. So would need like IDs of unbounded length or a hard limit on length and nesting. --Edit-- |
the problem is that to support trait casting there has to be a finite known set of possible |
@programmerjake What's the V-Table for this then leaving casting aside? impl <T> MyTrait<T> for MyType { ... }It's just same V-Table regardless of of T correct? This would just be doing the same but for every type in the crate. Naively it could just thought of copying pasting basically the above. impl<T: crate, U> MyTrait<U> for T { ... }Now back to casting let just look at this case. In the exhaustive table all we care is that we match the trait, the generic part could be treated as a wild card/any ect... since we have an impl that applies to all T. Hence why I split it into two parts a trait_id and generic_id. If the generic_id is zero in the table it's basically a wild card otherwise it's a concrete type. impl <T> MyTrait<T> for MyType { ... }The bigger issue which I noted above is how to handle arbitrarily wide or nested types which I noted above. |
|
@Keith-Cancel I replied here: #3885 (comment) |
There was a problem hiding this comment.
actually, we should move this conversation to a separate thread to avoid clogging up the main PR comments, so I'm creating one here. was originally here #3885 (comment)
@programmerjake What's the V-Table for this then leaving casting aside?
impl <T> MyTrait<T> for MyType { ... }It's just same V-Table regardless of of T correct?
nope, it generates a different vtable for each T that's used anywhere for unsizing to dyn MyTrait<T> (ignoring some lifetimes here).
e.g. in https://rust.godbolt.org/z/WsrxzKf5W
each of f1, f2, and f3 generate a different vtable:
pub trait MyTrait<T> {
fn f(&self) -> &'static str;
}
pub struct MyType;
impl<T> MyTrait<T> for MyType {
fn f(&self) -> &'static str {
std::any::type_name::<dyn MyTrait<T>>()
}
}
#[unsafe(no_mangle)]
pub fn f1(v: &MyType) -> &dyn MyTrait<[(); 1]> {
v
}
#[unsafe(no_mangle)]
pub fn f2(v: &MyType) -> &dyn MyTrait<[(); 2]> {
v
}
#[unsafe(no_mangle)]
pub fn f3(v: &MyType) -> &dyn MyTrait<[(); 3]> {
v
}There was a problem hiding this comment.
@programmerjake Hmm, Okay I see what you are saying. That does make things trickier for the casting and having it work without as many restrictions as in the RFC. Before I looked at this I kinda thinking about some kinda TraitID(id=X, generic=PATTERN) thing to handle nesting/wide types, but was not really coming up with any good ways to flatten a pattern to an integer, but looking at that Assembly kinda nixes that.
So the first 2 probably could be handled.
// Finite
impl<T: crate> MyTrait T { ... }
impl<T: crate, U: crate> MyTrait2<U> { ... }
// Not Finite
impl<T: crate, U> MyTrait2<U> { ... } Although I wonder if there is a way around that since to do the cross_trait_cast a concrete type has to specified in someway for the generic part of the trait. We could use that concrete type to materialize/actualize such v-tables during monomorphization.
trait MyTrait2<T>: crate { }
fn cast(a: &dyn Other) {
// We know that MyTrait2 has a finite number of implementers because
// it's crate local only bound.
// So it would seem possible to ensure during compilation, since we specify
// MyTrait2 with a concrete type to check for MyTrait2 impls with a generic.
// Then any such generic implementation like:
// `impl<T> MyTrait2<T> Foo { ... } ` gets monomorphized with `[u32; 2]`
// and as part of monomorphization that vtable type gets added to the Foo's
// exhaustive table.
let b: &dyn MyTrait2<[u32; 2]> = cross_trait_cast_ref(a).unwrap();
}
fn cast_gen<T>(a: &dyn Other) {
// Even with a generic function like this `T` will have to
// be a concrete type at some point to monomorphize this function.
// At which point we can check `MyTrait2` implementers
let b: &dyn MyTrait2<T> = cross_trait_cast_ref(a).unwrap();
}Although, I am not sure that would work well with how the compiler currently compiles crate by crate. There also might be something else I am over-looking that could make doing that hard.
if that's the case it might be sensible have an attribute like the RFC's #[exhaustive] that further restricts how a trait can be implemented, and something like crate bound since it seems generally useful for more than just the cross trait cast feature.
There was a problem hiding this comment.
if that's the case it might be sensible have an attribute like the RFC's
#[exhaustive]that further restricts how a trait can be implemented, and something likecratebound since it seems generally useful for more than just the cross trait cast feature.
sounds good to me! if cross-trait casting can be made to efficiently work with generics like you proposed, that can always be added as a future extension, though I think it should still have some opt-in syntax on the trait that's more than just trait Trait: crate since it has a significant code-size cost from all the vtables especially on embedded systems where you only have a few kB of memory.
There was a problem hiding this comment.
I think it should still have some opt-in syntax on the trait that's more than just
trait Trait: cratesince it has a significant code-size cost from
True those exhaustive tables could/may add a fair bit of static data to a binary, so even if it's possible to create them based off bounds alone, it might not best to create them automatically in all cases of a crate bound unless requested, and just have those pointers in the vtable be an Option/Nullable or point to same single empty stub unless specified.
An attribute for opting into that seems fine since a lot the of other std attributes are for memory and memory layout purposes.
There was a problem hiding this comment.
@izagawd what's your thoughts on something like this:
#[cross_cast]
trait MyTrait: crate { ... } // again just assuming `crate` for now.I like it because it separates the two main concepts #[exhaustive] as proposed does. Allowing one to be used independently or both in combination.
This RFC proposes #[exhaustive] traits to enable sound cross-trait casting for trait objects.
For any concrete type T, the set of #[exhaustive] traits it implements is finite and deterministic, allowing runtime checks like “if this dyn A also implements dyn B, cast and use it.”
The design adds a per-type exhaustive trait→vtable map and enforces four rules (type-crate ownership of implementation, trait arguments determined by Self, object safe, and 'static only) to keep the mapping coherent under separate compilation.
Use cases include capability-based game entities (e.g., Damageable, Walkable traits) and GUI widgets (e.g., Clickable, Scrollable),
avoiding manual registry/macro approaches such as bevy_reflect.
This enables patterns such as: "if dyn Character is dyn Flyable, then character.fly()"
Rendered