Idea: object-safe static trait methods


#1

Static trait methods, that is, those without a self argument (associated functions), are currently considered non-object safe. The reason is there is no self argument to do dynamic dispatch on.

However, it would be possible to allow those associated functions callable on trait objects, by taking &dyn Trait as the first argument, and looking up the function pointer in the vtable. For example:

trait Trait {
    fn associated_function(x: i32) -> i32;

    fn takes_raw_pointer(trait_object: &dyn Trait) {
        let y = Trait::associated_function(trait_object, 3);
        println!("y = {}", y);
    }
}

In fact, this is already sort of possible, in a hacky, unstable way, using raw-pointer receivers (enabled by the arbitrary_self_types feature).

trait Trait {
    fn associated_function(x: i32) -> i32 where Self: Sized;
    fn takes_raw_pointer(self: *const Self, x: i32) -> i32 {
        Self::associated_function(x)
    }
}

fn takes_trait_object(trait_object:  &dyn Trait) {
    let y = Trait::takes_raw_pointer(self as *const Self, 3);
    println!("y = {}", y);
}

EDIT: after posting, I realized that takes_raw_pointer could be replaced with a method taking &self, and it would also work, and not need any unstable features. I guess I wanted to call to attention the fact that you can create a null trait object and still call takes_raw_pointer on that. For example, you could do (ptr::null::<!> as *const Trait).takes_raw_pointer(), even though there exists no value of type !.

Since this only requires a valid vtable, this could be extended to let the function be called on *const dyn Trait, or the vtable itself.

Is there any interest in this?


#2

FYI I have a previous proposal, that allow t:Option<dyn Trait> = None being a way to make “bare” vtables, and allow dynamic dispatch on it with useful use cases, such as virtual constructor. See this post.


#3

How would you solve this problem?

trait Tr {
    fn f();
}

fn func<T: Tr>() {
     T::f()
}

func::<dyn Tr>()

This is what it means to be object safe: dyn Tr: Tr holds. If we don’t allow this (and I don’t know how we could), this extension doesn’t seem to make much sense.


#4

I didn’t see why dyn Tr have to impl Tr though; It may be the case, but in theory not necessary I think. After all, dyn Tr is a concrete type, only when the trait is object safe, you can say that dyn Tr: Tr.

(I am so happy that we have started transition from Tr to dyn Tr, otherwise, this is really confusing to new users though)


#5

The current system is that being “object safe” makes dyn Tr a type which implements Tr. That’s just how the system works today. Its why dyn Tr has these methods on it.

Its very useful that this holds, such as for functions that take arguments like &impl Tr + ?Sized to cover both static and dynamic dispatch in one function, or for impls like impl<T: ?Sized + Tr> Tr for &T.

Breaking this seems like it would make an already confusing part of the language even more nuanced:

  • dyn Tr would have methods that have the same name as trait methods from Tr but have a different signature.
  • Instead of object-safe and not object-safe, there is now a third class, object-safe but without the reflexive impl.

To my mind, there are more straightforward solutions:

  1. Just make it a method that takes &self if you want to be able to call it like a method.
  2. If that’s really not adequate, have both forms and bound the static method where Self: Sized. Users have to implement both, unfortunately.
  3. We don’t have sufficient specializations, but the ideal form would look like this:
trait Tr {
   fn f_static() where Self: Sized;
   fn f_dyn(&self);
}

default partial impl<T: Tr> Tr for T {
    fn f_dyn(&self) { T::f_static() }
}

#6

Ok, if breaking the assumsion that dyn Tr: Tr is not an option, another option is to desugar

func::<dyn Tr>()

to

func::<! as dyn Tr>()

and report error if this is not possible.


#7

I’ve never understood why static methods posed any difficulty for object safety in the first place. x::static_method() is essentially sugar for calling a free function, so dynamicX::static_method() can also just call that function without touching any vtables.

Is the issue that we want to support specialization of static methods? But even if that is the case, wouldn’t it still be trivial to desugar static methods to regular methods that simply never touch their self argument and then they can be vtable-invoked the same as regular methods?


#8

Note that we are talking about associated functions of a trait, not of a type. Types implementing that trait can override it. So it essentially isn’t 100% static - you don’t need an instance of that type to call it, but you still need to know the non-erased type to dispatch it. If you are calling a static method of dyn T and you don’t have any instance to dispatch with, you can’t know what function to call!


#9

I guess one thing you could do is to allow the trait author to provide an implementation for dyn T in case they think it would make sense and they want the trait to be object-safe. I don’t know though: since the dynamically dispatched methods of that trait would call their own static implementations, it sound like a C++ kind of a footgun.


#10

At first I was very confused by this comment, but I think what you mean is that a static method with a default implementation could call the default implementation; if this is what you mean I suppose you omitted the obvious: a static method with no default implementation would not be object safe.

While this would be functional, this is not the behavior people expect I think, and its not the behavior other items have. Users expect to get the items of the type that was cast into dyn Tr.

This is the proposal in the beginning of this thread.


#11

I think I see what you mean, if dyn Trait implements Trait, then intuitively you should be able to call <dyn Trait as Trait>::associated_func().

But where Self: Sized methods can’t be called on trait objects either, and yet if we put where Self: Sized on the associated function, dyn Trait still implements Trait. Maybe there’s a middle ground where, when T: ?Sized, <T as Trait>::associated_function takes an extra argument (something with a vtable, or other pointer metadata), instead of not existing at all.


#12

The problem emerges because of further abstraction:

fn function<T: Trait + ?Sized>() -> i32 {
    T::associated_function(0)
}

Now what happens when you call function::<dyn Trait>()?


#13

That function would fail to compile, because in that context, where T: ?Sized, <T as Trait>::associated_function would require an extra argument, something with metadata for T. I guess the type of <T as Trait>::associated_function would be fn (*const T, i32), or with the custom DST stuff we’ve been talking about in Pre-eRFC: Let’s fix DSTs, <T as Referent>::Meta. In that case, I’m assuming &T and *const T would have to coerce to T::Meta, so you could call Trait::associated_function() with &T as a the first argument.

But wait… now that I think about it, that would be backward incompatible and silly. Because then <[T] as Trait>::associated_function would now require an extra argument.


#14

[T] as Trait means [T]s meta data for Trait is known at compile time. I didn’t see why the compiler cannot create this “extra argument”.


#15

@earthengine what are you proposing? The problem is that in this function:

fn function<T: Trait + ?Sized>() -> i32 {
    T::associated_function()
}

T::associated_function takes an argument when T = dyn Trait and doesn’t when T = [U]


#16

If T= dyn Trait the meta data can be calculated from the vtable etc. If T=[U] the compiler need to find impl Trait for [U] to obtain the meta data. Is this not the case? Otherwise I didn’t see how can I justify the call like this.


#17

Yes, that’s exactly the point. You need to provide that vtable somehow. My proposal was that you would provide the vtable as an extra argument to the function call. But in the body of function above, you don’t have anything with a vtable, all you have is a type T. Which lets you call T::associated_function today because Trait is not object safe, meaning dyn Trait doesn’t implement Trait, and therefore can’t be used as the type argument to this function.


#18

More than that, this just already compiles today:

pub trait Trait {
    fn assoc_func();
}

pub fn function<T: Trait + ?Sized>() {
    T::assoc_func()
}

Even if it weren’t backward incompatible, it does not seem feasible to me for the signature of T::assoc_func to change depending on whether or not T is bound Trait or Trait + ?Sized. That would be a big hazard.


#19

I think the solution would be either:

  1. Allow creating dyn Tr even when Tr is not object safe, but we do not guarantee that dyn Tr: Tr: this is only true when Tr is object safe (defined as today). (This is not a break of backward incompatibility as you cannot even create dyn Tr if Tr is not object safe)

  2. Have an ultimate default implementation for all traits: impl ! for Tr, such that if no default implementations was provided by the user,

    • All associated types are defined as !
    • All methods are implemented as panic!("Method has no default implementation!"). (Looks terrible; but a deny-by-default lint can easily help the user avoiding this)
    • When a vtable for a type is needed but no concrete can be derived, the above is provided.

So in the following

fn function<T: Trait + ?Sized>() -> i32 {
    T::associated_function()
}

proposal 1 says, you can pass T: dyn Trait only when Trait is object safe, like it was defined today; But even Trait is not object safe, you can still call a.associate_function on a trait object:

fn function(t: &dyn Trait) -> i32 {
    t.associated_function()
}

proposal 2 says, you can always pass T:dyn Trait but you get lint error if associated_function does not have default implementation, and panic if you turn the lint off.

Today we already have significance between T: Trait + Sized and T: Trait: if you have a trait bound on a method, this method will be removed from the vtable. I didn’t see how this makes things harder.