Summary
RFC 3721 implemented default support for homogeneous try {...} blocks, where all ?s return the same error type. This RFC aims to provide support for explicit annotation of the returned error type from a try {...} block.
Motivation
I'm a bit concerned about this change. Applications and libraries often use crates like
thiserrorto automatically group errors. For example, I often write something likewhich I then use as
With this change, this approach would stop working in
tryblocks.
Currently there is no way to get the following example to compile, as the compiler is unable to safely determine the correct types returned from the try blocks, and no notation is available for the user to specify the type:
use ParseIntError;
;
;
The initial experimental approach to provide a proof-of-concept introduced the (deliberate placeholder) syntax try bikeshed ... {...} in PR #149489.
For the remainder of this RFC we will continue with
bikeshedto allow for examples which work on current nightly with#![feature(try_blocks_heterogeneous)].See open questions and try bikeshed: What should the syntax be? for consideration of possible target syntax.
This would allow the above example to become:
and for cases where no direct Into relationship exists, or is needed, via a common third error type:
use ;
Guide-level explanation
Assuming the explanation for try blocks is implemented as per RFC 3721, which contains:
This behaviour is what you want in the vast majority of simple cases. In particular, it always works for things with just one
?, so simple things liketry { a? + 1 }will do the right thing with minimal syntactic overhead. It's also common to want to group a bunch of things with the same error type. Perhaps it's a bunch of calls to one library, which all use that library's error type. Or you want to do a bunch ofiooperations which all useio::Result. Additionally,tryblocks work with?-on-Optionas well, where error-conversion is never needed, since there is onlyNone.It will fail to compile, however, if not everything shares the same error type. Suppose we add some formatting operation to the previous example:
let pair_result = try ;The compiler won't let us do that:
error[E0308]: mismatched types --> src/lib.rs:14:32 | | let c: i32 = b.parse()?; | ^ expected struct `std::io::Error`, found struct `ParseIntError` = note: expected enum `Result<_, std::io::Error>` found enum `Result<_, ParseIntError>` note: return type inferred to be `Result<_, std::io::Error>` here --> src/lib.rs:14:32 | | let a = std::fs::read_to_string("hello")?; | ^
For now, the best solution for that mixed-error case is the same as before: to refactor it to a function.
replace the final sentence with ...
While it may be obvious, or even irrelevant, to you which error type
pair_resultcould potentially have, the compiler has no way to know this.Just like in other situations where the compiler cannot safely infer the exact type to use, you must annotate the block with a valid error type. We've already mentioned that
Resultautomatically converts between error types where a suitable implementation ofIntoexists and you can leverage this and write:let pair_result = try bikeshed ;As long as you have defined a suitable error:
Of course, there are crates available to simplify this if you do not want or need to create your own custom error type.
Reference-level explanation
This described the experimental implementation, currently in place in nightly, as implemented by scottmcm in PR 149489 with occasional additional comments by the RFC author.
Compiler changes
Extend ast::ExprKind::TryBlock to store optional return type
with associated adjustments to visit::walk_fn, assert!
Parse & pretty-print syntax try bikeshed T {...}
-
Add (temporary)
bikeshedkeyword (see unresolved-questions) to available Tokens -
Add
bikeshed&try_blocks_heterogeneousspanned symbolssymbols! -
Parse
tryblocks with optionalbikeshedkeyword/// Parses a `try {...}` or `try bikeshed Ty {...}` expression (`try` token already eaten). -
Correctly pretty print
bikeshedannotatedtryblocksTryBlock =>
Desugaring
-
Introduce
TryBlockScopeenum// The originating scope for an `Expr` when desugaring `?` -
Update desugaring
tryblocks at definition site/// Desugar `try { <stmts>; <expr> }` into `{ <stmts>; ::std::ops::Try::from_output(<expr>) }`, /// `try { <stmts>; }` into `{ <stmts>; ::std::ops::Try::from_output(()) }` /// and save the block id to use it as a break target for desugaring of the `?` operator. -
Update desugaring
?, specifically in the construction of theControlFlow::Breakarm and the final return value/// Desugar `ExprKind::Try` from: `<expr>?` into: /// ```ignore (pseudo-rust) /// match Try::branch(<expr>) { /// ControlFlow::Continue(val) => #[allow(unreachable_code)] val,, /// ControlFlow::Break(residual) => /// #[allow(unreachable_code)] /// // If there is an enclosing `try {...}`: /// break 'catch_target Residual::into_try_type(residual), /// // Otherwise: /// return Try::from_residual(residual), /// } /// ```
Drawbacks
This adds further syntax complexity to the language with another, slightly different, way in which types must be annotated. The open question on the correct syntax shows that whatever is chosen it will not be immediately obvious to users.
Rationale and alternatives
Homogeneous try-blocks with manual error conversion
Only support homogeneous try blocks and force manual conversion.
For example, you could do something like
try ;
- This leads to much more verbose code where multiple error types are involved.
- In cases where the final
Residualis not any of theResidualsinside thetryblock (likely a very common situation withanyhow) this creates further verbosity by forcing turbofish annotation in at least one place. - Changing the block
Residualrequires multiple adjustments. - This breaks for cases where the
Trytype in question is notResult/Optionunless it implements an equivalent ofmap_err(). - It is not immediately obvious to the user reading the block definition what the resulting
Residualwill be, the information is inside the block, not at the start of the definition. When you seetry bikeshed Foo {you know the type without analysing the block.
Fold through some type function that attempts to merge residuals
This is much less local, complex to implement and removes control from the user.
Why not use type annotated variables?
Why not instead just fix type inference so that
let x: Result<_, B> = try {}works, as well as variants like returning the try value, etc.?
Updating type inference to cover these cases is a much more significant undertaking. Leveraging existing type annotation possibilities is not always practical.
-
For cases where this is possible it is not always desirable:
let foo: = try ; let foo = foo?;is more verbose, more brittle and less clear than:
let foo = try bikeshed ?; -
Other times this is only possible with a more significant amount of extra effort, e.g. an anonymous future created by an
asyncblock. (Or inside an anonymous closure, e.g. in a call to.map()).let run_loop = async block_on?;
See also
- Future Possibilities
- Could we evolve this in future? (below)
- Discussion on third-party error types in motivation
Could we evolve this in future?
Once the correct keyword / syntax is identified the remainder is an early desugaring to existing features, this is the easiest kind of thing to change over editions.
Therefore, if in the future we get new type system features that would allow improved "fallback hinting" or inference of unannotated
try blocks, we could relax the restrictions on homogeneous try blocks while still maintaining the ability to annotate for explicit
clarity or where inference is not possible. This would be easy to achieve over an edition change, but we don't need to wait for an unknown to ship something now; we can switch how it works later easily enough.
Prior art
Languages with traditional exceptions don't return a value from try blocks, so don't have this problem.
Even checked exceptions are still always the Exception type.
In C#, the ?. operator is scoped without a visible lexical block.
Related RFCs & experimental features
- RFC #2388
try-expr - RFC #3721
homogeneous_try_blocks - RFC #3058
try-trait-v2 - Tracking issue #91285
try_trait_v2_residual - Tracking issue #63178
Iterator::try_find - Tracking issue #79711
array::try_map - Tracking issue #89379
try_array_from_fn - Tracking issue #96373
yeet_expr
Unresolved questions
- [ ] What should the syntax be? See Issue #154128 for discussion of alternatives (
:,->,as, nothing justtry T {}, or eventry ☃️ {...}as in RFC3721) - [ ] What type should be annotated? This should probably be the full type, with optional inference, as currently implemented for
bikeshed, but see Issue #154127 for discussion.
Future possibilities
Allow inference via function return type or variable binding
For cases such as
where the errors involved all implement Into<Error1>