Why does the default-associated-type trait fail to infer that Output = Self?

(This post is related to my previous one but stands on its own, it probably stems from a misunderstanding about how specialization works. Also, sorry in advance for mistakes, this is not my area.)

To solve a seemingly unrelated problem, I needed a generic transformation trait from T to U with default identity implementation (T->T) . Since this requires specialization, I tried the following approach (which compiles in isolation):

pub trait ConvertOutput {
  type Output;
}
impl<T> ConvertOutput for T {
  default type Output = Self;
}
pub trait Convert: ConvertOutput {
  fn into(input: Self) -> Self::Output;
}
impl<T: ConvertOutput<Output = T>> Convert for T {
  fn into(input: Self) -> Self::Output {
    input
  }
}

The problem is that when I try to use this with a concrete type it fails:

fn foo() {
  let x = 42_i32;
  let _ = Convert::into(x);
}

As I understand it, the compiler sees that i32 implements ConvertOutput via the blanket impl, where Output defaults to i32, however, due to the default keyword, Output is treated as an "opaque type", i.e., it ignores the actual type of Output, and for this reason, i32 does not implements Convert, since the blanket implementation of Convert requires Output to be i32.

This seems related to: Hazard: interactions with type checking section in the specialization RFC where they show a "bad" example, and their solution was to treat any default associated type as opaque. Here is their example for reference:

trait Example {
    type Output;
    fn generate(self) -> Self::Output;
}

impl<T> Example for T {
    default type Output = Box<T>;
    fn generate(self) -> Box<T> { Box::new(self) }
}

impl Example for bool {
    type Output = bool;
    fn generate(self) -> bool { self }
}

fn trouble<T>(t: T) -> Box<T> {
    Example::generate(t)
}

fn weaponize() -> bool {
    let b: Box<bool> = trouble(true);
    *b
}

However, in my case, the default associated type Output is intentionally separated from the method into to avoid these issues, so I don't see why it must be made opaque.
In fact, even in their case, separating the associated type from the method by splitting the trait Example into two separate traits should, I think, prevent all their problem. Consider this modification for their "bad" example implementation:

trait ExampleOutput {
    type Output;
}

trait Example: ExampleOutput {
    fn generate(self) -> Self::Output;
}

impl<T> ExampleOutput for T {
    default type Output = Box<T>;
}

impl ExampleOutput for bool {
    type Output = bool;
}
impl Example for bool {
    fn generate(self) -> Self::Output { self }
}

For the blanket implementation of Example we have two options:

// Option1 ("full" blanket implementation)
impl<T> Example for T {
    fn generate(self) -> T::Output { Box::new(self) }
}
// Option2 ("restricted" blanket implementation)
impl<T: ExampleOutput<Output=T>> Example for T {
    fn generate(self) -> T::Output { Box::new(self) }
}

With Option1, the function fn trouble will not compile, and would need to be modified in the following way:

// This will not compile since for a *general* T the 
//  associated type is a *general* Output and not Box<T>.
fn trouble<T>(t: T) -> Box<T> {
    Example::generate(t)
}

// The fix:
fn trouble<T>(t: T) -> T::Output {
    Example::generate(t)
}

But then fn weaponize will fail to compile since in the line let b: Box<bool> = trouble(true); the type returned from fn trouble will be <bool as ExampleOutput>::Output which is bool and not Box<bool>.

With Option2, fn trouble will not compile and would need the following fix which restricts the type

fn trouble<T: ExampleOutput<Output=Box<T>>>(t: T) -> Box<T> {
    Example::generate(t)
}

which will again make fn weaponize fail to compile in the line let b: Box<bool> = trouble(true);, where now it will fail since bool will not be accepted by fn trouble.

Did I miss anything here? Is there another reason to keep a defaulted associated type opaque even if there are no methods (/ other associated types?) in the same trait?
Moreover, I don't even see how their example compiles even when the defaulted associated type and the method are in the same trait. In that case, for a general type T the function fn generate returns a general Output and not Box<Output> and thus

// this will fail:
fn trouble<T>(t: T) -> Box<T> {
    Example::generate(t)
}

// this will not fail, but does not make problems as covered above.
fn trouble<T>(t: T) -> <T as Example>::Output {
    Example::generate(t)
}

Thank you in advance

That definitely doesn't cover all cases. Maybe the associated type has a Default bound, say.

Aside from safety hazards, relying on the default would be another case of "adding an implementation breaks downstream", which is a SemVer hazard that we probably don't want more of.

Incidentally, I wouldn't put too much stock in anything that requires specialization (in contrast with min_specialization). The former is known to be unsound and won't be stabilized in its current form.

2 Likes

Thank you very much for the reply.

Yea no I know that for some reason specialization is fundamentally unsound. I don't know if it is fixable, and I don't want to propose any changes. This is since, for one, I barely know anything about it. Moreover, even if specialization would be stabilized one day, it would take time, and would not be relevant to what I currently needed.

What I did want to do is to ask why this behavior is as it is. Specifically, I don't understand why a defaulted type should be opaque, especially for traits with a single type and no methods.

Can you elaborate? I struggle to see what problems could occur if Output were required to implement Default. In general, a default blanket implementation does not mean anything specific on a general type, only that it is safe to assume that any type does implement the trait with some implementation (since if there is not a "specific" implementation, then there must be at least a general one). This is similar to the fn trouble example:

And thus if you want to rely on the defaulted implementation you will have to specify it:

I also struggle to see how this can make a SemVer hazard:

If I understood correctly and you referred the fact that adding a specialized implementation can be a breaking change, isn't it the expected behavior? If there already is a (default) implementation, then specializing a type is not really adding a new implementation but modifying an existing one (e.g. the default one of the type).

You're correct, it's not really on Output that's the problem. It's that Default can be excercised elsewhere while relying on the blanket implementation. The point I was trying to get at is that a trait not having methods doesn't mean you can't make use of its associated types.

Let me present something based on your split up version of the trouble example.

trait ExampleOutput {
    /* default */ type Output;
}

trait Example: ExampleOutput {
    fn generate(self) -> Self::Output;
}

impl<T> ExampleOutput for T {
    type Output = Box<T>;
}

fn trouble<T: Default>(t: T) -> Box<T> {
    <<T as ExampleOutput>::Output as Default>::default()
}

fn weaponize() -> bool {
    let b: Box<bool> = trouble(true);
    *b
}

This compiles today and trouble can still assume that the blanket implementation applies, so specializing for bool would still be problematic, as far as I understand.

From the RFC:

Furthermore, it doesn't work to say that the compiler can make this kind of assumption unless specialization is being used, since we want to allow downstream crates to add specialized impls. We need to know up front.

So as far as I understand, no, it wasn't expected to be a breaking change.


Truth be told, I haven't put a ton of effort into my replies, and may be off on more points. I just pointed out some problems based on my existing understanding of the feature. Because it's a broken feature that probably won't go anywhere stable without a fresh RFC, I don't feel it's worth putting in a lot of effort to understand it in depth.[1]

Especially if it won't be of practical use to anyone else participating, either.


  1. And the original RFC dates from June 2015, when Rust 1.0 was one month old; sometimes the details of an RFC go out of date, making it take even more effort to grok the reality of the current situation. â†Šī¸Ž