Summary
Argument-position impl Trait
corresponds to a hidden type parameter, which in turn is implemented by monomorphizing the callee function for each set of concrete type arguments.
Return-position impl Trait
could be extended to match, by monomorphizing the caller function (or more specifically, the continuation after the call) for each concrete return type.
Motivation
Rust functions that return impl Trait
are currently only allowed to return a single concrete type. This can be a papercut—for example, while it can replace some uses of Box<dyn Future>
, it can’t handle functions that can return multiple future types.
async fn
mitigates this for futures, because it automatically generates an enum
-like type to handle its corresponding states. There have also been proposals for something like enum impl Trait
, which would automatically wrap all the return types into an enum
and derive a delegating implementation of Trait
for it.
Monomorphizing caller continuations would enable impl Trait
to handle multiple return types via the usual trade-off of code size instead of function pointer-based dispatch (as with dyn Trait
) or enum
boilerplate (as automated by enum impl Trait
).
Aside: some context
There has been quite a bit of discussion in RFC 2071, its associated tracking issue, and the #design channel in the rust-lang Discord server around the syntax of impl Trait
/abstract type
, as well as how to explain its semantics.
The prevailing explanations in announcements/docs have centered around existential types. The the 1.26 release notes claim "impl Trait
is universal in an input position, but existential in an output position." The post "impl Trait
is Always Existential" gets closer to the truth, but doesn’t quite capture everything.
More recently, @varkor’s summary of the Discord discussion points out the source of the confusion here: argument-position impl Trait
(APIT) and return-position impl Trait
(RPIT) can both be expressed as existential types, but only with inconsistent quantifier scopes- thus RPIT’s inability to handle multiple types.
So one solution, which I don’t want to discuss in this thread, is to stop talking about existential types at all and instead explain impl Trait
in terms of type inference. @varkor has another summary for this approach, which is basically to treat impl Trait
as a placeholder for ML-style let-polymorphism, inferring the “most general type.” This makes the semantics of APIT and RPIT consistent with each other.
Instead, in this post I describe an alternative solution—keep the existential-types interpretation (if not the docs, because “existential” is a terrible way to teach this stuff), and make APIT and RPIT consistent by extending RPIT’s capabilities.
Implementation
View a function call’s return address as an argument to the callee—in this sense, we’re already using a restricted form of continuation-passing style. We can add more return address arguments (i.e. passed continuations). Push them all on the stack like today’s return address, or put them in a vtable and push a pointer to that, etc.
Today, a function returning impl Trait
has a single return type that is known to the compiler as it builds the caller (though not exposed to the program). Extend this to a set of N possible return types that can still be known to the compiler, in the same way.
In the caller, for each call to a function returning impl Trait
, monomorphize the continuation for each of those N types, and point the extra return address arguments (i.e. passed continuations) to them.
In the callee, each return site still knows its return type statically. Select the return address corresponding to this type, clean up the stack frame, and jump to it.
Example
For example, look at these functions:
fn f() {
println!("{}", g(false));
println!("{}", g(true));
}
fn g(x: bool) -> impl Display {
if x { 42 } else { "hello" }
}
We can lower g
to something like this:
struct GVtable {
i32: extern "rust-continuation" fn(i32) -> !,
str: extern "rust-continuation" fn(&'static str) -> !,
}
fn _g(r: &'static GVtable, x: bool) -> ! {
if x {
become r.i32(42)
} else {
become r.str("hello")
}
}
And we can lower f
to something like this:
fn f() {
_g(F1_GVTABLE, false);
static F1_GVTABLE: &'static GVtable = &GVtable {
i32: _f1<i32>,
str: _f1<&'static str>,
};
extern "rust-continuation" fn _f1<T: Display>(t: T) -> ! {
println!("{}", t);
_g(F2_GVTABLE, true);
}
static F2_GVTABLE: &'static GVTable = &GVtable {
i32: _f2<i32>,
str: _f2<str>,
};
extern "rust-continuation" fn _f2<T: Display>(t: T) -> ! {
println!("{}", t);
return;
}
}
Notes
The “rust-continuation” calling convention is a bit magical, but then again this syntax is purely for illustration. Its main properties:
- “rust-continuation” functions must take zero or one arguments, and return !.
- “rust-continuation” functions share the stack frame of their parent
fn
item, and thus are only ever called by its callees. - While “rust-continuation” functions appear to return !, they can
return
from their parentfn
item.
The lowering of f
looks a lot like the lowering of async fn
s. There can be somewhat of a combinatorial explosion of “states”, depending on the impl Trait
functions called and the control flow around them, but this may be the most straightforward way to convince LLVM to generate this code?
This technique should still work for trait methods that don’t return associated types- impl Trait
was already not going to be object-safe. It does, however, make it less plausible that we might see a dyn Trait<typeof(method)::Output = T>
extension, since that would no longer really make sense.
On the other hand, this doubles down on the need for abstract type
/RFC 2071, and really calls for a syntax other than type Foo = impl Bar
, especially for associated types, which would need to remain as single types. Such a feature would really be something entirely unrelated to impl Trait
.
Unanswered questions
How possible is it to convince LLVM to generate this sort of code? Maybe it’s a pipe dream; maybe it’s plausible but a ton of work; maybe it’s easier than I expect. Even if this is only plausible as a far-future extension, maybe we like it enough that we try to stay forward-compatible with it, and make syntactic decisions accordingly.
Does this implementation style overcome the hurdle faced by enum impl Trait
? I like that it folds all the “micro-side” runtime cost and implementation complexity back into a dynamic jump that already exists—function return—rather than inventing a wholly new dispatch mechanism, while simultaneously providing the functionality that people expect there.
What do people think of the teachability side of this? Personally I like that it makes impl Trait
capable of essentially the same things as dyn Trait
, bringing impl
/dyn
closer to a straightforward “code size”/“run time” knob. It also decouples impl Trait
from the abstract type
-like functionality that it was created for, which ought to alleviate some of the angst around argument-position impl Trait
.