Pre-RFC: Expand object safety

This is a draft for a potential MCP I had in mind for a while. I don't really intend to champion it any time soon, especially since I already have a MCP in the pipeline. I'm posting this in case this inspires anyone, and for future reference in dyn-safety discussions. Feedback welcome.

Proposal

NOTE: There has been some debate in the past as to what term best describes when a trait can be turned into a trait object with the syntax dyn MyTrait. The current official term is "object safety", but it is widely agreed to be unsatisfying. We use "dyn-safety" in this document.

Summary

  • Progressively move the language towards having by-default dyn-safety for all traits.

  • To that end, add a new ?dyn trait qualifier to indicate that template parameters accept a trait object.

  • Loosen dyn-safety restrictions. Allow traits with associated functions, associated constants and generic methods to be made into trait objects (with some caveats related to ?dyn).

  • Plan a long-term roadmap for more restrictions to be removed over time, so that eventually impl SomeTrait and dyn SomeTrait in function arguments are functionally equivalent.

Motivation

There are frequent requests to make Rust friendlier towards runtime polymorphism, aka vtables. Use cases include:

On the other hand, in current Rust, a given trait MyTrait can only be used dynamically if it is dyn-safe; specifically, if it obeys a specific set of rules. The aim of these rules is to allow developers to pass compile-time implementations and trait objects interchangeably to any interface specying that it wants an instance of MyTrait.

This proposal claims that a different set of rules should be adopted, that still allows trait-objects and compile-time types to be used interchangeably, while expanding the scope of traits that can be used as trait objects.

The new set of rules would be:

  • For each trait, internally compute a dyn-safe subset of that trait. For instance, that subset would exclude associated functions and associated constants.

  • Add a ?dyn trait qualifier to template parameters. Possible syntax:

    fn my_func<T: ?dyn MyTrait>(x: &T) {
      x.do_something();
    }
    
    • If the ?dyn qualifier isn't used, the current rules apply.

    • If the ?dyn qualifer is used, my_func is only allowed to use the dyn-safe subset of MyTrait.

  • Allow trait objects to be created for traits with associated functions and associated constants.

    • If a trait has associated functions and associated constants, it can only be passed to a function expecting a ?dyn version of that trait.

    • Objects that are currently dyn-safe can still be passed as normal.

  • Allow trait objects to have generic methods and be dyn-safe, if every generic type argument is bound to a dyn-safe trait or the dyn-safe subset of a trait. Example:

    trait DebugPrinter {
      // DebugPrint is dyn-safe, because std::fmt::Debug is dyn-safe
      fn print_debug<D: std::fmt::Debug>(&mut self, data: &D);
    }
    
    • In that case, a default implentation is generated that takes a trait object for every generic type the method expects.

This new set of rules would allow trait objects to be made for a wider set of traits (traits with associated functions/constants, traits with generic methods) with no modification of existing code. In particular, this would allow developers to use traits in existing libraries that weren't designed with trait objects in mind, in contexts where trait objects are necessary.

NOTE: This proposal deliberately doesn't mention Sized. It's written with the assumption that, if/when RFC-1909 and RFC-2884 are implemented, size will matter less to the language over time, and syntax will eventually be changed accordingly. Strictly speaking, the examples would probably need + ?Sized annotations to compile, barring an edition change.

Breaking changes

None that I can see.

This feature should be strictly additive.

Future improvements

  • Have impl MyTrait arguments use the dyn-safe subset of MyTrait by default (breaking change).

  • Add a syntax to access associated functions/constants from a trait object (eg <typeof my_trait_object>::static_method(...)); this would require that additional data be stored in the vtable.

  • Perform the step above automatically in cases where the relevant vtable can be trivially found from an argument.

6 Likes

My one largest fear is that makes the concept of a "completely dyn-compatible trait" more subtly problematic. Because now instead of dyn Trait being an error for traits that aren't, whether fn foo(impl Trait); foo(&x as &dyn Trait) works is not based on whether dyn Trait works but on whether Trait is "completely dyn-compatible". (Or otherwise stated, whether adding ?dyn is a no-op.)

My secondary fear is smaller, but that basically every generic argument is going to be ?dyn. As the set of things allowed under ?dyn grows, the reason to not use ?dyn shrinks, so it will eventually end up just a required sigil for most use cases (that can still be omitted and then just make the API less usable).

But this is also effectively what best practices are today, just that you separate out the dyn-safe part of the trait manually. For that reason I think this is probably an improvement and motion in the correct direction, and I fail to see an edition-compatible way to make the default not be "wrong." (Except maybe <T1: ?dyn Trait, T2: impl Trait>, but that also seems strictly worse.)

1 Like

So, a lot of people aren't aware of this, but you can make this work manually already, you can "opt out" of associated items being included on trait objects with where Self: Sized. I personally vastly prefer this: object safety rules are typically not on the forefront of my mind so when reading code it is easier if I can see that an associated item doesn't work on the trait object instead of having to check if something is object safe. And I wrote the docs on the different reasons a method may not be object safe.

Furthermore, note that this is infectuous: any method returning an associated item, any method using an associated item in its defaulted body, all need to have where Self: Sized as well, which means the reason why a method is inaccessible on a trait object may not be immediately obvious.

Every time I've had to make a trait object safe, there has been a cascade of changes (usually where Self: Sizeds) that is necessary to make it work. It is hard for me to see how these changes can be implicitly made by the compiler in all but the simplest case, and it's hard for me to see how implicit where Self: Sized will not be confusing for people.

I do not see how this is any different from T: ?Sized + MyTrait. You mention later that not mentioning Sized is deliberate, but I don't see the point of ?dyn here aside from perhaps distinguishing object safety issues from unsized type issues.

Furthermore, ?dyn as a term is confusing: ?Sized means "may not be Sized (the default)", whereas ?dyn means "may be dyn (not the default)". The ? means different things here.

The reason generic methods are not object safe is not because of the trait bounds being not-object safe, it is because it can explode the vtable -- you now need a vtable entry for each debuggable type in your program.

2 Likes