Try   HackMD

Discussion

This meeting is a follow-on from:

Design meeting 2024-01-24: Solving the Send bound problem in 2024

Attendance

  • People: TC, nikomatsakis, Josh, tmandry, pnkfelix, scottmcm

Meeting roles

  • Minutes, driver: TC

Objectives of RTN

TC: How do we want to approach this?

tmandry: We should get to the bottom of the objectives and design axioms. I'd like to hear if anything is missing there. And I'd like to hear about alternative approaches and see whether they also achieve those axioms.

Josh: That sounds like a good start. It sounds like tmandry has thought about alternative approaches and doesn't think that any meet those axioms. So I'd like to take a look at the alternatives also. And I too would like to see whether we have any axioms missing. And I'd like to make sure we have the simplest solution that solves the problem.

NM: We should discuss what Josh said yesterday about having a derive macro that made use of unstable internals. If we did that, it should be part of the compiler itself rather than doing it externally.

pnkfelix: How much of the design space should we think about today? Are there any bounds we could set?

tmandry: I don't know how to bound it.

NM: I'd like to limit it to RTN or to some kind of stable derive.

JT: I'd like to explore the possibility of making use of generics for bounds. I'd like to figure out if that's a viable solution.

pnkfelix: That's a hypothetical that may or may not have semantic meaning. We should figure out quickly whether that's plausible and what it means exactly.

TC: That's true, and it seems that we would want Josh to write out a design document with that proposal. Perhaps talking through the axioms here will help Josh cover all of those points in that draft, or, alternatively, will cause Josh to realize that such a design does not cover the desiderata and abandon it.

JT: +1, and to be clear, I am willing to write out a design document.

Design axioms

We believe

Axiom 1

  • Users should be able to write and reuse middleware that passes through its auto traits. Our type system needs to be flexible enough to support this pattern.

tmandry: The passthrough is the key bit here.

NM: The important bit is the conditional element. Being able to say that something is Send if something else is Send.

JT: No disagreement on this point. Clearly this is a primary use case.

Axiom 2

  • Users want uncluttered trait definition and impls that can just say async fn. We want a trait like Service to be as close as possible to trait Service { async fn call() } and to be implementable via something like impl Service for Foo { async fn call() { } }. It's a negative if we have to add extra annotations to make a trait definition widely reusable.

NM: It says what I feel. The most controversial part might be the last part.

JT: The middleware pattern is completely reasonable.

JT: It's worth considering if we can make a much simpler solution that solves a subset of the problem. But if we can make a solution that solves the full problem and is simpler that would be great.

eholk: We want to avoid clutter at the use site also.

JT: I'd like to understand what extra trait annotations would include or exclude.

NM: What I don't want us to do is, e.g., you have to rewrite the trait to use GATs to name each explicit type. I'd like for the natural thing that you'd write to work.

JT: +1 to that. You should not need to use an explicit GAT, or a named return type instead of impl Trait.

pnkfelix: So the trait-bound-as-generic-parameter straw proposal, are we saying that's too burdensome or no?

NM: I see the value of a design that makes some methods Send and some not. A trait may not be able to predict in advance what the clients are going to need.

JT: I'd want to see concrete use cases.

TC: Hit one of those just the other day. I was prototyping async iterator designs. If you write an async iterator that uses AFIT like

trait AsyncFnNextIterator {
    type Item;
    async fn next(&mut self) -> Option<Self::Item>;
}

and you want to map it to an AsyncIterator using poll_next (or to some other trait that you define), you need these types to be named so that you can map it in your impl to other types. When I wrote it, to make it work, I had to change everything to use GATs so I could write this. And I realized, oh, this notation is about way more than Send-bounds, it's about your ability to map things from one trait to another.

(The demonstration is here, and we discussed it in this WG-async thread.)

JT: All those return types, is there more than one?

TC: Not in this case, no, but I was mapping it into an IntoAsyncIterator, and it had multiple GATs.

JT: I hesitate to say that means you need a general way to name it. There's already a need to have Item, isn't that enough?

TC: No, I needed to name the future returned by async fn next. I needed to name both the Item and also the future.

TC: The main point is, we talk a lot about Send bounds, but when I went to write the code, the problem I hit first was not Send bounds at all. The fact that GATifying it solved Send bounds also was just a bonus.

JT: Worth looking closer at, the solution I was augering for is something specifically designed to let you fill in bounds, so you could do the Send thing or the DoubleEndedIterator thing, but it would just be for bounds, not naming the return values in general. If there is a use case for naming the return types of RPIT/AFIT functions and doing more than just placing bounds on them, that's important.

TC: Without these features, it'd surprise me if we stabilized any trait that didn't GAT-ify everything. This gets at what Niko was saying: unless there's an RTN thing that lets the the downstream consumer express it, the only way to write public traits that can be reused in these ways would be to give explicit names to everything.

NM: This is also exactly why we say not to use RPIT in public APIs. I want TAITs, but I do think it's an awkward design that will force people to make names everywhere.

JT: Observation: the RTN design document focused on the problem of applying bounds (e.g. Send). We should be clear about whether we're looking to solve the bounds problem or the more general naming-the-return-types problem.

Axiom 3

  • The "send bound" problem (or a close relative) is just a special case of a more general problem of bounding or naming RPIT return types (especially with traits). Therefore, we'd like a solution that isn't specific to async functions or the trait Send and which permits users to bound specific methods in different ways (not have a single bound that applies to all methods in the trait); ideally it would also generalize to support naming return types from top-level or inherent async functions, though those can be handled with TAITs.

NM: This is what we just talked about.

JT: This is what felt like overgeneralization in the context of just solving the Send bound problem, in the absence of use cases for the more general case of naming RPIT/AFIT return types.

NM: Regarding "permits users" this language is not great. I meant users of the trait, not any user of Rust.

TC: One other thing is that TAIT on its own doesn't solve this. Trait definition still must have GATs in the type (and then impls can use TAIT on the impl side to name return types).

NM: Trait definitions bascially are module interfaces, similar to how languages like ML have first class generic modules.

Josh: Verifying: this doesn't always need GATs, just associated types, which only need to be generic if the function return type was generic, right?

TC: Correct. The experiment did need GATs, but this generalizes to both.

JT: We could separate the AFIT and the RPITIT case. It seems maybe there's a stronger case on AFIT rather than on RPITIT. We already have established that you shouldn't use RPIT in public APIs. Maybe for traits, you should name the types as ATs.

NM: I consider that a limitation of the language.

Axiom 4

  • Send bounds should be easy to read and write and have a clear meaning. This is true even if they are hidden by trait aliases, because those aliases have to be read and written.

JT: Not sure what the alternative would be. Sounds like we have consensus on this.

NM: The devil is in the details of what it means to be easy.

Axiom 5

  • The Rust language shouldn't make opinionated choices about names etc. We'd prefer to avoid encoding conventions e.g., converting method names from snake case to camel-case as part of the language rules themselves. Those kind of conventions are great in API definitions and procedural macros.

JT: This is self-evident.

NM: Note: "s/API definitions/API guidelines/" is what I think I meant to write.

TC: We should consider what happens if we don't do RTN. Then someone, probably me, will immediately write and publish an rtnify proc macro that will rely on conventions about the names. It's not part of the language, but it may become widely adopted in the same way as async_trait was adopted.

JT: Seems fine to me if a proc macro does it, but +1 for the axiom that the language shouldn't.

TC: Once something is as widely adopted as async_trait, it almost becomes part of the language, as people perceive it. Nearly as many crates use async_trait as use RPIT for example.

Axiom 6

  • It is important to move quickly, and therefore we bias against designs that require more than one proposal.

JT: Making this more concrete: to the axiom that we shouldn't do something that requires 3+ steps/RFCs before we have a solution, +1. For the remainder of "move quickly", more-or-less agreed but the devil is in the details.

Follow-on from the above

TC: Josh and I will talk about the concrete example, regarding axioms 2 and 3.

JT: +1.

Consensus on above

JT: We do have consensus on the axioms apart from that detail and apart from potentially missing axioms.

Does the alternative matter?

NM: To what degree is this about the axioms, and to what degree is the blockage here about considering an alternative design?

JT: To me, it's about minimizing complexity. RTN feels like a fairly complex solution. That doesn't necessarily mean that it's not the simplest solution that solves the problem. But if there is a simpler solution that solves the problem, we should consider that. If there isn't a viable simpler solution, then that makes RTN more appealing. But even then, I'd want to talk about whether there are ways to minimize the resulting complexity from RTN. E.g., if users have to rewrite the same bounds over and over again, that seems bad.

JT: That said, RTN is clearly a plausible solution for solving the problem that RTN purports to solve. If we can't make a simpler solution for the problem or a large useful subset of the problem, then we may want to use RTN, but then we should evaluate possible ways to mitigate the user-visible complexity. For instance, we may want to keep RTN unstable and stabilize a proc macro atop it.

TC: To what degree is repeating bounds a general problem in the language and orthogonal to RTN? E.g., if you add bounds to the well-formedness of a struct, you end up having to repeat those bounds everywhere today.

JT: Yes, that's a problem also. (Not sure if the same solution would solve both.)

NM: There is that problem that TC mentioned, and there's also the case that downstream users may need the flexibility to be specific.

JT: If we could solve most of the problem with something atop RTN without stabilizing RTN, then we have to ask whether stabilizing RTN is worth it to solve the additional problems.

NM: It's a common pattern that we block a feature on wondering if there's some hypothetical other thing that might be better. But in this case, we have spent a lot of time considering all of the possibilities before landing on this.

JT: In this case, I'm referring specifically to whether we use proc macros in the language that build on an internal RTN to expose something simpler.

tmandry: I'm not sure what benefit we would get from that. In some sense, we've evaluated all of the considerations.

pnkfelix: If there were a proc macro that let you swap in multiple different solutions underneath the macro, that might justify the macro.

JT: I think the macro is justified even if there's only one implementation underneath it.

JT: As an example, we haven't stabilized specialization, but we have stabilized many things that rely on stabilization underneath. We might put a macro on top of RTN if the full general power of RTN isn't something we ever want to expose to the ecosystem.

TC: Isn't specialization a counterexample? We've been trying to avoid building on specialization because we're not sure that we'll ever stabilize it.

JT: There's probably a better example.

NM: We often hold space just to avoid making decisions. So in this case, it comes down to how much confidence we have that we've explored the entire space.

NM: This also comes back to the general problem of being able to name return types.

NM: If I were going to name the result type of a function, I would want to talk about it in terms of the return type of the function and its parameters.

scottmcm: We actually have type_of reserved for this reason.

JT: That's actually a good argument. I do think that we should have type_of in the language. It may be equivalent to RTN.

TC: type_of is far more powerful than RTN.

JT: Exactly.

NM: There are complexities for type_of.

TC: +1.

scottmcm: I'd like to have a reasonable design for turbofishing RTN, and I'm not sure that it was included in this design.

tmandry: It may not be in the MVP, but it's something for which we're reserving space.

pnkfelix: Arguably async fn itself is an instance of this, as a layer over the underlying coroutine feature that is not stable.

JT: That's a great example. As I understand it, we're also going to desugar gen fn/iter fn to the same underlying mechanism, but there's no proposal to stabilize the underlying coroutine mechanism.

pnkfelix: Josh, as a clarification, you're saying you think we should have stable type_of?

JT: Yes.

pnkfelix: But you're objecting to RTN?

JT: Correct.

pnkfelix: OK.

JT: To be clear, I'm trying to critically evaluate RTN.

tmandry: For RTN, what we've tried to do is thread the needle between complexity and usability. My fear is that we block on this forever.

JT: That's valid.

Feeling stuck

NM: What is the objection?

JT: One is the question of how much do we need to solve the fully general return type problem versus how much do we need to solve the Send bound problem.

JT: The other is about what we want to see propagating throughout the ecosystem in terms of how we want people to write this in the general case.

JT: I'm concerned people would dive into using RTN directly rather than using something higher-level, less complex, and less repetitious.

TC: To what extent does trait-variant represent the right higher-level solution that you're looking for?

JT: Not sure whether that solves the full problem or only part of it.

tmandry: trait-variant doesn't solve the middleware problem. And it doesn't solve the conditional bound problem.

JT: It seems clear that people want a solution to the middleware problem. So a high level solution should solve at least the middleware problem.

TC: Assuming that when we look at the trait mapping example I described, if we agree that the types need to be named and that it's a valid use case, how would that affect your thinking?

(The use case we'll be discussing is here, and we discussed it in this WG-async thread.)

JT: Then we'd probably end up with something like RTN modulo syntax bikeshedding, then we just need to decide whether we'd expose RTN or some subset of it with macros.

NM: I think that's all pretty helpful.

NM: Let's write out the decision tree.

JT:

  • Do we want to solve the fully general "naming the return type" problem, for purposes other than writing bounds on it? Or, do we want to solve the "different bounds on arbitrary methods" problem rather than only inserting bounds in the "obvious" places?
    • If no: Josh would like to explore solutions based on generics (e.g. Handler<Send>, Handler<?Send>).
    • If yes: we probably need something close to RTN, so:
      • Bikeshed RTN syntax.
      • Figure out if we want to expose RTN as stable surface syntax, or just stabilize higher-level macros atop it.

NM: I'm not just concerned about naming return types, I'm also concerned about being able to place arbitrary bounds on different methods rather than only putting bounds in the places the trait author anticipated.

JT: It'd be good to see a use case for where we would want to bound the return type of different methods differently.

NM: There's one thing that I want to check with the people here. The concern about what we propagate across the ecosystem. If we say, for sake of argument, that the best practice is to write a version of your trait that is Sendable, then to what degree are trait transformers, such as represented by trait-variant enough? In a way, I view the proc macro as even riskier than RTN.

NM: Do you agree that the proc macro isn't enough to solve the problem you raise? And how confident are you that a high level solution exists that otherwise solves this?

JT: It's clear trait-variant isn't enough as it doesn't solve the middleware problem.

tmandry: It would solve the middleware pattern if we stabilized RTN.

JT: Could it do that if we didn't stabilize RTN?

tmandry: Sure, if we also built trait-variant into the standard library.

JT: I'm hesistant to agree with what NM said about the proc macro being less safe to stabilize. I think it's OK if we stabilize something that solves only a subset of the problem and we have to do more things later.

NM: I would hope that we'd deprecate it then.

NM: I'd actually prefer to stabilize primitives first and then stabilize the high level things that we want after we understand the high level use cases better. We've unfortunately done it the other way often.

TC: Async is a case where we definitely stabilized the primitives first. RawWaker, e.g., is very low lever, and recently we've stabilized higher-level things such as the Wake trait after we better understood the common use cases.

Josh: I do think in this case we should focus on the high-level interfaces, and consider the suitability of the low-level primitives to the extent they support those.

pnkfelix: On the issue of whether the trait author understands what the downstream use cases are, I can imagine that there are use cases for both. I still think that we will want RTN.

TC: NM, wasn't one of your axioms that downstreams should be able to "solve their own problems"? It is hiding in the axioms we discussed here, but perhaps we should be more explicit about that.

NM: Indeed.

Josh: I've also attempted to capture that as an axiom below.

tmandry: I'm trying to write down the high level points we may want to cover.

Questions that weigh on the decision

tmandry:

  • Will users want to customize bounds by bounding different subsets of types in the trait?
    • Can we expect trait authors to anticipate all the places users will want to place additional bounds within their traits?
  • Do we need to support naming return types in places other than bounds, e.g. in struct fields?
    • Do we need to support bounding the opaque return type of the method on a trait to an arbitrary generic parameter in an impl?

tmandry: What other big questions do we have?

(None were raised.)

(Some discussion of the AsyncFnNextIterator -> AsyncIterator use case followed.)

TC: E.g., here is what the mapping looks like specifically:

// All `AsyncFnNextIterator` types implement `IntoAsynciterator`.
impl<'s, I, T: 's, F> IntoAsyncIterator<'s> for I
where
    I: AsyncFnNextIterator<Item = T, NextFuture<'s> = F> + 's,
    F: Future<Output = Option<T>> + 's,
{
    type Item = T;
    type IntoAsyncIterator = ConvertedAsyncIterator<'s, I, F, T>;
    fn into_async_iterator(self) -> Self::IntoAsyncIterator {
        ConvertedAsyncIterator {
            iter: self,
            fut: None,
            _p: PhantomData,
        }
    }
}

(The full demonstration is here.)

(The meeting ended here.)

Potentially missing design axioms

Josh Axiom 7

Josh: The solution should handle bounds that the trait author did not anticipate. For instance, it should not exclusively work for Send. (This axiom does not say that the solution should handle placements of bounds that he trait author did not anticipate.)

Josh: We should provide the simplest design that solves the problem, without overgeneralizing.

Josh: To the extent possible, we should leverage existing mechanisms rather than create new ones.

Josh: We should stabilize the minimum necessary to solve the problem, which does not necessarily imply stabilizing the underpinnings that that desugars to.

Non-send bounds examples

scottmcm: To pnkfelix's question earlier, I was thinking of things like:

// some other crate
fn do_the_thing() -> impl Iterator<Item = String> {}

// in my code
struct State {
    it: ty#do_the_thing(),
    ...
}

where it's more like sugar for a used-once TAIT.

Or for the "be conditional based on the input", something like:

fn my_map<T, U>(
    x: impl Iterator<Item = T>, f: impl FnMut(T) -> U,
) -> impl Iterator<Item = U> + (ty#x is ExactSizeIterator ? ExactSizeIterator : ε) {}

Josh: I would argue that the first case is an instance of "don't use -> impl Trait if you want to name the return type", and I don't inherently think that's a problem.

scottmcm: Well I didn't write that impl Trait code, so I didn't make that decision. So it's possible by making a local TAIT, but used-once-TAIT having syntax seems pretty reasonable to me, especially since it avoids the user needing to think about inference.

Thought on variable-RTN?

scottmcm: Random idea that came up, would RTN lead to something like:

fn min(x: impl Ord, y: x) -> x {}

using the same "well it's a different namespace so it means the type of the thing".

(This kind of extension makes me tempted to say that in general it should have more syntax, perhaps only outside of associated type bounds, than what I remember from the current proposal.)