Weird error with tokio::try_join! and mapped futures

Having M as both a type parameter and an associated type in the impl is like this function:

fn f<T: Display>(t: T) -> impl Display {
    t
}

Where you're supposed to have one implementation-defined return type as an output, but you based it on an input parameter that is generic, and that the implementation doesn't get to choose. (It's pretty much exactly like this in fact -- the Output of the Fn traits are associated types.)

Your associated TAIT is capturing M, so it's the same sort of issue. Hopefully that clears up what I meant.


I wasn't serious about getting rid of M as a trait parameter, the associated type comment was just a technical side note / toying with it for fun (I did get a ICE out of it). Though you could remove it with some sort of type erasure (fn pointer or dyn FnOnce). I don't think that's really a goal on its own though.

1 Like

But can't each implementation of the trait specify their own type for each associated type? I.e. each typle of types <F, O, E1, E2, M> results in a different FutureMapResErr, for example. Therefor, I don't see (yet) why I can't pass the M the same way I can pass the other types.

But it's really not that important, I think I'll need more practice with type parameters and associated types in future. AFAIK, associated types can't achieve anything that type parameters couldn't achieve (except better readable code where the type isn't really "free" to choose).

But thanks for trying to explain.

Ah, okay :grinning_face_with_smiling_eyes:

Yeah, I wouldn't want type erasure here.

In the process of writing this up, I became uncertain if we're talking past each other or not. It mainly depends on whether or not you're taking GATs into account or not. With enough use of GATs and generic methods, you can get rid of the trait parameters. (I hit the ICE and bailed before getting there on my first go, but succeeded this time -- link at the end.)

But... since I'm uncertain if that's what you meant or not, let's ignore GATs for the moment and concentrate mostly on stable. I'm going to weave TAIT in and out some (and we'll see how it both obscures and enables things), and then come back to GATs at the end.

(Side note: When the playground links below have stable in the URL, the run button is still coming up "Nightly" for me; I'm not sure why. In case it's the Playground and not my browser, I'll just note that any playgrounds linked below that do not use features work on stable.)


Okay, let's start with a simplified example using TAIT.

pub trait ResExt<D> {
    type Output: Debug;
    fn dmap(self, debug: D) -> Self::Output where D: Debug;
}

impl<D, O, E> ResExt<D> for Result<O, E> {
    type Output = impl Debug;
    fn dmap(self, debug: D) -> Self::Output where D: Debug {
        // Wrapper<Result<D, E>>
        Wrapper(self.map(|_| debug))
    }
}

fn main() {
    let res: Result<i32, ()> = Ok(42);
    let one = res.dmap("foo".to_owned());
    let two = res.dmap(vec![13]);
    
    println!("{:?}", one); // Wrapper<Result<String, ()>>
    println!("{:?}", two); // Wrapper<Result<Vec<i32>, ()>>
}

This all works. Two implementations for Result<i32, ()> get instantiated: an implementation of ResExt<String> (with Output = Wrapper<Result<String, ()>>) and an implementation of ResExt<Vec<i32>> (with Output = Wrapper<Result<Vec<i32>, ()>>). Note how the TAIT hid the capture of the type parameter D into the associated type Output.

Let's get rid of the TAIT so less things are hidden. (This demonstrates some utility of TAIT: this removal isn't always possible if the types are opaque, and even though they are not in this case, I had to change the privacy of Wrapper and move the Debug bound to the implementation and not just the method.)

Can we get rid of the D parameter on stable? Let's try moving D to the method. Spoilers, it doesn't work. Well, the trait definition is fine, but there's no way to make Output depend on D, because D isn't in scope for Output anymore. This is one of the main things I was getting at -- it's just like your M in the previous comments, though TAIT makes the dependency harder to see.

And it would be problematic if the Output could depend on D, because users of dmap can supply any D they want (if it meets the Debug bound) -- but we now only have one implementation of ResExt for any given type Result<X, Y>. Coherence demands we find the singular implementation, which contains a dmap function that returns a single, concrete type. Because there can only be one implementation per concrete Result<X, Y>, there can only be one Output type.

If you try with TAIT, the error is more obscure. But the "parameter list for impl Trait" is limited precisely to avoid problems like the coherence ones here.


Alright, try two. How about if we make D another associated type instead? Because if we use an associated type, Output can refer to it once again.

Hey, it works! Err, sort of. We still only have one implementation (per concrete Result type), so there can only be one associated type. So if we want to use a different D, well, we can't. dmap is no longer generic for a concrete Result type -- it can't be generic over multiple implementations, and it's not a generic method either.

In your async case, if every Future you want to implement this trait only needs to accept a single closure, this still might work. But if there's a concrete future type that needs to accept more than one closure, it won't. (This is another case where I'm not sure if we're talking past each other.)

TAIT doesn't change this (and my guess was right, your defining use has to be in the impl).


I've been ignoring GATs until now. Are they up to the task? Yes, GATs can do it. We make the method itself generic on the input, and then we weave the generic input into the now-generic Output.

Interesting side note:

I had to move the Debug bound off of the Output type and on to the dmap method in this version, because Wrapper<Result<In, E>> doesn't always implement Debug. Even though we only return Debug implementers, you can do things like this:

fn f() {
    struct NotDebug;
    let wrapper: <Result<i32, ()> as ResExt>::Output<NotDebug> = Ok(NotDebug);
    println!("{:?}", wrapper); // if this compiles, someone's lying
}

Well, maybe that's not too interesting on it's own... but if we add back TAIT again, we can move the bound back to Output. Uh oh, is this a problem? It is not, because we can't construct the opaque type. Turns out this is very related to why I had to move the Debug bound on the implementation when I removed TAIT the first time, above.


Alright, finally, let's apply this to your code. I'll start with the moe version.

First, we only need one implementation per Result<O, E>, so we can demote those to (plain) associated types.

Next, we move M to the method instead of the trait. This version errors, and I think it's worth pausing to think it through. We can't get rid of TAIT because of all the opaque types, but there's that pesky parameter list error again -- this is just like our original attempt above to move D from the trait to the method! It's a little more confusing because FutureMapResErr is already a GAT. But we still must explicitly weave the input (M) into the GAT. That will put M in the parameter list for the GAT. (This is probably a suggestion the compiler will make, once GAT + TAIT support is more fleshed out.)

Once we do that, it works. Lots of TAIT and GAT, but no dyn and no type parameters on the trait.

1 Like

I think I can follow now. Using TAITs, I can make the impl Future<Output = Result<O, E2>> differ for each M:

type FutureMapResErr<T> = impl Future<Output = Result<O, E2>>;
fn map_res_err<M>(self, mapper: M) -> Self::FutureMapResErr<M>
where
    M: FnOnce(E1) -> E2,
{
    /* … */
}

(Playground with only M removed as type parameter of the extension trait)

But what I don't understand yet is the following:

It seems I thought that type Ty = impl … doesn't imply there is only one type Ty, but one type Ty for each implementation, i.e. one particular type for each type tuple <F, O, E1, E2> (as specified after impl). That should be correct. But then, I get: error[E0207]: the type parameter M is not constrained by the impl trait, self type, or predicates. (Playground)

So I would say this is kind of a syntax restriction that it's forbidden to add type parameters which apparently aren't used? (See rustc --explain E0207) Except in this case, the type parameter M would be used because it should serve as a quantifier for the associated type (TAIT), so it isn't useless (but the compiler thinks it is?).


Update:
Maybe the quantification of impl Trait must always be explicit, either through a type parameter (using GATs) or by explicitly naming a type parameter on the right-hand side.

Yes, well... I would say, using GATs. Or GATs and TAITs; if you can't name your outputs, you need both.

I've hit this error myself and thought "hmmm really?", but have never really followed up on it. You can read RFC 447 for the motivations; the case of an unconstrained associated type is explicitly called out as a drawback. But I can see how it would be problematic: under RFC 447, if we have a concrete type that implements a concrete trait, there is only one possible implementation, and thus all associated types and other non-parameterized trait items are also concrete. Allowing this case would break that tautology, which is used a lot.

trait Tr<X> { type D: Default; }
fn _f<T: Tr<()>>(_: T) {
    // If you can call this function, `T::D` is a concrete type
    let _: T::D = Default::default();
    // More generally,
    let _: <SomeType<A, B> as SomeTrait<C, D, E>>::F = ...;
    // If the type and all its inputs are known,
    // and the trait and all its inputs are known,
    // then then impl and all its inputs are also known,
    // and thus its items (like associated types) are also known
}

Anyway, yes, there is exactly one (plain) associated type per implementation. That's true independent of whether the type uses TAIT or not.

In a more general sense, for any $Thing, there's exactly one definition for every set of concrete input parameters that satisfy their bounds. You can look at (plain) associated types as:

  • Having no input parameters, within the context of a concrete implementation
    • And thus there's only one in this context
  • Having the same input parameters as the implementation, within a broader context
    • E.g. <Vec<String> as Deref>::Target

If I understand what you mean by quantification -- ways to make multiple? -- then there must be a parameter somewhere, and that parameter must be "in scope" to be part of the definiton (be it explicitly on the right, or implicitly in the defining use). As I understand it, in fact, impl Trait captures all parameters in scope (which is of practical importance if the parameters happen to carry lifetimes).

Outside a trait, that can be a parameter on a generic function:

  • fn foo<X>() -> impl Trait ...

Within a trait, that parameter might be:

  • On a GAT: type Gat<X> = impl Trait ...
  • On the trait itself (when impl Trait is on either a GAT or a plain associated type)
  • But return-position impl Trait is still not allowed...

So if there's going to be more than one definition per implementation, that means a GAT must have supplied the parameter. And to emulate return-position impl Trait, if you want the function parameters to be in scope, you have to weave them through by adding parameters to a GAT as well.

  • :x: fn method<T>(&self) -> impl Trait;
  • :white_check_mark: fn method<T>(&self) -> Self::Gat<T>; type Gat<T>: impl Trait;

(I suspect there will be sugar for this some day, but who knows when.)

1 Like

This topic was automatically closed 90 days after the last reply. We invite you to open a new topic if you have further questions or comments.