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::Kindrule above - The
Try::Breakof the?target must convert to theTry::Breakof the try scope result usingFrom.
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
Breaktype in "fat try" is intended to be fully minimized and not carry any information about the originatingTrytype. The burden of defining whichTrytypes are interoperable is fully shifted to theTrytrait itself, particularly withTry::Kind. Also there is no more need "unwrap" the residual type infrom_residual. - No
FromResidualimplementations. WithTry::Kind, allTrytypes of the sameKindare 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 implementFromto convertBreaktypes as needed. - No
Residualtrait. (Generally replaced withTryAs) - Less fine control over which types are inter-convertible. I think this is totally fine. But this is putting some faith in
Try::Kindthat it will make sense across use cases. Fromconversion is built into?. Withtry_trait_v2, you need to includeFromconversion intoFromResidualimplementations if desired. I think the advent of homogeneous try blocks at least partially negates some of the motive to remove universalFromsupport. And I fear that having both variableFromand two types of try blocks is too much complexity overall.Try::Kindis kind of strange as a "marker type". Though I believe this is just relocating the strangeness of "residual types" likeResult<!, 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. TryAsimplementations 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 theTryimpl.TryAsfor types with a non-genericContinueis possible but strange to implement.TryAsisn't strict enough - it should be required to be round-trippable:<R as TryAs<R::Continue>>::Try = R. This could be a lint.