rust-lang / rust

Empowering everyone to build reliable and efficient software.
https://www.rust-lang.org
Other
99.03k stars 12.79k forks source link

Tracking Issue for `try_trait_v2`, A new design for the `?` desugaring (RFC#3058) #84277

Open scottmcm opened 3 years ago

scottmcm commented 3 years ago

This is a tracking issue for the RFC "try_trait_v2: A new design for the ? desugaring" (rust-lang/rfcs#3058). The feature gate for the issue is #![feature(try_trait_v2)].

This obviates https://github.com/rust-lang/rfcs/pull/1859, tracked in https://github.com/rust-lang/rust/issues/42327.

About tracking issues

Tracking issues are used to record the overall progress of implementation. They are also used as hubs connecting to other relevant issues, e.g., bugs or open design questions. A tracking issue is however not meant for large scale discussion, questions, or bug reports about a feature. Instead, open a dedicated issue for the specific matter and add the relevant feature gate label.

Steps

Unresolved Questions

From RFC:

From experience in nightly:

Implementation history

Stargateur commented 2 years ago

It's look like curent FromResidual is just the From trait. Why do we need this trait if From is mostly the same ? Unless I miss something they shouldn't have a problem with conflit implementation cause a crate that would have a type that implement Try would by definition own the type.

mbartlett21 commented 2 years ago

@Stargateur Unfortunately, that causes conflicts with using ! in the residual type:

#![feature(never_type)]

pub trait From2<T>: Sized {}

// Default impl in the standard library
impl<T> From2<T> for T {}

// Conflicts with the impl above, since T can be !
impl<T> From2<Option<!>> for Option<T> {}

// Conflicts with the impl above, since T can be !, and F can be E
impl<T, F: From2<E>, E> From2<Result<!, E>> for Result<T, F> {}
Errors ``` error[E0119]: conflicting implementations of trait `From2>` for type `Option` --> src/lib.rs:9:1 | 6 | impl From2 for T {} | ---------------------- first implementation here ... 9 | impl From2> for Option {} | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ conflicting implementation for `Option` error[E0119]: conflicting implementations of trait `From2>` for type `Result` --> src/lib.rs:12:1 | 6 | impl From2 for T {} | ---------------------- first implementation here ... 12 | impl, E> From2> for Result {} | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ conflicting implementation for `Result` For more information about this error, try `rustc --explain E0119`. ```

(see playground)

scottmcm commented 2 years ago

Marking design concerns because there's still on-going discussion here about trait structure. I don't think there's concerns about the general idea of this (coloured residuals, matching on controlflow, etc), so I don't think there's foundational concerns here, but future work on try blocks might also want to add a few more bounds on associated types here.

(Basically this is the best S-tracking fit, since it's implemented but doesn't seem exactly ready-to-stabilize yet.)

cuviper commented 2 years ago

Is it possible to stabilize some subset, like enough to write your own Iterator::try_fold, while leaving those design concerns open in unstable space? You wouldn't be able to impl Try for ... until it's finished, but maybe we can use it a little more...

I think Try::{self, Output, from_output} would be enough, and of course the ? operator we've long had.

cuviper commented 2 years ago

I think Try::{self, Output, from_output} would be enough, and of course the ? operator we've long had.

Here's what it looks like for rayon's implementation of try_* methods to use just that subset: https://github.com/rayon-rs/rayon/compare/master...cuviper:rayon:unstable-try

Stargateur commented 2 years ago

Is it possible to stabilize some subset, like enough to write your own Iterator::try_fold, while leaving those design concerns open in unstable space? You wouldn't be able to impl Try for ... until it's finished, but maybe we can use it a little more...

This trait is core to the Rust language, I think it shouldn't be rushed. Specially https://github.com/rust-lang/rust/issues/84277#issuecomment-1066120333 suggest change that are not compatible with your suggestion, we should test it before commit to anything.

cuviper commented 2 years ago

I agree it shouldn't be rushed, but I'm trying to give it some forward momentum. This stuff has been around in stable use for a long time -- ? since 1.13, try_fold since 1.27 -- so I don't think anyone can accuse it of moving too quickly. Right now we seem to be stuck in a mostly-bikeshed limbo, although there are some functional aspects still being hammered out too. "Perfect is the enemy of good," but I hope an interim goal like "make it possible to write Iterator::try_fold" will help make progress.

Specially #84277 (comment) suggest change that are not compatible with your suggestion

In that proposal, we would need Try, Try::wrap (rename of from_output), and access to the Branch::Continue associated type -- I think you can still name that in constraints like Try<Continue = something> or T: Try, T::Continue: Debug, without directly naming Branch. (I know that works with Iterator subtraits for naming Item, at least.)

scottmcm commented 2 years ago

I do like the interim goal!

I'll note that it's actually possible to (indirectly) get to Try::from_output on stable today:

macro_rules! try_from_output {
    ($e:expr) => {
        std::iter::empty::<std::convert::Infallible>()
            .try_fold($e, |_, x| match x {})
    }
}

https://play.rust-lang.org/?version=stable&edition=2021&gist=9c3540cb33148314790bd75840aab638

Another option for letting people call it without stabilizing trait details would be to stabilize try{} blocks -- that's actually how the standard library calls it:

https://github.com/rust-lang/rust/blob/e5682615bb4fdb90e3a37b810a1b7bded2a1199e/library/core/src/iter/traits/iterator.rs#L2240

(That was convenient for the v1 → v2 change as it meant the library code didn't need to change, just the try{} desugaring in the compiler.)

So I think the core thing that'd be needed for an interim bit is just a way to let people write the equivalent of R: Try<Output = B>. One version of that we could consider is adding something like

// in core::iter

pub trait TryFoldReturnType<Accumulator> = Try<Output = Accumulator>;

so that users can write

    fn try_fold<B, F, R>(&mut self, init: B, mut f: F) -> R
    where
        Self: Sized,
        F: FnMut(B, Self::Item) -> R,
        R: TryFoldReturnType<B>,
    {

and if we change how that's implemented later, great. (We could even deprecate or doc-hide it later, should another option become available, like we did with https://doc.rust-lang.org/std/array/struct.IntoIter.html#method.new.)

Idea written as trait alias for communication simplicity. It'd probably want to be a trait with a blanket impl and an unstable method so it can't be implemented outside core, or similar, if we wanted to actually do this, because we probably don't want to expose trait aliases to stable consumers just yet.

Stargateur commented 2 years ago

"make it possible to write Iterator::try_fold" will help make progress.

I don't understand what you mean by that, you can use ControlFlow of std that is stable.

Edit: Ah yes the Try trait for the generic.

"Perfect is the enemy of good,"

I agree I just think naming is important, the feature of current Try is fine for me.

cuviper commented 2 years ago

"make it possible to write Iterator::try_fold" will help make progress.

I don't understand what you mean by that, you can use ControlFlow of std that is stable.

I mean for your own custom type implementing Iterator, it should be possible to write a custom version of the try_fold method, just like we do for the implementations for Chain, FlatMap, etc. At minimum, that means you must be able to match the constraints of that method, R: Try<Output = B>.

Stargateur commented 2 years ago

I used Try in a project and there is one thing that I would like but I don't think it's possible. I also don't know if it's needed. My problem is that like iterator I end up have duplicate function, one variant for thing with Try and thing without. My concern is both about user experience and performance. It would be nice that we could impl Try<Residual = Infallible> for every T. I think is impossible since we would end with conflict implementation. I end up with something like this:

#![feature(try_trait_v2)]

use std::convert::Infallible;
use std::ops::{ControlFlow, FromResidual, Try};

pub struct NoFail<T>(pub T);

impl<T> FromResidual for NoFail<T> {
    fn from_residual(_: Infallible) -> Self {
        unreachable!()
    }
}

impl<T> Try for NoFail<T> {
    type Output = T;
    type Residual = Infallible;

    fn from_output(output: Self::Output) -> Self {
        Self(output)
    }

    fn branch(self) -> ControlFlow<Self::Residual, Self::Output> {
        ControlFlow::Continue(self.0)
    }
}

enum Foo<T, E> {
    T(T),
    E(E),
}

impl<T, E> FromResidual<Infallible> for Foo<T, E> {
    fn from_residual(_: Infallible) -> Self {
        unreachable!()
    }
}

impl<T, E> FromResidual for Foo<T, E> {
    fn from_residual(foo: Foo<Infallible, E>) -> Self {
        match foo {
            Foo::T(_) => unreachable!(),
            Foo::E(e) => Foo::E(e),
        }
    }
}

impl<T, E> Try for Foo<T, E> {
    type Output = T;
    type Residual = Foo<Infallible, E>;

    fn from_output(output: Self::Output) -> Self {
        Foo::T(output)
    }

    fn branch(self) -> ControlFlow<Self::Residual, Self::Output> {
        match self {
            Foo::T(t) => ControlFlow::Continue(t),
            Foo::E(e) => ControlFlow::Break(Foo::E(e)),
        }
    }
}

fn foo<T, U, E>(u: U)
where
    U: Try<Output = T>,
    Foo<T, E>: FromResidual<U::Residual>,
{
}

fn main() {
    foo::<i32, _, ()>(NoFail(42));
    foo::<i32, _, ()>(42);
}

From the user point of view that not better than Ok(42), from a performance point of view I don't know, I expect Rust is able to optimize a use of Try that can't fail. I would like to know if it's possible to write foo::<i32, _, ()>(42);.

My second question is: what is the recommandation about duplicate code, should we have two variants for most function or should we only propose the "Try" version ? It's look similar to async problem with "What color is your function ?".

Victor-N-Suadicani commented 2 years ago

I haven't followed the discussion closely, but I have just run into a situation where I think this would help.

Basically I have an enum Response of possible responses from a server, including successful responses and error responses. It would be great if I was able to use the ? operator to easily "short circuit return" when certain other function return errors. For instance, if I get a Err(e) of some certain type Result<T, E>, I would like ? to map that to a certain variant in my response enum and return that (while an Ok(t) should just continue execution).

A temporary workaround that my team is currently using is the somewhat bizarre type of Result<Response, Response>. This allows one to use the ? operator to return a response of a certain variant in case of Err but also return a response in case of Ok. However you then have to unwrap_or_else(|e| e) the result in order to get either the Ok or Err response out.

Again, haven't followed closely but would love if this feature would allow this use case to simply use Response as a return type rather than Result<Response, Response>! :)

Stargateur commented 2 years ago

A temporary workaround that my team is currently using is the somewhat bizarre type of Result<Response, Response>. This allows one to use the ? operator to return a response of a certain variant in case of Err but also return a response in case of Ok. However you then have to unwrap_or_else(|e| e) the result in order to get either the Ok or Err response out.

I would advice if you don't want use nightly to split you Response into two, one for Ok, one for Err. If you have several ok value or err value use two enums.

enum ResponseOk { Todo };
enum ResponseErr { Todo };

then use Result<ResponseOk, ResponseErr> that way better than your current approach and will fit nicely when Try trait is stable. If you end up saying "but sometime a response can be either Ok or Err" then the try trait is probably not what you seek (thus I could be wrong here).

Victor-N-Suadicani commented 2 years ago

Unfortunately the Response type is an enum coming from autogenerated code that is generated by prost, i.e. a Protocol Buffers type definition. So changing it is not so easy.

... then use Result<ResponseOk, ResponseErr> that way better than your current approach and will fit nicely when Try trait is stable.

Would the try trait not be able to handle an enum like Response? For instance, imagine this:

enum Response {
    Success,
    NotFound,
    InvalidRequest,
    InternalError,
}

With the try trait fully part of the language, would I not be able to write short-circuiting return behavior for this type, using the ? operator? If not, I'd be kinda worried about the design of the trait as I would think this use case would be the whole point almost. I mean, it's just a Result with more than two possibilities. I feel like that should be handled by this feature.

I think I'm okay with using the Result<Response, Response> workaround until the try trait lands (assuming it would work for my use case).

heaths commented 2 years ago

@scottmcm asked me to share my experience with using these traits. I've been writing some Rust for a few years, but not as comfortable with it as with most other languages I know, but maybe that's useful.

My goal was to allow devs to use the ? operator with any error type to convert to a u32 (more specifically, a UINT or unsigned int in C / Win32) as seen here: https://github.com/heaths/msica-rs/blob/db652f4c85123171c9f0f6acf406547655934f13/src/errors.rs#L153-L160. Windows Installer custom actions all have a (C) form of extern "C" UINT FuncName(MSIHANDLE hSession). Supported error codes from a custom action are limited, with all other values effectively being mapped to 1603: https://github.com/heaths/msica-rs/blob/db652f4c85123171c9f0f6acf406547655934f13/src/errors.rs#L197-L203.

It wasn't too hard to discover these traits, but it wasn't clear what a "residual" was ("output" was obvious) and needing a newtype for a NonZeroU32 was initially nonobvious; though, it made since once I realized 0 shouldn't result in an Error so should be an otherwise impossible case. It wasn't until I found https://rust-lang.github.io/rfcs/3058-try-trait-v2.html#implementing-try-for-a-non-generic-type that all the pieces started falling into a place. A good example like that might be good on either (or both) of the traits.

Once implemented FromResidual for my custom error type, I was still not able to figure out how to return a u32 (effectively) for any error. This is still an ongoing problem. To fulfill my goal, I just want any error to ideally map to one of the handful of values a custom action can return, or at the very least just return 1603 (ERROR_INSTALL_FAILURE). Though not ideal, at least a workaround exists: since I implement From<std:error::Error> for my Error type (effectively like #[thiserror::from]), one could do:

CString::new("t\0est").map_err(|err| err.into())?

Having to map all errors is less than ideal, though.

~One nit: since this largely seems related to mapping std::result::Result<T, E> to some arbitrary output, it seems that std::ops::ControlFlow<B, C> should clip the C (Continue) and B (Break) type arguments, which seem to better align with <T, E>; though, this may simply be my own misunderstanding of how this enum is intended to be used.~ (@scottmcm explained why)

That said, both ControlFlow::Break and ControlFlow::Continue enums seem very intuitive, as was Try::Output. It was only Try::Residual (and, by extension, FromResidual itself) that were not. Reading through the motivation in the RFC, would something related to "error" or even "break" make more sense? In the RFC, the Try trait section reads,

The residual that will be returned to the calling code, as an early exit from the normal flow.

So some word more closely related to "error", "break", or even "exit" might help understanding it better.

RagibHasin commented 2 years ago

May I suggest Unexpected as an alternative to Residual? After reading through the RFC thoroughly (twice and early on), I think Residual conveys the meaning best, although English is not my first language. But the rationalization for Residual is a little hard to follow. Unexpected might be a better choice there because that is easier to model mentally, and I haven't seen it suggested before.

tmccombs commented 2 years ago

I think Unexpected has the same problem as Error, where it can make it awkward to use it in places where it is actually expected that it is used. Perhaps "Exceptional" would work better, as it is an exception to the normal flow? But then you might have some baggage from exceptions in other languages.

SafariMonkey commented 2 years ago

The RFC cites the Oxford definition as:

a quantity remaining after other things have been subtracted or allowed for

That aligns with my definition, but to me the intuitive interpretation was "extracting the diverging cases and leaving the remaining cases to continue execution", thus outputting (returning) the diverging type and continuing execution with what remained, the residual. If this is specifically for the ? operator, could one use more explicit and less confusable term like Diverging?

heaths commented 2 years ago

As a follow-up to my experience above wherein my goal was to allow for any error to effectively return ERROR_INSTALL_FAILURE (1603), I wanted to support the case where a callee could return a wrapped u32.

The most obvious way was with specializations as seen here: https://github.com/heaths/msica-rs/pull/22. I'm considering not supporting this case, though, because I think the use cases are small enough not to warrant yet another unstable feature that seems even less likely to stabilize.

Perhaps to facilitate handling specific residuals but falling back to more general one, could core at least support specialization for cases like this? It seems that's the direction being considered, that only core could use specialization because of unsound behaviors in general.

alercah commented 2 years ago

It sounds like both @Victor-N-Suadicani's example and @heaths's example are cases where the desire is for the actual result type to be different than the type that provides the FromResidual implementation.

I ran into a similar problem when trying to write a type that would allow x? to panic on failure (effectively becoming sugar for unwrap()). There needs to be some type Unwrapped<T> providing FromResidual<E: Display> so that from_residual can panic. But this unfortunately means that Unwrapped<T> needs to be actually exposed in the function signature. Playground link.

In all these cases, effectively there is no distinction between an actual output return and a residual return.

Another use case I can think of is defaulting. Imagine:

struct OrDefault<T>(T);

impl<T: Default> FromResidual<Option<!>> for OrDefault<T> {
  fn from_residual(o: Option<!>) -> OrDefault<T> {
    Some(Default::default())
  }
}

It seems to me that the core disconnect here is that the return type of the function is expected to contain the error handling logic, but these kinds of use cases show that this isn't true. There are multiple plausibly valid ways to convert between, say, Option<!> and T, or Result<_, !> and T. And the existing syntax has no way to disambiguate between them, so there is no way it can possibly work.

So I think the only reasonable way to do this kind of think is with more syntax, probably an inner try block or some sugar, but I'm not even sure how one would annotate the types to make that work.

GoldsteinE commented 1 year ago

We have GATs now, so maybe we can make the design less awkward? Current type for std::array::try_from_fn doesn’t fill me with joy, and it seems like it could be more readable:

https://play.rust-lang.org/?version=nightly&mode=debug&edition=2021&gist=51e5635ce77b7cc3672d8c69223da5fb

scottmcm commented 1 year ago

@GoldsteinE I wrote some notes about why I didn't make it a GAT over in Residual's tracking issue, https://github.com/rust-lang/rust/issues/91285#issuecomment-1308246080

GoldsteinE commented 1 year ago

@scottmcm That's reasonable. I feel like current signatures of try_ functions seriously hinder learnability though. Maybe we could have additional trait with blanket impl (as in my playground) or some other kind of sugar?

Additional thought: I think people would probably write their own try_ function more often than new Try types and it shouldn't require to write this whole incantation every time (even the standard library doesn't do it — there's a private type alias to make it easier).

rinarakaki commented 1 year ago

Sorry to interrupt but a quick question. Can this trait Try be automatically implemented from Default?

liigo commented 1 year ago

So some word more closely related to "error", "break", or even "exit" might help understanding it better.

premature, slink

mitsuhiko commented 1 year ago

I want to make the argument to unwind most of the residual based try API. I do not know how much can be unwound, and I not know how much should, but here is why I'm starting to lean towards Try in this form being a mistake.

Let me state that I really appreciate being able to use ? on Option and Result, and I also agree with the desire that this type of functionality is interesting to have for custom types as well. If the only need was to have these traits be some internal abstractions for that operator I would probably not think that much about it.

However some of the APIs that are currently making their way into the nightly builds are really horrible in terms of type definitions. The original Iterator::try_fold doesn't read all that bad:

fn try_fold<B, F, R>(&mut self, init: B, f: F) -> R
where
    Self: Sized,
    F: FnMut(B, Self::Item) -> R,
    R: Try<Output = B> { ... }

Unfortunately some of the newer APIs require much more involved bounds. A pretty bad example is try_collect and try_reduce:

fn try_collect<B>(
    &mut self
) -> <<Self::Item as Try>::Residual as Residual<B>>::TryType
where
    Self: Sized,
    Self::Item: Try,
    <Self::Item as Try>::Residual: Residual<B>,
    B: FromIterator<<Self::Item as Try>::Output> { ... }

fn try_reduce<F, R>(
    &mut self,
    f: F
) -> <<R as Try>::Residual as Residual<Option<<R as Try>::Output>>>::TryType
where
    Self: Sized,
    F: FnMut(Self::Item, Self::Item) -> R,
    R: Try<Output = Self::Item>,
    <R as Try>::Residual: Residual<Option<Self::Item>> { ... }

As a somewhat seasoned Rust developer it takes me too long to understand what is going on here, and it is even harder to explain to someone not particularly familiar with Rust. The Residual stuff also makes itself out via some compiler errors (even on stable) and I doubt that users are not at least in parts confused:

1 | fn foo() -> Result<(), ()> {
  | -------------------------- this function returns a `Result`
2 |     Some(42)?;
  |             ^ use `.ok_or(...)?` to provide an error compatible with `Result<(), ()>`
  |
  = help: the trait `FromResidual<Option<Infallible>>` is not implemented for `Result<(), ()>`
  = help: the following other types implement trait `FromResidual<R>`:
            <Result<T, F> as FromResidual<Result<Infallible, E>>>
            <Result<T, F> as FromResidual<Yeet<E>>>

Why do I need to know what a FromResidual is? The documentation for all of this machinery also does not really do a good job at explaining it either. This is the documentation of the associated Residual type on Try (with questions of mine added in italics):

The choice of this type is critical to interconversion (why?). Unlike the Output type, which will often be a raw generic type, this type is typically a newtype of some sort to “color” the type so that it’s distinguishable from the residuals of other types. (why? it does not explain the motivation)

This is why Result<T, E>::Residual is not E, but Result<Infallible, E> (again, why? This does not explain the motivation at all). That way it’s distinct from ControlFlow<E>::Residual (where is ControlFlow all the sudden coming from?), for example, and thus ? on ControlFlow cannot be used in a method returning Result (None of this is obvious).

If you’re making a generic type Foo<T> that implements Try<Output = T>, then typically you can use Foo<std::convert::Infallible> as its Residual type: that type will have a “hole” (What is a hole? Why do I need one?) in the correct place, and will maintain the “foo-ness” (What is foo-ness?) of the residual so other types need to opt-in to interconversion. (You completely lost me here)

Obviously with some reading on other parts you can piece together all of this, but man this is a complex system for the supposed simple operation of "unwrap success, propagate and convert error on error".

BurntSushi commented 1 year ago

@mitsuhiko I'm not sure if I necessarily agree with the conclusion, but I am definitely quite annoyed with the type signatures too. Another example is OnceCell::get_or_try_init. Today, its type signature is this:

    pub fn get_or_try_init<F, E>(&self, f: F) -> Result<&T, E>
    where
        F: FnOnce() -> Result<T, E>,

I can pretty much read that almost instantly. It does have generics, but they are succinct and pattern match to extremely widely used patterns.

But in PR #107122, it has been proposed to make this generic to support Try. The proposed type signature is now:

    pub fn get_or_try_init<'a, F, R>(&'a self, f: F) -> <R::Residual as Residual<&'a T>>::TryType
    where
        F: FnOnce() -> R,
        R: Try<Output = T>,
        R::Residual: Residual<&'a T>,

My eyes instantly gloss over. I've been using Rust for almost ten years, and I can barely read this.

It is possible that these sorts of signatures will become so ubiquitous that our eyes will learn to pattern match them. But I'm skeptical. There is a lot going on here. There's three traits. Associated types. Weird as syntax in the type the language. Oodles of symbols. It just does not lend itself to being comprehensible. And even worse, as a member of libs-api, it's really hard for me to even know whether this type signature is correct. Is it covering all the use cases it is intended to correctly? Is it missing any? What are the backcompat hazards? These are all very tricky questions to answer.

Now, the middle road between the complaints I'm lodging here and the Try trait more generally is that we don't actually have to go through and make every one of these methods generic on Try. The problem with that sort of road is that it's like running up an ever increasing hill.

TCROC commented 1 year ago

I'm new to rust and have been very much enjoying what I've seen so far. I love the ? operator. I was quite disappointed that I couldn't use it on Option. Then I stumbled across this issue :)

Can someone summarize for me and others that are new to the language like me why adding ? support to Option is so challenging? What are the edge cases that we are struggling to overcome?

From the perspective of a new and naive user, it seems like converting to Error should be simple enough. We already have ok_or and ok_or_else. It seems like we could just bundle that with ?.

(Again I'm naive and new :). I'm sure it's more complex than that)

CAD97 commented 1 year ago

The new API generalization wordiness all comes from ops::Residual moreso than Try directly, though the two are of course entwined. It's also worth noting that there's an alias used that's not exposed in rustdoc which makes the signatures a bit clearer and a bit less cluttered:

fn try_collect<B>(&mut self) -> ChangeOutputType<Self::Item, B>
where
    Self: Sized,
    <Self as Iterator>::Item: Try,
    <<Self as Iterator>::Item as Try>::Residual: Residual<B>,
    B: FromIterator<<Self::Item as Try>::Output>,
;

Just for interest, here's how it could look with associated type bounds:

fn try_collect<B>(&mut self) -> ChangeOutputType<Self::Item, B>
where
    Self: Sized,
    Self::Item: Try<Residual: Residual<B>>,
    B: FromIterator<Self::Item::Output>,

It's still dense, but a lot less noisy; in my opinion it's noticeably clearer what the signature is saying. (Assumes the <Self::Item as Try>::Output associated item disambiguation could be made unnecessary.)

The difficulty fundamentally comes from wanting to rebind to the same try "kind" (i.e. Result versus Option). Without trying so hard to prevent this, the signature can become a lot easier to look at, even without associated type bounds:

fn try_collect<B, R>(&mut self) -> R
where
    Self: Sized,
    Self::Item: Try,
    R: Try<Output = B, Residual = Self::Item::Residual>,
    B: FromIterator<Self::Item::Output>,
;

The problem is that this kills type inference, for a related reason to why try blocks are currently nearly unusable without extra type annotation: you can't derive the output "try kind" uniquely anymore. Rust isn't really able to talk about Result rather than specifically Result<T, E>; that's where the residual comes from, as we can talk about Result<!, E>. (try blocks additionally suffer from error conversion meaning the error type isn't known uniquely until further constrained.)

Working backwards from the error, what might we ideally want it to look like? Perhaps:

1 | fn foo() -> Result<(), ()> {
  | -------------------------- this function returns a `Result`
2 |     Some(42)?;
  |             ^ use `.ok_or(...)?` to provide an error compatible with `Result<(), ()>`
  |
  = help: the trait `Try<Option<_>>` is not implemented for `Result<(), ()>`

(FWIW, I think the offering of other implementations here is just wrong. It depends on what you consider the polarity of the error to be: that the return type can't receive )

That could give us the trait shape of:

pub trait Try<R> {
    type Output;

    fn from_output(output: Self::Output) -> Self;
    fn branch(self) -> ControlFlow<R, Self::Output>;
}

I chose R to signify the "Return" type of the try unit, but if it weren't for the overwhelming std style of single-letter type generics, I'd probably call it Break. I think this supports all of the current cases,

(very shorthand) ```rust // stable, kind preserving impl Try, Output = C> for ControlFlow; impl Try, Output = T> for Option; impl Try, Output = T> for Result; impl Try>, Output = Poll> for Poll>; impl Try>>, Output = Poll> for Poll>>; // unstable, kind preserving impl Try, Output = T> for Ready; // unstable, kind changing impl Try, Output = T> for Ready; impl Try, Output = !> for Yeet<()>; impl Try Output = !> for Yeet; // stable, kind changing impl Try, Output = Poll> for Poll>; impl Try, Output = Poll> for Poll>>; impl Try>, Output = T> for Result; impl Try>>, Output = T> for Result; ``` I do actually quite like how the purpose of each impl is immediately clear, as opposed to e.g. `Ready` having a residual of `Ready` and you having to know that `Poll<_>: FromResidual>` to assemble the real functionality there. It also makes things more deliberate. `Poll` reused `Result`'s residual and I'm not certain the result was intentionally bidirectional. When utilizing residuals, any other (potentially downstream) types which decide to take the easy approach of using `Result` as a residual instead of defining a fresh one will now _all_ be interchangable by `?`, corresponding to a huge cross product of impls in this flattened `Try` representation, which could be desirable, but I believe isn't. This does seem nicer on the surface, -----

but it doesn't really help at all with the rebind case or try, and in fact makes it significantly worse, since now you need to know the "try output" type in order to determine what the output type of e? is.

Knowing the output type directly from e? is a major reason the residual type is associated with. The counterparty is thus to embrace the rebind and throw GATs at the problem, e.g.

pub trait Try {
    type Output;
    type Break<T>;

    fn from_output(output: Self::Output) -> Self;
    fn branch<T>(self) -> ControlFlow<Self::Break<T>, Self::Output>;
}

but now we've overcorrected and thrown the whole ball of GAT complexities into the mix, plus heavily cut back on actually useful expressivity:

With the hindsight of the difficulties try kind conversion and error conversion bring, maybe it would have been better if the Poll kind adjustment was a transpose method and error conversion required .map_err(Into::into). However, error conversion is a huge part of what makes ? convenient to use (even though uses often end up with a .context() style conversion attached anyway), and kind conversion has its motivating examples as well (e.g. a manually packed version of Result for specific T, E).

The last option is to go back towards v1 (using the do yeet terminology):

pub trait Try {
    type Output;
    type Yeet: Yeet<Self>;

    fn from_output(output: Self::Output) -> Self;
    fn branch(self) -> ControlFlow<Self::Yeet, Self::Output>;
}

impl Try<Output = T, Yeet = E> for Result<T, E>;

and perhaps it's okay that this results in being able to ? between Option and Result<_, ()> since you can do yeet () to both, but this still does nothing for the rebind case that ops::Residual exists for.

So I don't think it's possible to cover try without three traits unless you're willing to drop functionality. You need Try to split the control flow, FromResidual to catch the broken value into a different type, and Residual to be able to rebind Try types to different output types. Only the functionality of the third isn't stable already, and it's the most foreign concept to anything currently stable (as it exists only to provide a type level mapping rather than any functionality of the impl type).

I'm quite generic-pilled, and to me the current shape seems fine, although the naming and documentation could certainly be improved. I don't have any good naming suggestions, though. My only remaining relevant observation is that the Residual type/name does feel intrinsically linked to do yeet, but the two are distinct concepts, since you do yeet the data carried by the residual.

Perhaps the answer is to (improve the docs for Try and) drop ops::Residual and any dependent functionality, either solving its problem space with keyword generics/effects or not at all.

mitsuhiko commented 1 year ago

Besides the type bounds I think part of the problem here is that "residual" is also an incommon English word that doesn't mean much to non native speakers. I would not be surprised if you say "residual" the only association that people have is the word "waste". But I don't think that is the biggest issue here.

and perhaps it's okay that this results in being able to ? between Option and Result<_, ()> since you can do yeet () to both, […]

On it's own I think being able to go from Option to Result<_, ()> both makes sense and is quite convenient. If that's the main restriction we are dealing with here, I would not have assumed that this needs to be resolved.

but this still does nothing for the rebind case that ops::Residual exists for.

Which I'm not sure what this is trying to address so I cannot give a comment here to it. My observation is that as an outside observer, I do not understand anything what's going on here and I hope I do not have to do a PHD in types to use some of those APIs.

I think the right way to go about this is:

  1. What's the simplest API that only addresses Option and Result
  2. What extra API is needed to allow abstraction over more types
  3. If the complexity for 1. is significantly easier than 2., maybe 2. should not be done.

I can come up with a bunch of quite trivial ways to go about 1, I don't think I could go about 2.

CAD97 commented 1 year ago

\2. is already done and stable with Poll, though, so not doing it isn't an option.

Rebinding refers to the use in e.g. try_collect, taking some try type (e.g. Result<T, E>) and a new output type (e.g. Vec<T>) to determine a different try type (e.g. Result<Vec<T>, E>).

ops::Residual is only used for rebinding. The FromResidual mechanism isn't all that complicated in actuality, it's just somewhat difficult to explain. It's no more complicated than other uses of newtype wrappers, just obscured a bit by the use of ! rather than fresh types.

mitsuhiko commented 1 year ago

That’s why I’m wondering how much could even be unwound. I’m not convinced that there is no path to undo the damage caused by Poll if there was a desire to do so. The treatment of into_iter on arrays comes to mind which had a creative solution that allowed a change that was assumed to be not possible.

fogti commented 1 year ago

One case of concern to me is that this deeply bakes in what feels like an escape hatch for Result, namely the possibility of automatic error conversion via From, which makes type conversions and re-bind prevention imo harder to reason about. I would prefer it if this aspect were realized in a way that doesn't hamper type inference as much (or perhaps make it completely optional via edition and put that abstraction into a completely separate trait). That is, this basically folds multiple conceptually distinct conversions together, but the abstraction doesn't seem particularly fitting (the problems with type inference also make that difficult to use in most cases that involve From, and to me it seems that that should be solved separately if possible (perhaps via syntactic sugar for From::from or such))

glaebhoerl commented 1 year ago

Do we need to use the same set of traits for governing the built-in language features (?, try, hypothetical yeet) and for expressing abstractions in library code (try_collect() and company)? If we don't do that, could it reduce the complexity/requirements load on either or both? I'm reminded of the way we have Deref, AsRef, and Borrow as well, which has some downsides, but potentially some upsides also.

(That is, it would carry the tradeoff of further proliferating traits; but you would only have to interact with the ones relevant to your current use case. This was meant as an actual question for my own edification, not a thinly veiled pointed suggestion.)

alilleybrinker commented 1 year ago

If I may summarize some of the discussion here, in part for my edification, it sounds like there are several distinct issues being discussed concurrently:

1. Use of the word "residual" in the API

The naming of "residual" for "the thing that gets passed up to the caller with the ? operator." This naming is shown in the Residual associated type on Try, and in the FromResidual trait. The central concern about the name is that it may not be immediately clear, especially to non-native English speakers, what the "residual" of a Try type is. A number of alternative names have been proposed, but there are concerns about implying "error"-ness or "unexpected"-ness, because those implications may not make sense for every context. Hence the relatively-neutral-but-unusual word "residual."

2. The impact of having both the Try and FromResidual traits on the complexity of trait bounds.

Several examples have been given in the thread of new APIs involving these traits which see their signatures become increasingly complex as they incorporate these experimental traits. In particular, additional work is needed to tie the <T as Try>::Residual to the R parameter in the FromResidual trait, to make sure they're the same when needed. It is possible that permitting associated trait bound syntactic sugar would improve this situation, but this may not be considered sufficient.

3. A question of the value of splitting Try and FromResidual in the first place.

Obviously, this was central to the changes from version 1 of the Try trait proposal to version 2. The value of having FromResidual as a super-trait of Try is that any Try-able type can support handling the use of the ? operator on other types, so long that there's an impl FromResidual<SomeOtherType> for MyType. This flexibility can be nice! However, the split between these two traits seems so far to have confused a number of folks.

4. A question over whether the current design can be changed, given its use throughout the standard library.

This API has already impacted the standard library, particularly through various try_* functions, and the FromResidual implementations for Poll. There's a question of how much here can be rolled back. I don't have a clear sense of the constraints / complexity of this task.

5. Concerns about the Infallible stuff appearing in the relevant impls.

As I take it, the use of Infallible in different places has to do with Rust's inability to reason about higher-kinded types as first class citizens. Infallible isn't super common in Rust code today, so people may be surprised by or unfamiliar with it.

6. Concerns about also teaching the helper ControlFlow enum.

The method Try::branch returns a ControlFlow, which means documentation for Try has to teach this type and what it means. Not impossible, but not done super well as-of-yet.

7. Concerns about how well this design plays with Rust's type inference.

Bad-type-inference errors are generally very frustrating to deal with, because (especially without good error messages) you can easily end up in a space where you feel like you're adding random hints to the compiler until it tells you the types are correct. I take it this current API has inference issues, although I don't understand the technical specifics yet.


If I've missed any big topics under discussion which are currently blocking progress on v2 of the Try trait, let me know.

I do notice that a lot of these are education questions, about how easy it will be for people to understand this new API. This touches on documentation, error messages, and the terminology itself.

There are distinct questions about implementation, which basically amount to questions of complexity of bounds and difficulty of correct type inference.

All of that said, I'd like to throw my hat in as someone interested in helping. I have a crate called woah (woah::Result's Try impl) which I am keeping pre-1.0 in part because it really needs the Try trait available to be usable, and I don't want to stabilize with a nightly-only unstable trait being so central to the design.

I'm particularly interested in helping around the documentation / education parts of things. Let me know if there's a good place to start.

ZanderBrown commented 1 year ago

A number of alternative names have been proposed, but there are concerns about implying "error"-ness or "unexpected"-ness, because those implications may not make sense for every context.

Seeing this randomly gave me an idea: How about ‘Alternate’?

It seems to fit the ‘neutral’ requirement, not making a particular value judgement beyond ‘different’, and seems to work well in prose, i.e. ‘the alternate path is taken’

I'd say it's also more intuitive for both native and non-native speakers, compared to a slightly odd interpretation of a relatively obscure word

withoutboats commented 1 year ago

(NOT A CONTRIBUTION)

A small change that I think would make this a lot easier to intuit would be to remove the default value for the generic parameter on FromResidual and change the supertrait bound on Try to Try: FromResidual<Self::Residual>. Some notes:

  1. There's just a lot to parse in FromResidual<R = <Self as Try>::Residual>. I find it hard to even figure out that what I'm looking at is a generic trait with a default value for the parameter, and not something like a supertrait bound.
  2. The alternative I'm proposing seems much more conventional.
  3. I, a user with an above average understanding of the trait system, did not even know it was possible to make the default bound depend on an impl of a trait that isn't a super trait in the way this does here. I doubt 1% of Rust users know this, and I doubt 10% of Rust users would find it easy to understand what's going on in less than a minute of reading the docs.
  4. My understanding is that FromResidual isn't really meant to be used anywhere, rather than Try, and so getting the default parameter doesn't seem to have much benefit.

Frankly, when I read that you had modified the Try trait to add a super bound that looks like FromResidual<R = <Self as Try>::Residual>, my immediate reaction was very negative. When I read through the RFC, I was more convinced that the actual structure of the interfaces has unique utility. But this specific quirk of it I think doesn't carry the weight of the cognitive load it adds to the interface.


I also think that "residual" is an unclear name and this isn't an area where Rust needs any more unique terms of art, but as a rule I now try to keep my comments on the lowest rung of Wadler's ladder possible so I won't push that further.


As another note, I think the desire the "generalize" APIs that currently involve closures returning Result to support any try type is a mistake in itself. I don't think there's a way to not get highly generic, inscrutable type signatures (regardless of the decision on the definition of Try), and I don't think there's enough utility in doing that to justify the cognitive overhead of those signatures (since Try types should have a pretty straightforward conversion to Result if you really need to use some other Try type in that case). PRs like #107122 should be closed as undesired.

alilleybrinker commented 1 year ago

Okay, it does seem like the question of whether the flexibility afforded by FromResidual is worth it is the most fundamental question. Improving naming or documentation around a feature are secondary to deciding if the feature should remain.

To help inform that discussion, I'd like to work on answering some questions:

Each of these can at least attempt to be answered by surveying available Rust codebases, something which I believe has already been done at least in part by others. I'd like to go through the discussions to identify any samples already taken, and likely add more samples myself, to help everyone involved have more concrete reference points for the benefits, additional complexity, and possible demand for this feature.

CAD97 commented 1 year ago

In practice, how much usage of ? and Try appear to use non-Result types?

Asking this question for unstable functionality rarely gives useful answers, because for most use cases, using a stable-compatible workaround is preferable to using unstable functionality.

There are five types which can be stably used with ?: Result<_, _>, Option<_>, ControlFlow<_, _>, Poll<Result<_, _>>, and Poll<Option<_>>.

All Try types will look "Result-ish," because the try operation is monadic, and Result is a canonical monad.

What specific additional generic powers become available in the presence of FromResidual?

The primary purpose of FromResidual is that it defines what types you can use ? on in a function which is returning Self.

The biggest incidental complexity of the residual system imho is the fact that there isn't a one-to-one mapping between Try and residual. The residual (definition "the part left behind") is supposed to represent the try type without the output case, so it's unfortunate that custom impls are getting implicitly nudged into just using Result<!, E> or Option<!> instead of actually encoding their type's residual.

In implementing FromResidual<Result<!, E>> for a type, you are stating that you should be able to ? a Result<_, E> in functions returning that type. That this meaning is obscured behind an intermediate is unfortunate.

Something along the lines of FromResidual needs to exist, though, because it's allowed to apply ? to a nonequal type, both in the simple "stem preserving" case of Result<T, E> -> Result<T, impl From<E>> and in the "stem changing" case of Poll's transparent Try impls.

(Although the latter is somewhat controversial in retrospect and Poll::ready exists unstably to "fix" it, it's stable and changing it is essentially[^1] a nonstarter.)

[^1]: Since ? is essentially language functionality while the library plumbing remains unstable, the behavior of ? could theoretically be changed over an edition to use a different trait which is implemented differently. But while technically possible, it's a horrible thing to change in practice.

How much more complex, in real world codebases, do type signatures become when incorporating the additional flexibility of FromResidual?

Frankly, FromResidual probably shouldn't show up generic signatures. The only time it makes any potential sense is with a generic return type (to communicate the potential failures the return type needs to accept), and a nongeneric return type of Result accomplishes this just as well, while not having the usability issues inherent to generic return types. And even then you still need to have some additional bound enabling the function to construct a success output.

Basically, being generic over FromResidual is like being generic over From — it doesn't make sense in 99% of cases to be generic in that direction. That direction is a trait because it makes the usefully generic direction of Into easier to express interconversions for.

The trait which would actually be appropriate for generic return types is Residual, which maps back from a residual to canonical Try type (that one-to-one correspondence), allowing you to map over success type (e.g. Result<T, E> -> Result<U, E>). This is the axis that try_* functionality would potentially be generic over, e.g. collecting impl Iterator<Result<T, E>> -> Result<impl FromIterator<T>, E>.

And Residual does result in incidental complexity in expressing that genericism; what you want to say for Q: Try is roughly just <Q as Try>::ButWithOutput<T>, but instead it needs to be spelled as <<Q as Try>::Residual as Residual<T>>::TryType.

Expressing maximally flexible monads in Rust is known to be a hard problem. And nearly all of that flexibility is actually used for individually reasonable applications.

I think I agree with boats here in that being generic in this way probably isn't necessary, and that try_* functionality can stick to being defined in terms of the canonical try type, Result, for much more legible signatures and little/no cost to functionality, just generality, since callers would need to reify between their custom Try type and Result.

As an actual example, see Iterator::try_reduce:

// today's plumbing
type RebindTryOutput<T, O> = <<T as Try>::Residual as Residual<O>>::TryType;
fn Iterator::try_reduce<F, R>(&mut self, f: F) -> RebindTryOutput<R, Option<R::Output>>
where
    F: FnMut(Self::Item, Self::Item) -> R,
    R: Try<Output = Self::Item>,
    R::Residual: Residual<Option<R::Output>>,
;

// "ideal" spelling of today's plumbing
fn Iterator::try_reduce<F, R>(&mut self, f: F) -> R::RebindTryOutput<Option<Self::Item>>
where
    F: FnMut(Self::Item, Self::Item) -> R,
    R: Try<Output = Self::Item>,
    R::Residual: Residual<Option<Self::Item>>,
;

// just Result
fn Iterator::try_reduce<F, E>(&mut self, f: F) -> Result<Option<Self::Item>, E>
where
    F: FnMut(Self::Item, Self::Item) -> Result<Self::Item, E>,
;

Some kind of similar functionality to Residual probably still needs to exist, though, to make try blocks somewhat functional without mandatory type hinting.

Stargateur commented 1 year ago

In practice, how much usage of ? and Try appear to use non-Result types?

I'm working on a project since 1 years that fully use all features of Try trait v2 so I hope the FromResiduel feature will not go away... I try to work on the doc to release it one day. I guess it will be a good example for this thread.

tmccombs commented 1 year ago

Using Result in the try_* funcions means giving up on this part of the motivation from the RFC:

Using the "error" terminology is a poor fit for other potential implementations of the trait.

At least in the scope of these functions. In some cases, such as OnceCell::get_or_try_init, that's probably not terrible, since if failing to init the cell, would generally be considered an error. But for something like try_fold, you are more likely to have situations where breaking is actually a success, not on error.

kevincox commented 1 year ago

Maybe we can use "exception" rather than error. As the try "triggering" would generally be the exceptional case and the common case would be continuing the straight-line code.

withoutboats commented 1 year ago

(NOT A CONTRIBUTION)

The trait which would actually be appropriate for generic return types is Residual, which maps back from a residual to canonical Try type (that one-to-one correspondence), allowing you to map over success type (e.g. Result<T, E> -> Result<U, E>). This is the axis that try_* functionality would potentially be generic over, e.g. collecting impl Iterator<Result<T, E>> -> Result<impl FromIterator, E>.

I think this is the part that people really are concerned about. Frankly, I think Residual should be removed, and all of these. highly generic APIs that cannot be written without it should be removed as well.

I'm going to be very honest about what I think, because I think it is also the opinion of many other prominent community members who are not involved in the libs/lang team clique and have been critical on issues like this or on Twitter. It seems to me that the product design group is trapped in some sort of code golf death spiral where one side creates extremely generic convenience APIs (i.e. try_collect) and the other side proposes whole new axes of abstraction to hide some of that highly generic type signature behind a language feature (i.e. keyword generics), while most users do not have any pressing need for these APIs or abstractions, which are at best "nice to have" and cannot justify the huge cognitive overhead they bring to the language and standard library.

The Try feature should be brought back to its original scope: making try and ? work with multiple types. The actual content of the RFC for Try v2 is fine, in my opinion, with the exception of the one little change I proposed in my previous comment. The addition of the Residual trait and the fake-monad type hacking by way of double projection from Try to Residual and back to Try with a swapped enclosed type needs to be dropped.

fogti commented 1 year ago

the fake-monad type hacking by way of double projection

yeah, I'd rather have proper functor, monad interfaces in Rust than such hackery (and I'd really appreciate that solely because dealing with ASTs which are extended/modified through various stages of progressing lead to that sort-of naturally, and being unable to abstract over multiple of them makes it harder than necessary to write generic AST mangling libraries imo).

CAD97 commented 1 year ago

Out of curiosity, @withoutboats, modulo exact naming,

Would you consider the use of monadic reprojection be more palatable if it were spelled -> R::Rebind<O> instead of the double projection (-> <R::Residual as Residual<O>>::TryType)? Presuming that it was just an alias to the use of Residual, that it showed up in docs as the alias, and that the language feature exists to associate a type alias to a trait without letting it be overridden.

What if it was a typical generic associated type of the Try trait instead of the double projection, but there was an associated trait alias used to bound the generic type[^1]?

[^1]: The ability to bound this is why we currently have <R::Residual as Residual<O>>::TryType and not R::Residual::TryType<O>. (...And that doing so would currently also cause an ambiguous associated type error, but IIUC that's considered a bug/fixable limitation.) The bound is necessary to support e.g. ErrnoResult.

I ask because I'm legitimately interested in whether it's the use of a generic monadic rebind at all (given that it's not strictly necessary when alternative Try types can temporarily reify into Result) which is considered unnecessary cognitive overhead, or if it's just the use of a double projection to accomplish it. (The former is the inherent complexity of making the API generic over this axis; the latter is incidental complexity of how the result is achieved.)

I ask this now prompted by learning of an open draft experiment utilizing a similar double projection scheme to permit allocator-generic collections to do -> A::ErrorHandling::Result<T, E> to select between -> Result<T, E> and -> T based on whether the allocator is (statically) in fallible or infallible mode. This permits the unification of the e.g. reserve/try_reserve split, but at the cost of any potentially allocating method which would have had a try_* version having the more complex signature.


I do agree that doubled type projection is essentially unprecedented in stable std function signatures. The use of a type alias for clarity in the definition of functions doing so is telling, and at an absolute minimum imo the alias should be made public so it will show up in the API docs.

I doubted it for a moment, but the use of a <Type as Trait>::Type return type in the API docs of stable functions is precedented, at least. Namely, Option::as_deref is documented as having the signature (&self) -> Option<&<T as Deref>::Target> where T: Deref[^2].

[^2]: This is despite it being implemented with -> Option<&T::Target>; I presume rustdoc doesn't bother figuring out whether a projection can be treated as unambiguous. Especially since when such a type projection would be ambiguous in rustdoc is a complicated and distinct question than it being unambiguous in the source code.

withoutboats commented 1 year ago

(NOT A CONTRIBUTION)

Would you consider the use of monadic reprojection be more palatable if it were spelled -> R::Rebind<O> instead of the double projection

Both the action of rebinding the wrapped type and the use of double projection to do so add cognitive overhead to the API. A GAT certainly seems simpler than a double projection, though when you start talking about "associated trait aliases" I get very suspicious. I think either aspect of this alone is complex enough to warrant a lot of caution, and require very compelling motivation. I don't see any of the enabled APIs as particularly well motivated, for exactly the reason we've discussed - you can just map things to Result if you really want, though even with Result I doubt the utility of all these try_ variants at all.

I ask this now prompted by learning of an open https://github.com/rust-lang/rust/pull/111970 utilizing a similar double projection scheme to permit allocator-generic collections

My immediate impression is also to be dubious of these fallible allocator APIs. I think allocators is another area where the working group seems to have gone down a rabbit hole and lost touch with how much complexity they're adding to the APIs. I realize there are motivations, but these motivations need to be balanced against how difficult they make std to understand. Maybe I underestimate how compelling the motivations are for that case, I haven't followed the work closely.

Overall, I think most people who work on Rust open source are very disconnected from how most non-hobbyist users experience Rust. They don't have time to nerd out about the language, so the model of the type system in their head is a bit wrong or incomplete, and they can easily be frustrated or confused or led astray by the kinds of type wizardry the online Rust community embraces without question. Rust has always been trying to bring a great type system "to the masses" (in total opposition to Rob Pike's statement that users "are not capable of understanding a brilliant language"), but this means recognizing when the cognitive overhead complex types are adding is not justified by the utility they bring to the API. This is especially true when that utility is basically just omitting a type conversion, as in a lot of these Try cases.

Of course, this sort of thing is fine in third party crates, where it can be proved out and experimented with and in libraries targeted at a certain kind of user can be effective for that user in achieving their goals. But the standard library should be a bulwark against this sort of programming, because the standard library is the lowest common denominator for all users.

Namely, Option::as_deref is documented as having the signature (&self) -> Option<&<T as Deref>::Target> where T: Deref

This seems like sloppiness to me, as_deref should be in a separate impl block with the where clause on the impl block, rather than the where clause on the method in a normal impl block. This should solve the problem with rustdoc (though I also think this is a poor showing from rustdoc as well). Not sure if this is a technically breaking change to make, though.

clarfonthey commented 1 year ago

I don't have too much to say regarding the actual design of the trait, although I think it's apt to bring up this argument I made in the original RFC: https://github.com/rust-lang/rfcs/pull/3058#issuecomment-797873462

The term "residual", despite being weird English, is unique enough that it could become synonymous with what it's being used for here. There are lots of terms that aren't used in Normal English that are used in Computer English (for example, verbose) and I think that adding another, although weird, is not the worst.

Essentially, however weird the idea of a "residual" is, the term is unique enough that its current meaning in Rust can be learned in isolation.

Folks have demonstrated that they have made use of the FromResidual trait in their own implementations and I think that it would be nice to retain this in the stable implementation. I do however sympathise greatly with the bounds required for methods that do use the Try trait, requiring both Try and FromResidual in the bounds.

I think that FromResidual should probably exist as a relatively "niche" feature that does not need to be understood to use the Try trait in most cases. It should be required to implement the Try trait, but that's a higher bar IMHO, and most people just want to make try_* methods that work without having to understand the whole system.

I feel like maybe more effort should be put into ensuring that the Try trait is easy to use, without sacrificing the flexibility of FromResidual.

fogti commented 1 year ago

@CAD97 just to note: I consider such double projection, especially as in applications of them also trait bounds on them can appear in downstream or implementation code, to be an implementation detail that imo shouldn't be necessary, i.e. should be abstracted by the language, leading to a cleaner interface.

re: the concern of "niche" or

Overall, I think most people who work on Rust open source are very disconnected from how most non-hobbyist users experience Rust. They don't have time to nerd out about the language, so the model of the type system in their head is a bit wrong or incomplete, and they can easily be frustrated or confused or led astray by the kinds of type wizardry the online Rust community embraces without question.

I believe that it shouldn't be necessary for most normal users to deal with the intricate parts of the API directly at all in most use cases, and if they do, it should be minimally invasive. A long type signature with a large amount of trait bounds and such that can't be abstracted away (except via macros, which makes the docs unhelpful) is a strong anti-pattern, and imo thus warrants a more fundamental solution, e.g. making all monads easier to express in rust instead of abstracting over it via a combination of multiple interlocked traits with potentially confusing semantics, and also potentially harder to read error messages in case of failures of type inference and such.

Such interfaces are not only annoying to write/copy-paste and debug, they pose a mental burden, make it harder to present users with good error messages (because the compiler doesn't see the actual abstraction but a workaround around the lack of abstraction, mostly), and might also make type inference/checking unnecessarily harder (and e.g. "simple guesses" by the compiler in case of errors also get much harder, both from the "implement this in the compiler" and "make it fast and maintainable" (also in regard to similar patterns which might evolve in third-party crates, etc.).

e.g. (rust-like pseudo-code) ```rust enum ControlFlow { /// Exit the operation without running subsequent phases. Break(B), /// Move on to the next phase of the operation as normal. Continue(C), } monad_morph ControlFlow { type Monadic = ControlFlow; fn pure(t: C) -> Self::Monadic { ControlFlow::Continue(t) } fn flat_map(input: Self::Monadic, f: F) -> Self::Monadic where F: /* function trait used might vary per monad_morph */ FnOnce(C1) -> Self::Monadic, { match input { ControlFlow::Break(b) => ControlFlow::Break(b), ControlFlow::Continue(c) => f(c), } } /* a question that remains here would be how to handle the "short-circuiting" generally and effectively (that is, without many nested closures) * probably similar to the residual stuff, but it would be interesting to see how it could be done generally * (e.g. simple ControlFlow), while also allowing lazier interfaces (like monads similar to iterators (considering `flat_map` there)) */ } ```

also, another idea would be to introduce some kind of "tagged ControlFlow", and just use that everywhere (especially try_ functions), forgoing the most difficult parts of this, while still allowing guided interconversions (mediated by some kind of type-level tags (ZSTs))

gtsiam commented 1 year ago

I think that FromResidual should probably exist as a relatively "niche" feature that does not need to be understood to use the Try trait in most cases.

That is already kinda the case. If you're using a Try type, you don't need to care about FromResidual. If you're writing one, then you will be forced to write exactly one impl of it, which is only fair.

Then again, if you're using one of the ready-made functions, you won't need to care much about the traits beyond the documentation.


However, unless I'm reading the thread wrong, most of the discussion right now seems to be about the Residual trait, as opposed to the Try & FromResidual traits. The relevant tracking issue is #91285, not here.

For what it's worth, I think things like try_collect are useful (and will be even more so when try trait stabilises and Result isn't the only result-like type anymore). That said, double projection does make all the relevant type signatures headache-inducing. So maybe something like this would work better?

trait TryWith<O>: Try {
    type TryWith: Try<Output = O, Residual = Self::Residual>;
}

I'd be cautious of GATs here, since they'd forbid any impl blocks from adding additional bounds on the projected Output, though I haven't thought about it much so I'll leave it at that.

But again, wrong tracking issue.


Also I'll echo @withoutboats on removing the default for R from FromResidual<R> for readability reasons. Appart from that I think the try trait design (which does not include the Residual trait) is fine.

rakshith-ravi commented 1 year ago

As somebody who would like to see this stabilized, what can I do to help? Is there anything I can do to help push this forward?