Idea: "Maybe Trait" Object and Bounds (an alternative form of specialization)

Inspired by the ~const syntax, which means a type that may or may not be const, I would like to propose ~Trait syntax which indicates that a type may or may not implement a trait. I believe that this constitutes an alternative form of specialisation that (much like Rust's approach to function parameter overloading) is implemented in terms of trait bounds on parameters instead of having multiple separate function body implementations.

I hope that this would make it easier to implement (/verify for soundness), and it also allows it to extend to trait objects.

Usage in Trait Objects

It should be possible to define a type as:

let foo_and_maybe_bar : Box<dyn Foo + ~Bar> = ...

which indicates a trait object which defnitely implements the trait Foo and may implement the trait Bar. It would be possible to cast any type that can be case to Box<dyn Foo> to this type, and methods from the Foo trait would be directly callbable on values of the type Box<dyn Foo + ~Bar> like they would on Box<dyn Foo>.

Methods on Bar would not be directly available, however it would be possible to:

  • Query whether the value implements Bar (foo_and_maybe_bar.implements<dyn Bar>() would return a boolean).
  • If it does implement Bar, it is possible to downcast the type to Box<dyn Foo + Bar>.

Usage in Generics

It should be possible to define a function with a generic bound like:

fn func<T: Foo + ~Bar>(foo_and_maybe_bar: T> {
   ...
}

Much like in the trait object example above it would be posssible to:

  • Query whether foo_and_maybe_bar implements Bar (in this case through a new core library function core::any::implements::<T>()
  • If it does, downcast the type to impl Foo + Bar

In this case, as the concrete type if fully known at compile time, these functions should be const functions, which ought to allow the optimiser to prune branches based on the value returned by this function.

2 Likes

Don't we already have the ?Trait syntax for this, albeit currently only available for the Sized auto trait?

I believe ?Trait means that a type "does not" implement Trait. The new syntax proposed above would indicate that a type "may or may not" implement Trait.

No, T: ?Sized means that T may or may not be Sized.

There is also some syntax for strictly negative trait bounds: !Trait, which is stable for "unimplementing" certain auto traits like Send and Sync and unstable for constraint bounds.

5 Likes

Ah ok, well I'd be equally happy with ? if I could get querying and downcasting.

Given that adding a maybe traits to an existing trait bound per se does not change anything, we could still conflate this syntax with ?Sized.

The question is however, do we really need to explicitly add this annotation to bounds or should be just allow the query and downcasting for any trait?

This is a bit bikesheddy, but “query and downcast” APIs are rather an antipattern, compared to the much more Rusty “downcast returns an Option”.

6 Likes

The question is however, do we really need to explicitly add this annotation to bounds or should be just allow the query and downcasting for any trait?

Well for trait objects the annotation would definitely be needed in order to trigger the type to store whether that trait is implemented and vtable information for that trait for each type. For generics it probably isn't technically necessary, but I think it would be a good idea to indicate that the type implementing a trait can affect the implementation on the function in the signature.

This is a bit bikesheddy, but “query and downcast” APIs are rather an antipattern, compared to the much more Rusty “downcast returns an Option”.

I guess there would be no reason not to offer both styles of API. Much of Rust's standard library does this.

Just to clarify, you would not be able to cast a Box<dyn Foo> to a Box<dyn Foo + ~Bar>, correct?

Tangential question: What does it mean for a type (as opposed to a value) to be const?

The proposal doesn't have anything to do with constness, it's just stealing the ~ syntax from ~const to mean "maybe"

I would argue it's good documentation.

That is, if I call a method foo taking a T: Debug with:

  • Type A, which also happens to implement Display.
  • struct Wrap(A) which implements Debug by delegating to A and does not implement Display.

And I get a different result, because foo used Display for A but not for Wrap, I'm going to be very surprised, and none too happy.

On the other hand, if foo takes T: Debug + ?Display, then I'm warned ahead of time that the behavior may change based on whether T implements Display or not, so I know which traits my wrapper should implement to be fully transparent.

3 Likes

A different solution for similar problems could be optionally implemented methods on traits. It's a common pattern in C to define "traits" (structs of function pointers) with the convention to use null-pointers for unimplemented methods. We will need to introduce some clunky syntax for calling these methods anyways, method-individual granularity might then at least give you all the power you could ask for. The safety shouldn't be a problem (from my uneducated POV), because we should be able to just wrap vtable-methods into Options where the trait definition allows it.

1 Like

I'm aware of that, it's just I don't fully understand the syntax and its semantics; that's also why it's just a tangential question. It's kind of difficult to figure this stuff out when it's not (yet) part of any documentation.

Oh, ~const is used to indicate trait implementations can be used in normal functions and const contexts. IIRC the syntax is currently something like

const fn debug<T: ~const Debug>(_: T) { }

Which would be a function that could be called in a const context that could use Ts Debug impl in the function body, which currently isn't possible on stable.

1 Like

Note that the hard part of soundness is lifetimes, since they're erased.

How does adding this syntax make it possible to know whether the lifetimes are sufficient for the trait impl people are asking to use?

2 Likes

I don’t think that’s necessary for an MVP, but I think this could be enabled in future (as I believe the conversion could be implemented directly on Box<dyn Foo> without worrying about what the underlying type is.

I might be missing something here, but could one not just preserve lifetimes through type casts of types that maybe implement a trai (seeing as the underlying concrete object is not changing)? If the original object met the lifetime requirements then the type cast one should too, and vice versa.

A single function may have two instantiations with different lifetimes where one makes the teait bound hold and another doesn't. As lifetimes are erased these two instantiations will be merged together before codegen and as such have to produce the same trait object either both with or both without the maybe trait present. In general it is not possible to preserve lifetimes long enough to ensure a consistent answer is always produced.

2 Likes

Indeed. If you have

impl Bar for &'static u32 {}
fn func<T: ~Bar>(t: T) -> bool {
    t.implements::<Bar>() // or whatever API is chosen
}

and the compiler is instantiating func<&u32>, what should it return? It should return true if T is &'static u32, or false if it's &'a u32 for any other lifetime 'a. But the lifetime has already been erased by this point, so the compiler cannot distinguish between the two cases.

This is the essence of the specialization unsoundness.

There is, however, a possible workaround. The test can be redefined as not 'is this trait implemented?', but 'is this trait guaranteed to be implemented for all possible lifetimes that might have been erased within the caller's type parameters?'.

So func<&u32> would return false, regardless of the actual lifetime. But any impl that doesn't depend on lifetimes would be fair game. If you had impl Bar for i64, then func<i64> would be guaranteed to return true. Beyond that, impls that do have lifetime constraints would work in contexts where those constraints were statically guaranteed to hold. For instance, if func's definition were changed from fn func<T: ~Bar> to fn func<T: ~Bar + 'static>, then func<&u32> would now return true, because the only possible erased lifetime would be 'static.

In practice, this would be good enough for the vast majority of use cases.

For the record, a similar workaround is also theoretically possible for the existing version of specialization.

3 Likes