Syntax bikeshed: specializable bounds

You could make full specialization "sound" if you are liberal with the "specialization is just for manual as-if optimization" juice, allow specialization to be a best-effort feature that randomly just doesn't happen, and be very careful to only call the more general implementation in potentially ambiguous cases.

I don’t think it’s that bad. Specialization not happening wouldn’t be “random”, it would be in specific rare cases where impls depend on lifetime bounds that are not already in scope.

Personally, I’d like to see the specialization -modality approach with syntax like this:

impl<T> Foo for T
  where regardless_of_lifetimes { T: Debug } {
    …
}

Sure, regardless_of_lifetimes would be a wordy keyword, and it’s also not quite an accurate description of the semantics (because you can depend on lifetime bounds if they’re present on the base impl). But I think it’s close enough to accurate, and makes it relatively self-explanatory what’s going on.

And with this approach you can just specialize on existing traits like Copy and Debug. The specialization-trait approach not only breaks compatibility with past traits, it also imposes painful choices in the future as trait authors have to decide whether to mark their traits specializable or not. I don’t think it’s worth imposing such severe limitations on specialization just to save a little confusion.

3 Likes

I don't like spec because to me it first means "specification". So the longer specialized or special or something else would be preferred.

2 Likes

Wouldn't for<'a> be a better spelling for it? You then have explicit control over which lifetimes must agree and access to any lifetimes of T when specifying the where clause(s).

1 Like

How would that work when there are no lifetimes to name? Maybe you can show a full example of what you mean.

for<'_> Debug would mean "is Debug for any given lifetime".

1 Like

But when lifetime bounds are "in scope" isn't necessarily a question with an obvious answer, so even if it's deterministic for a given version of the compiler, it could appear random to even a fairly knowledgeable developer.

I suspect that by "in scope," you mean determinable by only the immediate generic binder of the function doing the specialization, as I think that's the only precisely definable set to perform specialization for. But if that's the case, isn't it possible to just use the more specific case, since it's always applicable?

If you want specializes_on(static_cow) to actually specialize, then we're applying specialization during generic instantiation. Rustc currently does no real generic instantiation (only a small subset of checks for wf guarantees for some type instantiations), and at the point monomorphization happens and the generic selection actually occurs after all lifetime info has been erased. That's the problem with any lifetime-dependent specialization.

We could say that instantiation of specializations occurs as a part of MIR inlining. (That's actually a rather promising angle, IMO.) But what gets inlined, especially at the MIR level, is effectively opaque to the developer (thus seemingly random).

Furthermore, it's not as simple as saying that the specialized impl needs to be "always applicable;" instead, it needs to be "always applicable" from a baseline bound of whatever the specialization context is. As a trivial example, given T: '_, it would be unsound to specialize on T = &'static str, but if we're given T: 'static, then T = &'static str is now a sound specialization.

Specialization that only works when satisfying impl lifetime bounds aren't explicitly bound is still a 90% solution, I agree, but I also think exploring what the 99% solution would look like is a worthwhile venture. Perhaps (with an exception of some core traits) it's a reasonable expectation for there to typically be a separate trait opt-in to specialization, that enforces the "always applicable" requirements.

Since these specific traits are typically derived, a specializable CopySpec/DebugSpec would day 1 cover most (but not all) types.

But, note, std is experimenting with TrivialClone now, with the soundness constraint that Clone is a simple copy, even if the type is not Copy. So that types like ops::Range<i32> can benefit from the library level clone elision specialization.

making them specializable over an edition

Both Copy and Debug are good candidates for wanting to be specializable. This could happen mostly transparently with something along the lines of:

trait Debug2015 {
    // elided…
}

spec trait Debug20XX: spec Debug2015 {
    // read: specialization trait `Debug20XX` which provides
    //       specialization access to the `Debug2015` bound
    // this item purposefully left blank
}

impl Debug20XX for dyn Debug2015 + '_ {}

trait Debug = builtin#magic! {
    if in specialization bound => Debug20XX,
    if min_edition = "202X" && in `impl for` =>
        Debug20XX + Debug2015, // impl both in one block
    otherwise => Debug2015,
}

derive(Debug) on all editions derives both traits, with generics bound on the respective trait.

For Copy, though, extending TrivialClone library optimizations to non-Copy types seems "worth it" enough that those should be separate concerns.


…I forgot to factor Christmas into my estimate for blog post publishing. But it's almost ready, hopefully.

1 Like

Well, my mental model is of how I'd like the compiler to work is that it should perform another type-checking step based on what's known about type arguments and the constraints in scope.

So, for example, suppose we specialize a trait for &'static str:

trait Bar { fn bar(); }
impl<T> Bar for T {
    default fn bar() { println!("generic implementation"); }
}
impl<'a> Bar for &'a str
    where regardless_of_lifetimes { 'a: 'static }
{
    fn bar() { println!("&'static str"); }
}

The following function would never use the specialization because the lifetime in the type argument would be erased:

fn foo<T: Bar>() { T::bar(); }
// foo::<&'static str>(); // prints "generic implementation"

But this function would use it, because even though the lifetime is erased, the compiler can derive 'a: 'static from &'a str: 'static:

fn foo<T: Bar + 'static>() { T::bar(); }
// foo::<&'static str>(); // prints "&'static str"

…But from what you're saying it sounds like this approach would be difficult to implement. I didn't anticipate that.