- Feature Name: try_trait_v2
- Start Date: 2020-12-12
- RFC PR: rust-lang/rfcs#3058
- Rust Issue: rust-lang/rust#84277
Summary
Replace RFC #1859, try_trait
, with a new designTry
trait and corresponding?
operator.
The new design
This is forward-looking to be compatible with other features, like try {}
blocks or yeet e
expressionsIterator::try_find
, but the statuses of those features are not themselves impacted by this RFC.
Motivation
The motivations from the previous
- Using the "error" terminology is a poor fit for other potential implementations実装of the trait.
- The previous前のRFC's mechanism仕組み、機構for controlling制御するinterconversions proved ineffective, with inference meaning that people did it unintentionally.
- It's no longer clear that
From
should be part of the?
desugaring for all types. It's both more flexible -- making inference difficult -- and more restrictive -- especially without specialization -- than is always desired. - An experience report in the tracking issue mentioned that it's annoying to need to make a residual type in common cases.
This RFC proposes a solution that mixes the two major options considered
- Like the reductionist approach, this RFC proposes an unparameterized trait with an associated type for the "ok" part, so that the type produced産出、産出するfrom the
?
operator演算子on a value is always the same. - Like the essentialist approach, this RFC proposes a trait with a generic parameter仮引数for "error" part, so that different types can be consumed.
Guide-level explanation
The ops::ControlFlow
type
This is a simple enum:
It's intended for exposing things (like graph traversals or visitor) where you want the user to be able to choose whether to exit early. Using an enum is clearer than just using a bool -- what did false
mean again? -- as well as allows
For example, you could use it to expose a simple tree traversal in a way that lets the caller
Now, you could write the same thing with Result<(), B>
instead. But that would require that the passed-in closure use Err(value)
to early-exit the traversal, which can causeControlFlow::Break(value)
instead avoidsbreak val
in a loop
doesn't inherently mean success nor failure.
The Try
trait
The ops::Try
trait describes?
operator,ops::Add
trait describes+
operator.
At its core, the ?
operator
- The output that will be returned from the
?
expression,式with which the program will continue, and - The residual that will be returned to the calling呼び出しcode, as an early exit from the normal flow.
(Oxford's definition
The Try
trait also has facilities?
and in methods generic over multipleTry
.
Here's a quick overview of a few standard types which implementTry
, their corresponding
+-------------+ +-------------------+ +-------------------+
| Try::Output | | Try Type | | Try::Residual |
+-------------+ Try::branch is Continue +-------------------+ Try::branch is Break +-------------------+
| T | <------------------------ | Result<T, E> | ---------------------> | Result<!, E> |
| T | | Option<T> | | Option<!> |
| C | ------------------------> | ControlFlow<B, C> | <--------------------- | ControlFlow<B, !> |
+-------------+ Try::from_output +-------------------+ Try::from_residual +-------------------+
If you've used ?
-on-Result
before, that output type is likely unsurprising. Since it's given
The residual types, however, are somewhat more interesting. Code using ?
doesn't see them directly
Most importantly, this gives each family of types (Result
s, Option
s, ControlFlow
s) their own distinctControlFlow::Break
is also an early exit, that doesn't mean that it should be allowedResult::Err
-- it might be a success, conceptually. So by giving ControlFlow<X, _>
and Result<_, X>
different residual types, it becomes a compilation error to use the ?
operatorControlFlow
in a method which returns a Result
, and vice versa. (There are also ways to allow
🏗️ Note for those familiar with the previous
前のRFC 🏗️This is the most critical semantic difference. Structurally this definition
定義of the trait is very similar似ている、同様のto the previous前の-- there's still a method splitting the type into a discriminated union between two associated types, and constructorsfunction Object() { [native code] }to rebuild it from them. But by keeping the "result-ness" or "option-ness" in the residual type, it gives extra control制御するover interconversion that wasn't possible before. The changes other than this are comparatively minor, typically一般的に、典型的にeither rearrangements to work with that or renamings to change the vocabulary used in the trait.
Using !
is then just a convenient yet efficientTryFrom
, where for example i32::try_from(10_u8)
gives a Result<i32, !>
, since it's a widening conversion!
here -- any uninhabited enum
would work fine.
How error conversion変換 works
One thing The Book mentions, if you recall, is that error values in ?
have From::from
called
The previousfrom_residual
method is on FromResidual
, which is generic so that the implementationResult
can add that extra conversion.
And while we're showing code, here's the exact definitionTry
trait:
The fact that it's a super-trait like that is why I don't feel bad about the slight lie: Every T: Try
always has a from_residual
function from T::Residual
to T
. It's just that some types might offer more.
Here's how Result
implementsFromResidual
to do error-conversions:
But Option
doesn't need to do anything exciting, so just has a simple implementation,
In your own types, it's up to you to decide how much freedom is appropriate. You can even enable interconversion by defining
🏗️ Note for those familiar with the previous
前のRFC 🏗️This is another notable difference: The
From::from
is up to the trait implementation,実装not part of the desugaring.
Implementing実装する Try
for a non-generic type
The examples in the standard library are all generic, so serve
Suppose we're working on migrating some C code to Rust, and it's still using the common "zero is success; non-zero is an error" pattern. Maybe we're using a simple type like this to stay ABI-compatible:
We can implementTry
for that type to simplify the code without changing the error model.
First, we'll need a residual type. We can make this a simple newtype, and conveniently there's a type with a niche for exactly
With that, it's straight-forward to implementNonZeroI32
's constructorTry::branch
:
Aside: As a nice bonus, the use of a NonZero
type in the residual means that <ResultCode as Try>::branch
compiles down to a nop on the current nightly. Thanks, enum layout optimizations!
Now, this is all great for keeping the interface that the other unmigrated C code expects, and can even work in no_std
if we want. But it might also be nice to give other Rust code that uses it the option to convertResult
with a more detailed error.
For expository purposes, we'll use this error type:
(A real one would probably be more complicated and have a better name, but this will work for what we need here -- it's bigger and needs non-core things to work.)
We can allow?
on a ResultCode
in a method returning Result
with an implementation
The split between different error strategies in this sectionwindows-rs
, which has both ErrorCode
-- a simple newtype over u32
-- and Error
-- a richer type that can capture a stack trace, has an Error
trait implementation,
Using these traits in generic code
Iterator::try_fold
has been stable to call
As a reminder, an infallible version of a fold looks something like this:
So instead of f
returning just an A
, we'll need it to return some other type that producesA
in the "don't short circuit" path. Conveniently, that's also the type we need to return from the function.
Let's add a new generic parameterR
for that type, and bound
Try
is also the trait we need to get the updated accumulator from f
's return value and return the result
We'll also need FromResidual::from_residual
to turn the residual back into the original type. But because it's a supertrait of Try
, we don't need to mention it in the bounds.Try
can always be recreated from their corresponding
But this "callbranch
, then match
on it, and return
if it was a Break
" is exactly?
operator.?
instead:
Reference-level explanation
ops::ControlFlow
The traits
Expected laws
What comes out is what you put in:
<T as Try>::from_output(x).branch()
⇒ControlFlow::Continue(x)
(akatry { x }?
⇒x
)<T as Try>::from_residual(x).branch()
⇒ControlFlow::Break(x)
(maybe aka something liketry { yeet e }
⇒Err(e)
, see the future possibilities)
You can recreate what you split up:
match
⇒一致する、マッチさせるx.branch() { ControlFlow::Break(r) => Try::from_residual(r), ControlFlow::Continue(v) => Try::from_output(v) }x
(akatry { x? }
⇒x
)
Desugaring ?
The previousx?
was
The new one is very similar:
The critical difference is that conversionFrom::from
) is left up to the implementation
Standard implementations実装
Result結果、戻り値
Option
Poll
These reuse Result
's residual type, and thusPoll
and Result
is allowedFromResidual
implementationsResult
.
ControlFlow
Use in Iterator
The providedtry_fold
is already just using ?
and try{}
, so doesn't change. The only difference is the name of the associated type in the bound:
Drawbacks
- While this handles a known accidental stabilization, it's possible that there's something else unknown that will keep this from being doable while meeting Rust's stringent stability guarantees.保証する
- The extra complexity of this approach, compared比較するto either of the alternatives代わりのもの、選択肢consideredみなす、考慮するthe last time around, might not be worth it.
- This is the fourth attempt at a design設計(する)in this space, so it might not be the right one either.
- As with all overloadable operators,演算子users might implement実装するthis to do something weird.
- In situations where extensive interconversion is desired, this requires more implementations.実装
- Moving
From::from
from the desugaring to the implementations実装means that implementations実装which do want it are more complicated.
Rationale and alternatives代わりのもの、選択肢
Why ControlFlow
pulls its weight
The previousResult
.
This RFC does use a new type because one already exists in nightly under the control_flow_enum
feature gate. It's being used in the library and the compiler, demonstrating that it's useful beyond just this desugaring, so the desugar might as well use it too for extra clarity. There are also ecosystem changes waiting on something like it, so it's not just a compiler-internal need.
Methods on ControlFlow
On nightly there are a variety of methods available on ControlFlow
. However, none of them are needed for the stabilization of the traits, so they left out of this RFC. They can be considered
There's a basic set
Traits for ControlFlow
ControlFlow
derives a variety of traits where they have obvious behaviour. It does not, however, derivePartialOrd
/Ord
. They're left out as it's unclear which order,
For Option
s, None < Some(_)
, but for Result
s, Ok(_) < Err(_)
. So there's no definitionControlFlow
that's consistent with the isomorphism to both types.
Leaving it out also leaves us free to change the ordering of the variants in the definition?
operator.
Naming the variants on ControlFlow
The variants are givenIterator::try_fold
or Iterator::try_for_each
.
For example, this (admittedly contrived) loop
can be written as
(Of course, one wouldn't normally use the continue
keyword at the end of a for
loop like that, but I've included it here to emphasize that even the ControlFlow::Continue(())
as the final expression
Why ControlFlow
has C = ()
The type that eventually became ControlFlow
was originally addedLoopState
used to make some default implementationsIterator
easier to read. It had no type parameter
Issue #75744 in 2020 started the process of exposing it, coming out of the observation that Iterator::try_fold
isn't a great replacement for the deprecated-at-the-time Itertools::fold_while
since using Err
for a conceptual success makes code hard to read.
The compiler actually had its own version of the type in librustc_data_structures
at the time:
The compiler was moved over to the newly-exposed type, and that inspired the creation of MCP#374, TypeVisitor: use ops::ControlFlow instead of bool. Experience from that lead to flipping the type argumentsIterator
, where things like default implementationfind
also want C = ()
. And these were so successful that it lead to MCP#383, TypeVisitor: do not hard-code a ControlFlow<()>
result,ControlFlow<Self::BreakTy>
.
As an additionalC = ()
is particularly common, Hytak mentioned the following
i didn't read your proposal in depth, but this reminds me of a recursive search function i experimented with a few days ago. It used a Result
結果、戻り値type as output, where Err(value) meant that it found the value and Ok(()) meant that it didn't find the value. That way i could use the?
to exit early
So when thinking about ControlFlow
, it's often best to think of it not like Result
, but like an Option
which short-circuits the other variant. While it can flow a Continue
value, that seems to be a fairly uncommon use in practice.
Was this consideredみなす、考慮する last time?
Interestingly, a previous
Current desires for the solution, however, have more requirements than were included in the RFC at the time of that version. Notably, the stabilized Iterator::try_fold
method depends on being able to create a Try
type from the accumulator. Including such a constructor
Also, ok-wrapping was decided in #70941, which needs such a constructor,
Why not make the output a generic type?
It's helpful that type information can flow both ways through ?
.
- In the forward direction,方向not needing a contextual type means that
println!("{}", x?)
works instead of needing a type annotation. (It's also just less confusing to have?
on the same type always produce産出するthe same type.) - In the reverse direction,方向it allows許可する、可能にするthings like
let x: i32 = s.parse()?;
to infer the requested type from that annotation, rather than requiring it be specified特定する、指定する、規定するagain.
Similartry
, though of course they're not yet stable:
let y: anyhow::Result<_> = try { x };
doesn't need to repeat the type ofx
.let x: i16 = { 4 };
works for infallible code, so for consistency it's good forlet x: anyhow::Result<i16> = try { 4 };
to also work (rather than default the literal toi32
and fail).
Why does FromResidual
takeとる a generic type?
The simplest case is that the already-stable error conversionsFrom::from
in the desugaring.
However, more experience with trying to use Try
for scenarios other than "the early exit is an error" have shown that forcing this on everything is inappropriate. ControlFlow
, for example, would rather not have it, for the same kinds of reasons that return
and break
-from-loop
don't implicitlyOption
may not care, as it only ever gets appliedNone
⇒None
, but that's not really a glowing endorsement.
But even for the error path, forcing From
causesanyhow
's Error
type, for example, doesn't implementstd::error::Error
because that would preventFrom
-convertible from any E: std::error::Error
type. The error handling project group under libs has experimented with a prototype toolchain with this RFC implemented,
my mind is exploding, the possibility of all error types implementing
実装するerror the way they actually should has such massive implications for the rest of the error reporting stuff we've been working on
As a bonus, moving conversionFromResidual
implementationserde
crate has their own macro for error propagation which omitsFrom
-conversion as they see a "significant improvement" from doing so.
Why not merge Try
and FromResidual
?
This RFC treatsFromResidual<_>
which don't also implementTry
-- so one might wonder why they're not merged into one Try<R>
. After all, that would seem to remove the duplication between the associated type and the generic type, as something like
This, however, is technically too much freedom. Looking at the error propagation case, it would end up callingTry<?R1>::branch
and Try<?R2>::from_residual
. With the implementationResult
, where those inference variablesFrom
, there's no way to pick what they should be, similar.into().into()
doesn't compile. And even outside the desugaring, this would make Try::from_output(x)
no longer work, since the compiler would (correctly) insist that the desired residual type be specified.
And even for a human, it's not clear that this freedom is helpful. While any trait can be implemented?
. Whereas any designbranch
on a generic trait would mean it'd be possible for ?
to return different things depending on that generic type parameter
Naming the ?
-related traits and associated types
This RFC introduces the residual concept as it was helpful to have a name to talk about in the guide section.fn branch(self) -> ControlFlow<Self::Residual, Self::Output>
API is not necessarily obvious.
A different scheme might be clearer for people. For example, there's some elegance to matchingfn branch(self) -> ControlFlow<Self::Break, Self::Continue>
. Or perhaps there are more descriptive names, like KeepGoing
/ShortCircuit
.
As a sketch, one of those alternatives
However the "boring" Output
name does have the advantage that one doesn't need to remember a special name, as it's the same as the other operatorAdd::Output
and Div::Output
even if one could argue that Add::Sum
or Div::Quotient
would be more "correct", in a sense.)
ℹ Per feedback from T-libs, this is left as an unresolved question for the RFC, to be resolved in nightly.
Splitting up Try
more
This RFC encourages one to think of a Try
type holistically, as something that supports all three of the core operations,
That's not necessarily the way it should go. It could be different, like there's no guaranteeAdd
and AddAssign
work consistently, nor that Add
and Sub
are inverses.
Notably, the this proposal has both an introductionTry::from_output
) and elimination rule (Try::branch
), in the Gentzian sense, on the same trait. That means that an implementor will need to support both, which could restrict?
(and try
and yeet
) could be used.
One unknown question here is whether this is important for any FFI scenarios. Often error APIs come in pairs (like Win32's GetLastError
and SetLastError
), but some libraries may only give them out without allowing?
on some ZST, and thus?
-supporting type where supporting from_residual
would be simple.
In pure rust, one could also imagine types where it might be interesting to allowtry
blocks, one could perhaps have something like
which works by allowingfrom_residual
from any Result<_, _>::Residual
, as well as from_output
from ()
. On such a type there's no real use in allowing?
on the result,
The split currently in the proposal, though it's there for other reasons, would allowimpl FromResidual<Result<!, !>> for ()
, which would allowu64::try_from(123_u16)?
even in a method that returns unit. That has a number of issues, however, like only supporting -> ()
and not other things like -> i32
where one would probably also expect it to work, and it could not be a generic implementationResult
. And even if it did work, it's not clear that allowing?
here is the clearest option -- other options such as an always_ok
method on Result<T, !>
might be superior anyway.
Another downside of the flexibility is that the structure of the traits would be somewhat more complicated.
The simplest split would just move each method to its own trait,
but that loses the desired property?
and expected-by-try
types match
One way to fix that would be to add another trait for that associated type, perhaps something like
But this has still lost the simplicityR: Try
boundtry_fold
. (And, in fact, all designs
There are probably also useful intermediary designsIgnoreAllErrors
example above suggests that introduction?
in infallible functions: it's absolutely undesirable for ()?????
to compile, but it might be fine for all return types to support something like T: FromResidual<!>
eventually.
ℹ Per feedback from T-libs, this is left as an unresolved question for the RFC, to be resolved in nightly.
Why a "residual" type is better than an "error" type
Most importantly, for any type generic in its "output type" it's easy to produceOption
-- no NoneError
residual type needed -- as well as for the StrandFail<T>
type from the experience report. And thanks to enum layout optimizations, there's no space overhead to doing this: Option<!>
is a ZST, and Result<!, E>
is no larger than E
itself. So most of the time one will not need to define
In those cases where a separate type is needed, it's still easier to make a residual type because they're transient and thusTry
type. This is different from the previousResult
. That has lead to requests, such as for NoneError
to implementError
, that are perfectly understandable givenResult
s. As residual types aren't ever exposed like that, it would be fine for them to implementFromResidual
(and probably Debug
), making them cheap to define
Use of !
This RFC uses !
to be concise. It would work fine with convert::Infallible
instead if !
has not yet stabilized, though a few more matchOption::from_residual
would need Some(c) => match
.)
Why FromResidual
is the supertrait
It's nicer for try_fold
implementationsTry
name. It being the subtrait means that code needing only the basic scenario can just boundTry
and know that both from_output
and from_residual
are available.
Default Residual
on FromResidual
The default here is providedOption
) doesn't need to think about it -- similarimpl Add for Foo
for the homogeneous case even though that trait also has a generic parameter.
FromResidual::from_residual
vs Residual::into_try
Either of these directionsTry
(not just the associated type). However that method was removed as unnecessary once from_residual
was added,?
/try_fold
functionality.
A major advantage of the FromResidual::from_residual
direction
ConvertingTry
type seems impossible (unless it's uninhabited), but consuming arbitrary
(Not that that's necessarily a good idea -- it's plausibly too generic. This RFC definitely isn't proposing it for the standard library.)
And, ignoring?
) is often the result
Can we just remove the accidental interconversions?
This depends on how we choose to read the rules around breaking changes.
A crater run on a prototype implementation
Definitely a good change.
Thanks for spotting that, that was indeed a confusing mix
However another instance
The interesting pattern boils down to this:
That means it's using ?
on an Option
, but the closure ends up returning Result<_, NoneError>
without needing to name the type as trait resolution discovers that it's the only possibility. It seems reasonable that this could happen accidentally while refactoring. That does mean, however, that the breakage could also be consideredAsRef
breakage, and fits the pattern of "there's a way it could be written that works before and after", though in this case the disambiguated form
This RFC thus
Compatibility互換性 with accidental interconversions (if needed)
If something happens that turns out they need to be supported, the following
This would take
- Add a new never-stable
FromResidualLegacy
trait - Have a blanket implementation実装so that users interact only with
FromResidual
- Add implementations実装for the accidental interconversions
- Use
FromResidualLegacy
in the desugaring, perhaps only for old editions
This keeps them from being visible in the trait system on stable, as FromResidual
(the only form
Prior art
Previous
- The original
Carrier
trait - The next design設計(する)with a
Try
trait (different from the one here)
This is definitely monadic. One can defineMaybe
monad as
use std::ops::Try;
fn monad_unit<T: Try>(x: <T as Try>::Ok) -> T {
T::from_output(x)
}
fn monad_bind<T1: Try<Residual = R>, T2: Try<Residual = R>, R>(mx: T1, f: impl FnOnce(<T1 as Try>::Ok) -> T2) -> T2 {
let x = mx?;
f(x)
}
fn main() {
let mx: Option<i32> = monad_unit(1);
let my = monad_bind(mx, |x| Some(x + 1));
let mz = monad_bind(my, |x| Some(-x));
assert_eq!(mz, Some(-2));
}
However, like boats describedasync.await
, using monads directly?
desugaring to a return
(rather than closures) mixes better with the other controlbreak
and continue
, that don't work through closures. And while the definitionsOption
, they don't allowResult
, so any monad-based implementation?
wouldn't be able to be the normal monad structure regardless.
Unresolved questions
Questions from T-libs to be resolved in nightly:
- What vocabulary should
Try
use in the associated types/traits? Output+residual, continue+break, or something else entirely? - Is it ok for the two traits to be tied together closely, as outlined here, or should they be split up furtherさらなる、それ以上to allow許可する、可能にするtypes that can be only-created or only-destructured?
Implementation実装 and Stabilization Sequencing
ControlFlow
is implemented実装するin nightly already.- The traits and desugaring could go into nightly immediately.直後に、直接的に
- That would allow許可する、可能にする
ControlFlow
to be consideredみなす、考慮するfor stabilizating, as the new desugaring would keep from stabilizing any unwanted interconversions. - Beta testing might result結果、戻り値in reports requiring that the accidental interconversions be addedたすback in old editions, due to crater-invisible code.
- Then the unresolved naming & structure questions need to be addressed before
Try
could stabilize.
Future possibilities
While it isn't directly
For example, one could define
With corresponding
And thus
This can be thought of as the type-level inverseTry
's associated types: It splits them apart, and this puts them back together again.
(Why is this not written using Generic Associated Types (GATs)? Because it allows
A previousTry
requiring it (something like where Self::Residual: GetCorrespondingTryType<Self::Output>
) wasn't actually even helpful for unstable scenarios, so there was no need to include it in normative section
Possibilities for try_find
Varioustry_map
for arraysIterator::try_find
wants to be able to return a Foo<Option<Item>>
from a predicate that returned a Foo<bool>
.
That could be done with an implementation
Similarly,Try
to automaticallymap
method:
Possibilities for try{}
A core problem with try blocks as implemented
That is, the followingx
and y
:
This usually isn't a problem on stable, as the ?
usually has a contextual type from its function, but can still happen there in closures.
But with something like GetCorrespondingTryType
, an alternative
(It's untested whether the inference engine is smart enough to pick the appropriate C
with just that -- the Output
associated type is constrained to have a Continue
type matchingContinue
type needs to matchz
, so it's possible. But hopefully this communicates the idea, even if an actual
That way it could compile so long as the TryType
s of the residuals matched.
Now, of course that wouldn't cover anything. It wouldn't work with anything needing error conversion,
So a future RFC could definetry { ... }
uses the "same family" desugaring whereas try as anyhow::Result<_> { ... }
uses the contextual desugaring.) This RFC declines to debate those possibilities, however.
Note that the ?
desugaring in nightly is already different depending whether it's inside a try {}
(since it needs to block-break instead of return
), so making it slightly more different shouldn't have excessive implementation
Possibilities for yeet
As previously mentioned, this RFC neither definesyeet
operator.Try::from_error
, it's important that this design
yeet
is a bikeshed-avoidance name for throw
/fail
/raise
/etc, used because it definitely won't be the final keyword.
Because this "residual" design
- It could directly直接takeとるthe residual type, so
yeet e
would desugar directly直接toFromResidual::from_residual(e)
. - It could put the argument引数into a special residual type, so
yeet e
would desugar to something likeFromResidual::from_residual(Yeeted(e))
.
These have variousyeet None
/yeet
, yeet Err(ErrorKind::NotFound)
/yeet ErrorKind::NotFound.into()
, etc -- but thankfully this RFC doesn't need to discuss those. (And please don't do so in the GitHub comments either, to keep things focused, though feel free to start an IRLO or Zulip thread if you're so inspired.)