(Original document is here.)
My concerns about RTN fall in two categories:
Based on further consideration, I no longer want to put forth an alternative to RTN.
Instead, I've proposed a set of mitigating mechanisms to address the former concern, and proposed specific requirements for potential RTN syntaxes to address the latter concern.
I very much appreciate the premise and value of being able to name the return value of an arbitrary trait method. I don't have any issue with that. I'm enthusiastic about more introspection mechanisms in general.
I like the idea of discouraging people from naming associated types when those types don't otherwise need a name, solely so that downstream users can write bounds on those associated types. I'm in favor of reducing the amount of "in case downstreams need it" boilerplate that library authors have to write, and that downstreams will push them to write if they don't.
And I'm a big fan of steps we can take towards "permissionless innovation", where we give people the capabilities needed to let them do innovative things that weren't anticipated by (for instance) their dependencies or other crates in the ecosystem.
Sometimes, providing a feature can steer the community in a particular direction, whether we intend that or not. There are some features that we've empirically observed people reaching for when they shouldn't; for instance, people who come from a language with global mutable variables reach for static mut
when there are much better solutions. What we provide in Rust, how we present it, and how readily at hand it feels in different contexts, influences the ways people use Rust and the ways the Rust ecosystem evolves.
Consider the history of C++ generics. Historically (and still in widespread current practice), C++ generics effectively use compile-time duck-typing: your requirements on a C++ generic type are precisely what you do with it, without having to declare those requirements. Whether you can instantiate a template function depends on the body of the function, which makes abstraction and encapsulation difficult. Modern C++ introduced "concepts", which provide benefits similar to our traits, though much code still doesn't use them. Rust avoided that from the outset, by using only traits and not duck-typed generics.
I think RTN has a danger of devolving into "do duck-typing and then add RTN bounds until it compiles", rather than encouraging better interface design such as defining a trait or trait alias. We're talking about RTN in the context of Send bounds, but I think some developers learning Rust and trying to get their code to compile may end up writing bounds like T::method(): PartialOrd + Debug
, which has many problems and seems like the wrong tool for the job. Most of the time, users should define a trait or a trait alias, and use that. Or, sometimes, they should be using a concrete type rather than a generic.
Using RTN in a function or data structure bound, rather than defining an interface, seems likely to make interfaces more fragile and less encapsulated, similar to C++ duck-typed generics. Once you've put RTN in the bounds of a public function, for instance, adding or removing a bound is likely to be a breaking change.
Proposed mitigating steps:
Mitigations considered and rejected:
ServiceSend
. I don't think this tradeoff is worth the cost, and it runs counter to the principles that (for instance) motivate relaxing the orphan rule.I'd like to avoid bikeshedding syntax in this meeting. Instead, I've proposed concrete requirements to apply to potential syntaxes, and I'd like to evaluate the requirement and then have asynchronous discussions about possible syntaxes that meet that requirement.
Writing T::func(): Send
looks and feels wrong, in that the type syntax doesn't feel like it mirrors the expression syntax, not least of which because it uses nullary ()
no matter what arguments the function takes.
In addition to looking and feeling wrong, and throwing off people's mental parsers, this syntax also closes off some future possibilities that we may want:
Type
is the return type of a function and you want to write Type::CONST
or Type::method(...)
. The proposed RTN syntax would be ambiguous with a function call.It's also less extensible to other things that we might want to introspect about a function other than its return type, such as its argument types or arity. For instance (straw syntax for illustration purposes), consider if we had method::return
and method::args::1
and method::ARITY
and similar.
I would propose that we pick some syntax that has all of the following properties:
I don't want to bikeshed the actual syntax used; I'd just like a syntax that has these properties.
I think RTN has a danger of devolving into “do duck-typing and then add RTN bounds until it compiles”, rather than encouraging better interface design such as defining a trait or trait alias.
tmandry: RTN doesn't allow duck typing because the methods you bound must already belong to a trait. If you have a bound like T::method(): Foo
, then you must also have a bound like T: Bar
, where Bar
is a trait containing method
.
We might choose to allow bounding inherent methods, but that would only work with concrete types, not any T
.
JT: The duck typing I'm referring to is the ad-hoc addition of bounds to the return values. Yes, you have to start out with a function or trait method, but the bounds on the return value could be defined based on precisely what you need from the return value. Send seems fine; PartialOrd + Add + Debug seems more like "write down bounds until it compiles".
tmandry: How is RTN different from associated types in that regard? People can already choose to turn their code into generics spaghetti soup, but we offer tools like traits and blanket impls (and in the future, trait aliases) to help them clean it up.
JT: What I'm talking about here is when you're externaling the things in the body into the bounds.
TC: You're talking about this, e.g.?
fn frob<W, X, Y, Z>(w: W, x: X) -> Z
where
W: Add<X, Output = Y> + Copy + Debug + Send + Into<u64> + 'static,
X: Mul<Y, Output = Y> + Copy,
Y: Div<W, Output = Z>,
{
dbg!(w);
thread::spawn(move || sleep(Duration::from_millis(w.into())));
x * (w + x) / w
}
JT: That's certainly an example, yes. Frankly, if we had a reliable way to catch and lint against that I'd encourage it.
NM: This is a possibility today. You could do the same with e.g. associated types. The basic mitigation that our language has against this is that we have traits that are groups of related functionality. You add a bound to get access to a variety of methods. That isn't changing with RTN.
NM: Imagine I have an async trait with two methods. So someone could put the bound on one method, but then someone could later want to call the other method. So that's a SemVer commitment.
JT: You mentioned that's a feature we want. I haven't seen why that is. What is the use case for having a trait with multiple methods, having an impl that makes some but not all of them return something meeting Send
, and having a caller that only needs those to be Send
?
NM: It does come up a lot. Most examples for me take the form of some code that I want to run in multiple situations. E.g.:
trait DataStore {
fn keys(&self) -> impl Iterator<Item = String>;
fn values(&self) -> impl Iterator<Item = String>;
}
impl DataStore for TestDataStore {
fn keys(&self) -> vec::IntoIter<String> {
vec![...].into_iter()
}
fn values(&self) -> impl Iterator<Item = String>;
}
impl DataStore for OtherDataStore {
fn keys(&self) -> hash_map::Keys<String> {
// keys are in a HashMap
todo!()
}
fn values(&self) -> impl Iterator<Item = String> {
}
}
fn do_stuff<T: DataStore>() {
for key in t.keys().rev() {
}
}
// I don't have a problem here exposing this bit of
// of my implementation.
fn do_stuff<T: DataStore>()
where
T::keys(): DoubleEndedIterator,
{
for key in t.keys().rev() {
}
for value in t.values() {
}
}
Similar example with Send
:
Rc
and it so happens that, in the parallel paths, I don't need to use those methods.
JT: To me, this is an argument that a trait alias should be used instead.
NM: There isn't a canonical set, and it's useful to be able to define arbitrary sets separate from the trait definition.
JT: It seems like you have multiple functions that want the same thing.
NM: I want aliases here for the same reasons I want aliases elsewhere, to reduce verbosity. This doesn't seem to me to relate to SemVer.
Josh: Is this in public API, or in local interfaces within a codebase?
NM: There could be a trait that defines a few methods… if you're asking, "is this something my crate exports?", probably not.
NM: A useful angle is to look at this from a SemVer point of view. Suppose there is a public trait. What are the situations where you would get errors we would not expect? E.g. when adding a new method.
JT: I'm asking about public APIs here because I'm less worried about this when used privately since it doesn't involve SemVer concerns. We could reduce the lints I proposed in this way.
NM: Imagine you have a public trait in crate A and a consuming function in crate B…what happens when you do:
trait Foo {
async fn foo();
// This is added later.
async fn new_method() {
let x = Rc::new(22);
foo().await;
drop(x);
}
}
NM: If someone has bounded on Foo
, and a new method is added, you want those people to be able to use those new methods.
Now…
new_method
is not Send
, which is good, beacuse I don't breakFoo
NM: If traits define an alias, it has SemVer implications. It's not just a shortcut.
NM: If every trait had an alias, and people didn't know it existed, and you were using alias, and the trait author later adds a method, they could break you.
NM: Put another way, if you had a design that said Send Foo
and it meant "the trait Foo
where all async fns are Send
". Now if a new async fn is added with a provided definition that is not Send
, that's a breaking change to any users of Send Foo
. (Same thing we say about ?const
traits.)
Yosh: We have a trait and people using it, when new methods are added, we want people to be able to add a non-Send
method, is that correct?
NM: That's a version of it. There are two motivating use cases. One is, for the person who defines the trait, defining a set of methods that will have a set of bounds that will be extended together. The other is having a function that can be specific about what it needs so the function can be reused as best possible.
NM: When you're defining a generic function, there's a tradeoff between how often you can use a function and what changes you can make to the implementation (without knowing what types you are being invoked with). Some patterns are very limiting to the changes you can make in the impl (i.e., limit you from calling more methods than you are already using). Other patterns are very limiting to the places you can use it.
JT: There's value in higher level groupings of the things that you might want. If you can give a name to the thing, it makes it more likely you can evolve it in the future.
JT: What mitigating steps do people disagree with?
JT: I'd like it if there's a higher level thing that can build on RTN.
tmandry: The trait-variant
macro does allow this, including that one can implement one of the traits and we provide blanket impls for the others.
JT: What's the situation on trait aliases?
NM: I don't want to block on it, but it may not be far away if we focused on it.
CE: There are no serious concerns on the implementation. It mostly comes down to what semantics we want. There's nothing in the trait solver that's problematic for trait aliases right now.
CE: To the compiler, RTN bounds are just regular associated type bounds.
tmandry: What about implementable trait aliases?
CE: No concerns, as long as there are reasonable restrictions such as that only one trait applies there. It's similar to dyn
in that respect.
JT: We probably don't even need implementable trait aliases. Just bare trait aliases is probably enough.
tmandry: You can simulate trait aliases today.
JT: Less friction is important here.
fmease: If the trait aliases have to be defined upstream, doesn't that still have the problem of adding new methods?
fmease: So there's no way to express that non-Send
methods could be added later to the trait?
NM: You'd have to annotate it.
JT: It sounds like we have a trait alias mechanism that is not too far off. Could we commit to shipping that concurrently with RTN?
NM: I could live with it.
tmandry: It would be better to ship them together. But if we're waiting 3-6 months for it, then I would reconsider.
nikomatsakis: can we edit to arrive at a consensus…?
Josh: I care more about the public interface case: could scope down to only having rustc lint on having RTN bounds directly in public interfaces. Then, if you really want ad-hoc RTN bounds you can allow the lint, but there's steering towards designing an interface via trait aliases.
Consensus on semantic concerns: We'll try to ship trait aliases (but not necessarily implementable trait aliases) concurrently with RTN (but we'll reevaluate if those are taking too long), and we'll ship RTN with a warn-by-default lint against the direct use of RTN in the bounds of a reachable crate-level public interface (e.g. function, method, type (or type alias), trait, etc.) except for a trait alias.
Outcome on syntax concerns: We did not discuss the syntax concerns. Those are still blocking, and we'll try to resolve those asynchronously. We'll check in at the next triage meeting how that is going.
(The meeting ended here.)
contexts that could be either a type or an expression
tmandry: I don't know of such contexts that exist in Rust today. What are some examples of contexts where we might want to allow this? Supplying expressions as const parameters without surrounding with curly braces?
JT: I'm talking about expression contexts in which you can start out writing a type before you write ::
, for instance.
tmandry: I see, so UFCS for instance. It is ambiguous in those contexts, though I think we could fix that with a turbofish-like form. We would need a new name. How about starship?
Foo::method::()::poll(fut, cx)
Of course, maybe it's better to use a type alias in these cases when you can.
Josh: Can we please not create a new syntax like turbofish, when we potentially have an alternative that allows using the same syntax for type and expression? :)
tmandry: Niko mentioned that we can use
<Foo::method()>::poll(fut, cx)
consider if we had method::return and method::args::1 and method::ARITY and similar
tmandry: We could project out like this if we had RTN, the user would omit parentheses. The question is whether we would want projections that are dependent on argument types. Supplying argument types is a natural extension of the RTN syntax, but RTN itself would not allow introspection of anything other than the return type.
JT: These are future possibilities, and ones that seem more compatible with using a different syntax that doesn't use the empty parentheses.
tmandry: What do you mean by "more compatible"?
One concrete issue I could see with RTN is if we wanted to bound a method as const, dependent on the argument type. I was thinking we could write const bounds like this:
where Type::method: const
but if we need to supply argument types (think higher order functions), that would not work. Maybe could instead write
where const Type::method(F)
Josh: By "more compatible" I mean that I think method(): Bound
is less extensible to things like argument types and arity, whereas method::return
has obvious parallels in method::ARITY
and method::args::1
and similar. That's one example syntax, but one that admits other variations in a way that ()
doesn't.
And the example you give of depending on argument types is exactly my concern about the empty parens; if we need to depend on argument types, then we should only use ()
for a function of zero arguments.
nikomatsakis: Generally agree with the concern as described that RTN presented the possibility of people authoring "overly specific" sets of bounds. This is both its power and its risk – I think a lot of the use-cases I can think of for having different sets of bounds on functions come down to trying to extract out some common code for particular scenarios where you know bounds will be met (e.g., I happen to know that in the various cases where I invoke this function, some particular trait method returns a double-ended iterator, and I'd like to take advantage of that). i.e., sometimes it's good to be able to carve out code. But it does create the risk that people will
Tyler and I had some discussion about precisely this and we realized though that the situation was a bit more complex. It seems like the rules we want are…
I'll write more later, going to finish reading.
nikomatsakis: The lints against using RTN seem overly broad to me. I was thinking of lints that push people towards defining aliases, something like
but linting against using RTN at all seems way too broad to me.
Josh: Nit: "every method" may not be the case, since Send might only make sense for the async methods, for instance. But I understand what you're proposing in general, that we lint against cases where we think it likely the
TC: The desire, it seems, is to avoid code like this:
fn frob<W, X, Y, Z>(w: W, x: X) -> Z
where
W: Add<X, Output = Y> + Copy + Debug + Send + Into<u64> + 'static,
X: Mul<Y, Output = Y> + Copy,
Y: Div<W, Output = Z>,
{
dbg!(w);
thread::spawn(move || sleep(Duration::from_millis(w.into())));
x * (w + x) / w
}
…but this doesn't need RTN, unnameable types, or even traits. It seems like we crossed this bridge long ago.
yosh: I thought the issues with RTN were less about that, and more about cases like these?:
/// Forward an `impl AsyncRead` which guarantees
/// all of its methods return `+ Send`.
///
/// Notably, this is not forward-compatible
/// if we introduce any new methods because
/// we have no "for all methods" bound.
fn wrapper<R>(reader: R) -> R
where
R: AsyncRead<
read(): Send,
read_vectored(): Send,
is_read_vectored(): Send,
read_to_end(): Send,
read_to_string(): Send,
read_exact(): Send,
read_buf(): Send,
read_buf_exact(): Send,
by_ref(): Send,
bytes(): Send,
chain(): Send,
take(): Send,
>,
{ reader }
nikomatsakis: the doc outlines three requirements for syntax…
I would propose that we pick some syntax that has all of the following properties:
- Equally valid and unambiguous either in expression context or in type context
- Not ambiguous between "nullary" and "unspecified arguments"
- Ideally leaves room for future properties of a function
…I believe the first one is going to be the hardest to fulfill. In fact, our existing types don't meet it (hence turbofish). My personal preference is to do <T>::foo()
when invoking methods on a type that isn't just a plain identifier or path. I wonder if that suffices? I'm sympathetic to the last two, which I note would I think be fulfilled by just T::foo(..)
– it seems verbose, but not excessively so.
Josh: Many of our types do meet it, though. Including associated types, and almost generic types (modulo having to add ::
, but they at least look closely similar).
TC: It seems what is desired here is a separate feature that would allow an upstream to prevent downstreams from setting bounds on return values. Not saying that we want that feature, just that there's a separate axis here and mixing this with unnamebale types seems perhaps to be less clear.
yosh: Where do we see trait transformers fall in this space? I haven't been a part of the last few T-lang conversations on RTN, so I'm not sure what the latest thinking about it is. But the way I understood it, it would present a credible solution to some of the RTN issues inherent to RTN?
nikomatsakis: can we edit to arrive at a consensus…?
The final point is encouraging people to define trait aliases themselves. I'm in favor, but more narrowly. For example: