Trouble with associated type propagation

I'm having some trouble understanding why get_direct() works here, but get() doesn't here - it seems like the value of A::To should be known at compile time and resolve to u64.. but it seems like that information is lost if working with an impl Map, rather than with A - but, it must be possible to know the value of <impl Map>::To in order for any <impl Map>::map to ever be called, right?

pub trait Map<From> {
    type To;
    
    fn map(&self, f: From) -> Self::To;
    fn and<O>(self, o: O) -> And<Self, O> where Self: Sized {
        And {a: self, b: o}
    }
}

pub struct And<A, B> {
    a: A,
    b: B
}

impl<A, B, From> Map<From> for And<A, B>
where
    A: Map<From>,
    B: Map<A::To>,
{
    type To = B::To;
    fn map(&self, f: From) -> Self::To {
        self.b.map(self.a.map(f))
    }
}

struct A;
struct B;

impl Map<usize> for A {
    type To = u64;
    fn map(&self, f: usize) -> Self::To {
        (f + 1) as u64
    }
}

impl Map<u64> for B {
    type To = usize;
    fn map(&self, f: u64) -> Self::To {
        (f + 2) as usize
    }
}

pub fn get_a() -> impl Map<usize> {
    A
}

pub fn get_b() -> impl Map<u64> {
    B
}

// This compiles just fine
pub fn get_direct() -> impl Map<usize> {
    let a = A;
    let b = B;
    a.and(b)
}

// This causes a compiler error
pub fn get() -> impl Map<usize> {
    get_a().and(get_b())
}

You should tell it what the To associated type is:

pub fn get_a() -> impl Map<usize, To = u64> {
    A
}

pub fn get_b() -> impl Map<u64, To = usize> {
    B
}

Hmmm yeah, I understand that I can do that, but I don't fully understand why I need to - if, for example, I wrote

pub fn get_a() -> impl Map<usize, To = bool> {
    A
}

I'd get an error for not implementing the relevant trait, so the type of A::To is known, and the compiler treats two impl Trait as distinct (this is why you can't have a Vec<impl Trait>, from my understanding) - why isn't the associated type propagated?

I guess my question is what about rusts type system doesn't permit get, but permits get_direct

The purpose of impl Trait is to hide implementation details. Anything not explicitly specified in the function signature is treated as unknown when you call it.

This allows you to change the underlying type without it being a breaking change.

3 Likes

Gah, so impl Trait always implies erasure of associated types?

This seems like a bit of a footgun if you're trying to, e.g. write a function that returns an impl Iterator

Also worth noting, the compiler doesn't take the current implementers of a trait into account. (with the helpful side effect that adding more implementers, or exposing the trait as pub doesn't break existing code)

struct HypotheticalNewC;

impl Map<u64> for HypotheticalNewC {
    type To = ();
    // ...
}

// now any `impl Map<u64>` does not have a known `To` type

Any use of impl Iterator that uses the underlying item will need to specify the Item type.

I think returning a bare impl Iterator with no item specified only gives you length hints (possibly is_empty?)

Rust never infers anything in a function signature from the function body, by design. Unless you explicitly constrain the associated type, it’s treated just like, say, an unconstrained type parameter (or indeed an unconstrained associated type of a constrained type parameter). There are very few things you can do with an unconstrained type variable.

edit: turns out this is incorrect explanation.

I'd add that impl Trait is same as a generic type.

fn my_func() -> impl SomeTrait { .... }

is the same as

fn my_func<T>() -> T
 where T: SomeTrait { ... }

or

fn my_func<T: SomeTrait>() -> T { ... }

And as I learned the hard way and with help of the guys here in the forum, generics are defined by the caller, not by the function.

The first example complies because types are told explicitly. In the second one you say my_function<_>() and ask the compiler to guess what you pass to .and(get_b<_>()) which means .and(get_b<I can't tell what type I need>()).

How? Why? The opposite would be a footgun. Not having to specify the associated type would mean that it could silently change without a change in the signature, just based on the body.

1 Like

This is false. Generics are only the same as impl Trait for function arguments, not for the return type.

5 Likes

Is that also true for async fn and marker traits? Does the Send and Sync-ness of the Future depend on the function body?

What's the difference between generic return type and generic impl Trait?

Generics and argument-position impl Trait are chosen by the caller, and return-position impl Trait is chosen by the body. The type-theoretic technical term is that generics and APIT are universally-quantified, while RPIT is existential. (In this regard, RPIT is more similar to a trait object, except that opaqueness isn't attained via type erasure but via the compiler intentionally hiding the otherwise statically-known concrete type.)

2 Likes

Generic return types are chosen by the caller. Impl trait return types are chosen by the function body.

For an example of generic return types, see the collect method. You can use it to take an iterator and generate a Vec or HashSet or many other choices. The return type is chosen by the caller.

1 Like

The Send and Sync traits are an exception here. They leak in a way that nothing else does when using impl Trait (or async fn).

This can actually be a problem for crates that want to be backwards compatible. They could accidentally change whether an async function is Send or not without noticing. It's why Tokio has this test:

2 Likes

If these are an exception then what is the actual rule here? Do these auto-traits always propagate, or only for impl Future’s generated by async? Is there a place these rules are written down?

The actual, full rule is that what you write in the function signature determines all information that's visible to callers. This applies to normal functions, impl Trait return types, async functions, and any other kind of function. The function body never has an effect on the signature in any way.

Except for one exception: For impl Trait in return position and async fn, whether the return type implements an auto trait is inferred from the function body. Currently, the complete list of auto traits is: Send, Sync, Unpin, UnwindSafe, and RefUnwindSafe.

1 Like

Hmmm, I don’t ask to be argumentative, to be clear, I am more wondering if it’s worth a PR on the rust reference to document the special casing of async fn.

Maybe also in the original case the compiler error could be clearer (something like “the value of A::To is erased due to the signature of get_a. You can avoid this by…” in cases where the erasure has erased a type that would cause the failing bound to be satisfied?

Edit: as an aside, this kind of special casing feels like a bit of a wart on the language, but there’s no real way to fix it with the current syntax I guess.

Edit2: Possibly the async fn reference would be a better place to document this

Mentioning it in the reference if its missing makes sense to me.

Yes, it's annoying. But the alternative can be rather cumbersome, so there's a tradeoff to be made.