Pre-RFC: Another attempt to re-design the Try trait - Try::Kind and TryAs

This is an attempt to re-design the Try and Residual traits to be simpler while generally maintaining functionality. I call it "fat try" because it stuffs most everything into Try instead of breaking it up into several traits.

I believe this is workable solution but the "ControlFlow Wrinkle" below is one non-trivial obstacle. I have a local branch as a POC where I have been able to prove that this generally works, including changes to try desugaring.


Here are the traits. There's only two:

pub trait Try {
    type Kind;

    type Continue;

    type Break;

    fn from_continue(c: Self::Continue) -> Self;

    fn from_break(b: Self::Break) -> Self;

    fn branch(self) -> ControlFlow<Self::Break, Self::Continue>;
}

pub trait TryAs<T>: Try {
    type Try: Try<
	    Kind = Self::Kind,
	    Break = Self::Break,
	    Continue = T,
    >;
}

Example implementation:

impl<T, E> ops::Try for Result<T, E> {
    type Kind = Result<!, !>;
    type Continue = T;
    type Break = E;

    fn from_continue(t: T) -> Self {
        Ok(t)
    }

    fn from_break(e: E) -> Self {
        Err(e)
    }

    fn branch(self) -> ControlFlow<E, T> {
        match self {
            Err(e) => ControlFlow::Break(e),
            Ok(v) => ControlFlow::Continue(v),
        }
    }
}

impl<T, U, E> TryAs<U> for Result<T, E> {
    type Try = Result<U, E>;
}

For Option, Kind is Option<!> and Break is ().

Try::Kind

(This is the main thing that makes this proposal different.)

The Try::Kind Rule: All ? operators must have a target whose Try::Kind is equal to the Try::Kind of the result of the surrounding try scope (function or try block).

Try::Kind is a marker type (never constructed at runtime) which ascribes a semantic to the ? operator for the implemented type. The Try::Kind for Result and Option are Result<!, !> and Option<!> respectively. You can think of it as an answer to the question: "What does the ? operator test for this type?". Try::Kind=Result<!, !> means "? tests for the presence of an error". And Try::Kind=Option<!> means "? tests for the absence of a value". Note that the Try::Kind does not necessarily indicate the actual type. For example, you may have CustomError as Try<Kind=Result<!, !>>.

Custom Try Types

When implementing a custom Try type, you will need to decide what the Kind will be, and whether to re-use a std Try::Kind (Result<!, !> or Option<!>) or define your own. Perhaps the most common choice would be to use Result<!, !>, thus declaring your type to be a "custom result", and having the same semantic of "? tests the presence of an error". Or you may define a new Try::Kind to signify some domain-specific semantic. But then your type cannot be used in try scopes which produce a std type. Choosing a std Try::Kind gives you interoperability with the corresponding std type "for free". That is, you may use ? on your custom error in a try scope that returns Result and visa versa.

? uses From

The ? operator includes a From conversion to convert the ? target's Break type into the try scope output's Try::Break type.

So we can say that these are the full requirements for using ?:

  • The Try::Kind rule above
  • The Try::Break of the ? target must convert to the Try::Break of the try scope result using From.

From conversion on the Break value is not included in homogeneous try blocks. More on that later.

The TryAs Trait

The TryAs trait draws a relationship between Try types that are the same type but with the Continue type interchanged. For example, io::Result<u32> as TryAs<String>::Try = io::Result<String>. Note that a TryAs conversion maintains that Try::Kind and Try::Break are unchanged. Also note that TryAs should maintain the same outer type, so it is more constrained than Try::Kind.

The TryAs trait is useful in function signatures that are generic over Try, and where the Continue type is changed. For example, here is a function signature for std::array::try_from_fn (some details omitted):

pub fn try_from_fn<T, R, const N: usize, F>(f: F) -> <R as TryAs<[T; N]>>::Try
where
    F: FnMut(usize) -> R,
    R: Try<Continue = T> + TryAs<[T; N]>

Homogeneous Try Blocks

Homogeneous try blocks generally work as described in the RFC. The underlying type requirements just look different. Where heterogeneous try scopes use Try::Kind to enforce a relationship between types, homogeneous try blocks use TryAs instead.

The Homogeneous TryAs Rule: For every ? operator within a homogeneous try block, given the ? target is T and the try scope result is R, <T as TryAs<<R as Try>::Continue>> = R. In other words, the ? target type can be related to the try scope result type via TryAs.

ControlFlow Wrinkle

ControlFlow is stabilized with no From conversion in its ? support. I don't know how to resolve this unfortunately. My preference is to use a time machine to reverse the stabilization, but I am unable to supply a time machine at this time.

Comparison with try_trait_v2, discussion

  • No "residual" types. The Break type in "fat try" is intended to be fully minimized and not carry any information about the originating Try type. The burden of defining which Try types are interoperable is fully shifted to the Try trait itself, particularly with Try::Kind. Also there is no more need "unwrap" the residual type in from_residual.
  • No FromResidual implementations. With Try::Kind, all Try types of the same Kind are allowed to be used with each other (a ? target paired with a try scope result) without implementing a trait for every pair within the set. You only need to implement From to convert Break types as needed.
  • No Residual trait. (Generally replaced with TryAs)
  • Less fine control over which types are inter-convertible. I think this is totally fine. But this is putting some faith in Try::Kind that it will make sense across use cases.
  • From conversion is built into ?. With try_trait_v2, you need to include From conversion into FromResidual implementations if desired. I think the advent of homogeneous try blocks at least partially negates some of the motive to remove universal From support. And I fear that having both variable From and two types of try blocks is too much complexity overall.
  • Try::Kind is kind of strange as a "marker type". Though I believe this is just relocating the strangeness of "residual types" like Result<!, E> and giving it a more dedicated place.
  • Addition of TryAs. This trait might feel a bit ad-hoc, admittedly. You don't need it until you try to use homogeneous blocks or certain functions that use it in the interface.
  • TryAs implementations tend to have a lot of generic parameters and this can be confusing. I wonder if there is a way to auto-derive these based on the Try impl.
  • TryAs for types with a non-generic Continue is possible but strange to implement.
  • TryAs isn't strict enough - it should be required to be round-trippable: <R as TryAs<R::Continue>>::Try = R. This could be a lint.

Thanks for sharing. That helps to give a concrete idea of some of the trade-offs involved in simplification, which in turn will help with the discussions needed before stabilisation.

To really get a view on the trade-offs it'd be great to understand how other known use-cases are affected. My estimate, without having tried any of them, is:

  • ErrorCode would be OK, but interconversion between a potential ErrorCodeU32 & ErrorCodeI32 would not be possible without additional methods. (Here the Residual/Break types are different, not just the Output/Continue)
  • TracedResult would be possible, with the additional logic falling into from_break.
  • TracingResult would be possible, with logic in branch as today. A possible future move to a more generic Tracing<Result>, Tracing<Option>, etc. would be possible and could maybe even be more flexible by making use of TryKind
  • thread_safely::Context would not be possible, at least, not across different TryKinds
  • A homogeneous Poll would introduce a fourth TryKind in stdlib.
  • In case of a homogeneous Poll, ?? and ??? etc. would need to be compiler-features, to allow for ergonomic approaches to the current Poll use-cases. Currently they (and other such wrapping-type cases) are possible with pure library implementation.
  • A semantically flattened OReO wouldn't allow "construction from parts via ?"
1 Like

These are great points for comparison, thanks!

Yes, and to me this feels like a really great case for Try::Kind. It has its own distinct semantic, and Try::Kind makes that distinction an explicit thing in code.

Could you elaborate on what you mean here? I think I'm missing some context.

These highlight the same limitation - the inability to coalesce multiple Try semantics in to a combined Try result. That does seem like it might be too severe of a limitation. Something else I'm noticing here is that branch() can't make much sense when you combine semantics like that. The whole point of branch() is that is splits over one axis with two possible outcomes.

We could have a FromBreak trait and give that a Kind associated type. So then Try::Kind describes branch()'s semantic but FromBreak::Kind describes the semantic of the Try that Break'ed. Then we can support the OReO as well as thread_safely::Context. And thread_safely::Context would then be even more flexible than it was, supporting custom result types etc.

The ship is probably sailed for having From built into ?, and for that reason we probably need FromBreak anyways.

As for "residual" vs "break" naming, I've actually come around to believe that "residual" is an okay name, but only in the case that it looks like e.g. Result<!, E>. If we can have "minimal break types", I think "residual" then becomes an unnecessary term. That was probably the general thinking behind it all along and I'm just catching up.

Currently std has impl<T, E, F: From<E>> FromResidual<Result<!, E>> for Poll<Result<T, F>> which is designed to allow for the following shape:

fn poll_flush(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
    ...
}

fn poll_close(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
    ready!(self.poll_flush(cx))?; // <- Uses FromResidual<Result<!,E>> for Poll<Result<T,E>>
    Poll::Ready(Ok(()))
}

That's a very common use case which will require an ergonomic way to get from Result<T,E> to Poll<Result<T,F>>. With the current set of traits the above would continue to work, even with a homogenous impl Try for Poll, and could be adjusted to self.poll_flush(cx)?? gradually over time. With a simplified set, I imagine the only fix would be to provide direct compiler support for desugaring multiple ? where one Try wraps another, and force the shift from ready!()? to ?? with any change to Poll Try. (Technically possible, clear 3-way-trade-offs between Try, homogeneity & impact)

Yes, they are the same case for this particular discussion right now, although due to the significantly different semantics, I'd consider them separately both from a use-case-discussion perspective and technically (subtle differences)

As to whether it's too much of a limitation - I'd say: it's clearly one of the main trade-offs to consider.

I don't follow ... For both cases there is a clear-cut output & residual. For Context: this is purely semantic (has thread cancellation been requested?); for the "OReO" it is both sematic and technical (did we get a data match?, if so - I want the data)

Which brings us back very close to the current 3 trait setup. I can't remember from all the archaeology what the exact reason for trait Residual was rather than adding an associated type to FromResidual, or whether this was discussed (before my time) ...

No ships have sailed until stabilisation. But personally, I'd consider them to be semantically different enough to warrant separation, without even thinking about potential collisions in blanket implementations.

I guess I'm just noticing that the Continue/Break types don't neatly correspond to the branching semantic, unlike Result where the Continue/Break types neatly come from the two enum variants. But that's not really a problem other than that it might be a bit confusing. With the "OReO", it's like branch() is not testing for one thing but is testing for N specific things represented by the variants. But again, that's not really a problem and it ultimately works out fine.

The Residual trait is explained pretty well in the homogeneous blocks RFC. The way I would explain it is that it has a dual purpose of 1) defining the type constraint for homogeneous blocks and 2) providing a way to define API's like Iterator::try_find or array::try_from_fn. My proposed TryAs pretty much serves the very same dual purpose.

In my mind, Residual or TryAs live in the "homogeneous try universe", and Try::Kind lives in the "heterogeneous try universe".

...And "residual types" like Result<!, E> live in the intersection of the two universes. So they carry a lot of weight: 1) They must communicate the specific Try type they are derived from for homogeneous purposes, and 2) they must support heterogeneous interoperability by way of FromResidual impls. Because of the former, you are forced to write more impls for the latter.

I have ControlFlow in mind specifically when I say the ship has sailed because I think it would be backward-incompatible to add From conversion to ControlFlow. But maybe we could do that over an edition boundary?


I'd like to think that this proposal can be decomposed into smaller orthogonal (to a degree) decisions.

  • Heterogeneous Try: "residual types" vs. Kind
  • Homogeneous Try: Residual vs. TryAs
  • From in FromBreak/FromResidual vs. From in ?
  • Complex Try Types (combined semantics), options include:
    • Try::from_break - no support
    • FromBreak without FromBreak::Kind - limited to a single Try::Kind - basically "no support"
    • FromResidual - supported via "residual types"
    • FromBreak::Kind - supported via Kind

Here's what the traits could look like if we keep Try::Kind and minimal "break types" but extend it with support for complex try types. TryAs and homogenous try is the same as OP. This can work with or without From in ? desugaring.

pub const trait Try: TryFromBreak<Self::Break, Self::Kind> {
    type Kind;

    type Continue;

    type Break;

    fn branch(self) -> ControlFlow<Self::Break, Self::Continue>;

    fn from_continue(c: Self::Continue) -> Self;
}

pub const trait TryFromBreak<Break, Kind> {
    fn from_break(b: Break) -> Self;
}

pub const trait TryAs<T>: Try {
    type Try: Try<
	    Kind = Self::Kind,
	    Break = Self::Break,
	    Continue = T,
    >;
}

Example impl:

impl<T, E> ops::Try for Result<T, E> {
    type Kind = Result<!, !>;
    type Continue = T;
    type Break = E;

    fn branch(self) -> ControlFlow<E, T> {
        match self {
            Err(e) => ControlFlow::Break(e),
            Ok(v) => ControlFlow::Continue(v),
        }
    }

    fn from_continue(t: T) -> Self {
        Ok(t)
    }
}

impl<T, E> TryFromBreak<E, Result<!, !>> for Result<T, E> {
    fn from_break(e: E) -> Result<T, E>{
        Err(e)
    }
}

The Heterogeneous ? Rule:

Let `T` = the `?` target.
Let `R` = the try scope result

R: TryFromBreak<<T as Try>::Break, <T as Try>::Kind>

This maintains the advantage of higher re-usability for a custom result type. If you use Try::Kind = Result<!, !>, you get two-way interoperability with std Result with writing only one impl TryFromBreak.

With this version, it may be more preferable to have dedicated types for Kind especially for readability in TryFromBreak.

/// The `Try::Kind` for types where `?` tests for the presence of an error.
/// The canonical type with this `Try::Kind` is `Result`.
struct TryResult(());

impl<T, E> Try for Result<T, E> {
    type Kind = TryResult;
    ...
}

impl<T, E> TryFromBreak<E, TryResult> for Result<T, E> {
    ...
}