An alternative proposal to try/catch/throw error handling
There is currently a lot of discussion going on about the future of
Rust error handling. The general direction seems towards error handling
that looks like it does in exception based languages.
Bias Warning: Personally, I’m opposed to such changes, and would like to build a
counter-proposal.
First, some links to the current/previous discussions and posts:
A rough outline of the things that are in the pipeline or have been
brought up in the past as possibilities would be:
-
try/catch blocks for catching and handling errors.
-
throw or fail for raising errors to some catching scope.
-
? also raising towards some catching scope.
- Auto-converting final expression results to
Ok or Some.
My goals with this proposal are the following:
- Users wishing to use these semantics can easily opt-in to them.
- Users who don’t want these semantics can still use the control
flow semantics in an explicit manner.
- The additional semantics can be more powerful and feature-rich
than language constructs, giving additional value to those that
do want to use them.
- Unfamiliar users are still given hints that additional semantics
might be in play.
Enhancing Rust to allow the ecosystem to provide the functionality
My proposal is: Instead of introducing special syntax for when error
handling is close to that in exception based language, we build up from
existing Rust control flow. This would allow all custom semantics to be
built using macros. Given the existance of break-with-value, this would
simply amount to adding a special block label.
This has a couple of advantages:
-
The semantics can be experimented with and iterated on by the
ecosystem.
-
If such functionality is indeed wanted without extra dependencies at
some point once things have settled, they can be included in std.
-
It is much easier to make future adjustments and improvements to
macros than it is to change language constructs. There is a massive
difference between iterating core features, and iterating a library
on crates.io, with the latter allowing a lot more freedom and
flexibility.
-
Before the new semantics find wide adoption, there is an explicit
opt-in with a dependency.
-
Even if the macros end up in std, the additional semantics are
still being signalled by them being macro functionality. Rust
developers are already trained to expect additional semantics from
macros.
-
The provided semantics can be a lot richer than is possible with builtin
language syntax in the current context of error propagation.
For example: There can be
auto-converting/non-converting alternatives, allowing one to skip result
type hints when the conversions aren’t necessary. Note: See
this section of the "throw expression" proposal on
a possible exception for propagating through scopes where types don’t
match and the jump destination is determined by types.
-
If some of the additional functionality turns out useful/wide spread
enough to get it’s own syntax, it can still be done, but based on
actual semantics of the usage in the wild. This is similar to how
try! used to encapsulate a common use-case, and turned into ?
once things had settled.
-
It is easy for new macros that are more special cased to be introduced
along-side the existing ones, while still remaining consistent.
-
It allows a bigger picture of error handling control flow requirements
to develop in the community before anything is made part of the core
language.
But there would also be advantages to users who opt not to use this
kind of semantics:
-
The additional functionality provided by Rust used to implement the
macros is also available to other parts of the ecosystem.
-
They are useful by itself even when not used by macros. As they allow
early-escape from scopes in many scenarios, and are not limited to the
success/error paradigm due to the lack of implicit auto-conversion.
-
The new functionality required to implement the macros in the ecosystem
is very limited, so the core language surface doesn’t increase by much.
Introducing a built-in 'escape block label
This is basically the only change that is required. There would be an
implicit 'escape label at the function boundary, but developers can
also put it on a block inside the function to limit where ? propagates
to.
In essence, this would simply introduce an explicitly specifiable
target block for ?.
The usefulness of this would be provided by combining it with the
break-with-value feature.
An interesting note is that the trait-based exception handling RFC that
introduced ? actually uses break-with-value to explain
semantics.
This would also further minimize breakage, as label identifiers are a
non-majority subset of used identifiers.
The label 'try has come up as a possibility in the past. This
would also be a good possibility. A 'try label also wouldn’t suffer
from too much exception association I believe, seeing how it looks
different than the construct in exception based languages.
Exception-like syntax as macros on top.
The new label would allow implementing the exception like syntax as
macros on top:
-
A catching block macro with auto-conversion would use an 'escape block
that applies the Try conversion to its result on the inside. It could include ways
to give type-hints (which are often necessary with always-on
conversions) or do error transforms as part of it’s syntax. It could
also provide a non-converting alternative. Due to
it being a macro it can be a lot more flexible with those features.
-
A throwing macro can use break 'escape Err(value).
Conversion semantics could be applied as well, or converting and
non-converting alternatives provided. This function would be able
to throw to both a catching block and the function boundary.
Bike-shedding
Fortunately, there is very little of that. Just the name of the
'escape block to reserve.
Examples of direct feature use
Limiting Error Propagation
let result: Result<_, MyError> = 'escape: {
let item_a = calc_a()?;
let item_b = calc_b()?;
Ok(combine(item_a, item_b)?)
};
Optional operations in sequences
let final: Option<_> = 'escape: {
let mut sum = 0;
for item in items {
sum += item.get_value()?;
}
Some(sum)
};
Searching for an item
let item = 'escape: {
for item in items {
let data = verify(item)?;
if matches(data) {
break 'escape Ok(data);
}
}
Err(MyError::NotFound)
};
Examples of implementable macros
A catching block with final-result conversion
macro_rules! catch {
($($body:tt)*) => {
'escape: { ::std::ops::Try::from_ok({ $($body)* }) }
}
}
let result = catch! { a? + b? };
An error throwing macro with final-result conversion
macro_rules! throw {
($value:expr) => {
break 'escape ::std::ops::Try::from_error($value)
}
}
fn open(path: &Path) -> Result<File, Error> {
match File::open(path) {
Ok(file) => Ok(file),
Err(io_err) => {
log("IO error occured");
throw!(io_error);
},
}
}
Finalising a block with a success value
macro_rules! final {
($value:expr) => {
break 'escape ::std::ops::Try::from_ok($value)
}
}
let value: Option<_> = catch! {
if let Some(cached) = cache.get(&id) {
final!(cached);
}
let new = calc();
cache.insert(id, new);
new
};
Other possibilities for macros
Note: The names aren’t final suggestions and merely serve as
illustration. The list is also likely not exhaustive.
// catch without conversion
catch_plain! { ... }
// catch with preset Result<_, _> hint
fallible! { ... }
// catch with preset Option<_> hint
optional! { ... }
// catch with error as converted final result
attempt! { ... }
// catch with preset Result<_, Error>
catch_error!(Error { ... })
// catch with preset Result<Success, Failure>
catch_result!(Success or Failure { ... })
// catch with preset Option<Value>
catch_option!(Value { ... })
// throwing without conversion
catch_plain!(...)
// providing a final value without conversion
final_plain!(...)
// optionally throwing an Option<Error>
throw_if_some!(...)
// Finalizing an optional value
final_if_some!(...)
// Inline mapping of an error
handle!(do { ... } match err { ... })
// Inline mapping and hinting of an error
handle!(do { ... } match err: Error { ... })
// Special case for failure crate removing need to type hint errors
catch_failure! { ... }
Summary
I believe adding 'escape functionality to blocks would allow us to pause on our way to try/catch/throw syntax, and gain experience before things are finalized as core language syntax.
With this post I’m hoping to find out if there would be enough support for turning this into an RFC and considering it an alternative (for the immediate future, at least).