`FnOnce`: why is `Output` an associated type?

Looking at FnOnce more closely I was wondering what's the reason are Args type parameters, but Output is an associated type. Coming from C++ it originally felt natural, since you can overload on arguments there, but not on the return type. But now I wonder why this setup was chosen in Rust.

Shouldn't they rather both be the same, i.e., either both type parameters, or both associated types?

Edit: I meant to post this into the users forum, sorry. Let me know if I should delete it here.

Edit 2: Reason why this occurred to me was because I was wondering whether yield types (incl. Futures from await), shouldn't also be associated types in the Fn family of traits. Functions could then select on those.

Spitballing:

trait FnOnce<Args> {
    type Arguments = Args;
    type Output;
    type Error = !;
    type Yield = !;
    type Resume = !;
    // type Continue = !;
    extern "rust-call" fn call_once(self, args: Args) -> Self::Output;
}

Type parameters are selected by the caller, and associated types are selected by the implementation.

For traits types are arguments, like for types values are arguments. If you have a function fn foo(i32) you can decide to call it with foo(7). Inputs are your choice. But if you have fn foo() -> i32, you can't call it with foo() == 7. Outputs are function's choice.

So I can tell "I'm going to pass you a string", but I can't tell a function "you're going to be a function that returns an integer!". Function returns whatever it was programmed to return.

It's clearer with the Add trait. You can choose types you want to add, but the implementation dictates what type you get from adding them. Or iterators use associated types to tell you what you get when you iterate, you can't make them output any type you specify.

4 Likes

Well, using Add as an example, I can implement various versions of Add, with different right-hand-side arguments, but not choose the result of those (impl Add<Rhs=i32> for Foo vs. impl Add<Rhs=u64> for Foo) - fair enough.

OTOH, Into is a counter-example where the type parameter chooses the return type of the implemented function instead.

But back to FnOnce: this would mean I can implement FnOnce multiple times with different argument tuples, in effect an overloaded function (?), which I thought was not a thing in Rust (?).

Given that Fn/Mut/Once traits are mostly auto-implemented on functions and closures, it would seem the argument list is determined by that function/closure, and thus Args could as well be an associated type (?).

1 Like

That’s not the case, took me a minute to realize though. The important point is polymorphism. A closure like |x| x will have some anonymous closure type, lets call it MyClosure such that you get an (automatically created) impl<A> Fn<(A,)> for MyClosure. see edit below

Regarding your original question about associated vs parameter type: As the example of Into shows, this is actually a thing that could be designed in different ways. Since the main application is closures which can (by polymorphism) allow different argument types but always have a result type that’s determined by their arguments, an approach of making the trait allow as little freedom as necessary leads to the return type being an associated type. Furthermore, it might help with type inference. see edit below

I was going to make a comparison of closures with normal functions claiming they work the same in this regard, but actually they don’t. I came up with this example which makes me even question if having the return type be an associated type in the Fn-traits really is the best way.

fn f<A>() -> A { unimplemented!() }

fn apply<A,F: FnOnce() -> A>(f: F) -> A { f() }

pub fn foo() {
    let g = || unimplemented!();
    let _ = apply::<String,_>(f);
    let _ = apply::<(),_>(f);
    
    // only one of these works at a time!
    let _ = apply::<String,_>(g);
    //let _ = apply::<(),_>(g);
}

(playground)



Edit: Apparently closures don’t offer any polymorphism either...

fn foo() {
    let f = |x| x;
    f(1);
    f("hi");
}

(playground)

   Compiling playground v0.0.1 (/playground)
error[E0308]: mismatched types
 --> src/lib.rs:5:7
  |
5 |     f("hi");
  |       ^^^^ expected integer, found `&str`

error: aborting due to previous error

For more information about this error, try `rustc --explain E0308`.
error: could not compile `playground`.

To learn more, run the command again with --verbose.

Edit2: I just had the idea that a need for the arguments being type parameters probably comes from the fact that closures can be polymorphic over lifetimes. In the way that

Fn(&str) -> &str

actually means

for<'a> Fn<(&'a str,), Output = &'a str>

Well, in your example, fn f<A>() -> A is explicitly polymorphic (right term?) in A, whereas the various closures are monomorphic and have their types determined by unification - i.e., once unified with integer, cannot also be unified with &str.

Regarding life-times, if that's the case that these make arguments require being type parameters, why doesn't the same hold for the return type? (Also, with Args as associated type for<'a> Fn<Args = (&'a str,), Output = &'a str> wouldn't be possible?)

ISTM that Add and Into are actually somewhat putting a lie to "Rust not supporting overloads", since in a way they do.

Yes, that should indeed not be possible.

To elaborate, the problem there would be the implementation of the trait.

Something like

// automatic implementation for |x| x when expecting “Fn(&str) -> &str”:
impl<'a> FnOnce for AnonymousClosureType {
    Args = (&'a str,);
    // ....
}

would complain about conflicting implementations.

Edit: more precisely it’s an error about an unconstrained parameter.

2 Likes

So to summarize: Arguments must be type parameters so as to capture any lifetime parameters in the function call, whereas lifetimes in the return type will always depend on those, thus an associated type is sufficient (?).

But wouldn't you know, you actually can overload function calls, apparently: Playground Which brings me back to the question: Why now allow overloading on return types then, like with Into?

Historical note: The Fn traits (RFC #114) were originally designed before associated types were added to the language (RFC #195). Like the other std::ops traits, they previously did use a type parameter for the return type.

That "Associated Items" RFC spells out the main reasons for the change.

3 Likes

I don't see any mention of Fn traits in that RFC 195. Haven't read through the whole thing in detail there though, could you perhaps point out, where exactly the change of the Fn traits to their present form is mentioned?

You're right that Fn traits aren't mentioned explicitly. However, most of the general discussion (especially the parts that use Add as an example) applies to Fn also.

The specific change to the Fn traits was detailed in RFC #587, and also discussed here and here.

6 Likes

Thanks for the informative links! So it seems I'm just 5 years late to the party! :stuck_out_tongue:

1 Like

This topic was automatically closed 90 days after the last reply. New replies are no longer allowed.