Rust needs #[throws]
Dec. 16th, 2022 06:35 pm![[personal profile]](https://www.dreamwidth.org/img/silk/identity/user.png)
tl;dr:
Ok
-wrapping as needed in today’s Rust is a significant distraction, because there are multiple ways to do it. They are all slightly awkward in different ways, so are least-bad in different situations. You must choose a way for every fallible function, and sometimes change a function from one pattern to another.
Rust really needs #[throws]
as a first-class language feature. Code using #[throws]
is simpler and clearer.
Please try out withoutboats’s fehler
. I think you will like it.
Contents
- A recent personal experience in coding style
- What is
Ok
wrapping? Intro to Rust error handling - A minor inconvenience, or a significant distraction?
- What is to be done, then?
- Appendix - examples showning code with
Ok
wrapping is worse than code using#[throws]
A recent personal experience in coding style
Ever since I read withoutboats’s 2020 article about fehler
, I have been using it in most of my personal projects.
For Reasons I recently had a go at eliminating the dependency on fehler
from Hippotat. So, I made a branch, deleted the dependency and imports, and started on the whack-a-mole with the compiler errors.
After about a half hour of this, I was starting to feel queasy.
After an hour I had decided that basically everything I was doing was making the code worse. And, bizarrely, I kept having to make individual decisons about what idiom to use in each place. I couldn’t face it any more.
After sleeping on the question I decided that Hippotat would be in Debian with fehler
, or not at all. Happily the Debian Rust Team generously helped me out, so the answer is that fehler
is now in Debian, so it’s fine.
For me this experience, of trying to convert Rust-with-#[throws]
to Rust-without-#[throws
] brought the Ok
wrapping problem into sharp focus.
What is Ok
wrapping? Intro to Rust error handling
(You can skip this section if you’re already a seasoned Rust programer.)
In Rust, fallibility is represented by functions that return Result<SuccessValue, Error>
: this is a generic type, representing either whatever SuccessValue
is (in the Ok
variant of the data-bearing enum) or some Error
(in the Err
variant). For example, std::fs::read_to_string
, which takes a filename and returns the contents of the named file, returns Result<String, std::io::Error>
.
This is a nice and typesafe formulation of, and generalisation of, the traditional C practice, where a function indicates in its return value whether it succeeded, and errors are indicated with an error code.
Result
is part of the standard library and there are convenient facilities for checking for errors, extracting successful results, and so on. In particular, Rust has the postfix ?
operator, which, when applied to a Result
, does one of two things: if the Result
was Ok
, it yields the inner successful value; if the Result
was Err
, it returns early from the current function, returning an Err
in turn to the caller.
This means you can write things like this:
let input_data = std::fs::read_to_string(input_file)?;
and the error handling is pretty automatic. You get a compiler warning, or a type error, if you forget the ?
, so you can’t accidentally ignore errors.
But, there is a downside. When you are returning a successful outcome from your function, you must convert it into a Result
. After all, your fallible function has return type Result<SuccessValue, Error>
, which is a different type to SuccessValue
. So, for example, inside std::fs::read_to_string
, we see this:
let mut string = String::new();
file.read_to_string(&mut string)?;
Ok(string)
}
string
has type String
; fs::read_to_string
must return Result<String, ..>
, so at the end of the function we must return Ok(string)
. This applies to return
statements, too: if you want an early successful return from a fallible function, you must write return Ok(whatever)
.
This is particularly annoying for functions that don’t actually return a nontrivial value. Normally, when you write a function that doesn’t return a value you don’t write the return type. The compiler interprets this as syntactic sugar for -> ()
, ie, that the function returns ()
, the empty tuple, used in Rust as a dummy value in these kind of situations. A block ({ ... }
) whose last statement ends in a ;
has type ()
. So, when you fall off the end of a function, the return value is ()
, without you having to write it. So you simply leave out the stuff in your program about the return value, and your function doesn’t have one (i.e. it returns ()
).
But, a function which either fails with an error, or completes successfuly without returning anything, has return type Result<(), Error>
. At the end of such a function, you must explicitly provide the success value. After all, if you just fall off the end of a block, it means the block has value ()
, which is not of type Result<(), Error>
. So the fallible function must end with Ok(())
, as we see in the example for std::fs::read_to_string
.
A minor inconvenience, or a significant distraction?
I think the need for Ok
-wrapping on all success paths from fallible functions is generally regarded as just a minor inconvenience. Certainly the experienced Rust programmer gets very used to it. However, while trying to remove fehler
’s #[throws]
from Hippotat, I noticed something that is evident in codebases using “vanilla” Rust (without fehler
) but which goes un-remarked.
There are multiple ways to write the Ok
-wrapping, and the different ways are appropriate in different situations.
See the following examples, all taken from a real codebase. (And it’s not just me: I do all of these in different places, - when I don’t have fehler
available - but all these examples are from code written by others.)
Idioms for Ok
-wrapping - a bestiary
Wrap just a returned variable binding
If you have the return value in a variable, you can write Ok(reval)
at the end of the function, instead of retval
.
pub fn take_until(&mut self, term: u8) -> Result<&'a [u8]> {
// several lines of code
Ok(result)
}
If the returned value is not already bound to variable, making a function fallible might mean choosing to bind it to a variable.
Wrap a nontrivial return expression
Even if it’s not just a variable, you can wrap the expression which computes the returned value. This is often done if the returned value is a struct literal:
fn take_from(r: &mut Reader<'_>) -> Result<Self> {
// several lines of code
Ok(AuthChallenge { challenge, methods })
}
Introduce Ok(())
at the end
For functions returning Result<()>
, you can write Ok(())
.
This is usual, but not ubiquitous, since sometimes you can omit it.
Wrap the whole body
If you don’t have the return value in a variable, you can wrap the whole body of the function in Ok(
…)
. Whether this is a good idea depends on how big and complex the body is.
fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
Ok(match s {
"Authority" => RelayFlags::AUTHORITY,
// many other branches
_ => RelayFlags::empty(),
})
}
Omit the wrap when calling fallible sub-functions
If your function wraps another function call of the same return and error type, you don’t need to write the Ok
at all. Instead, you can simply call the function and not apply ?
.
You can do this even if your function selects between a number of different sub-functions to call:
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
if flags::unsafe_logging_enabled() {
std::fmt::Display::fmt(&self.0, f)
} else {
self.0.display_redacted(f)
}
}
But this doesn’t work if the returned error type isn’t the same, but needs the autoconversion implied by the ?
operator.
Convert a fallible sub-function error with Ok( ... ?)
If the final thing a function does is chain to another fallible function, but with a different error type, the error must be converted somehow. This can be done with ?
.
fn try_from(v: i32) -> Result<Self, Error> {
Ok(Percentage::new(v.try_into()?))
}
Convert a fallible sub-function error with .map_err
Or, rarely, people solve the same problem by converting explicitly with .map_err
:
pub fn create_unbootstrapped(self) -> Result<TorClient<R>> {
// several lines of code
TorClient::create_inner(
// several parameters
)
.map_err(ErrorDetail::into)
}
What is to be done, then?
The fehler
library is in excellent taste and has the answer. With fehler
:
Whether a function is fallible, and what it’s error type is, is specified in one place. It is not entangled with the main return value type, nor with the success return paths.
So the success paths out of a function are not specially marked with error handling boilerplate. The end of function return value, and the expression after
return
, are automatically wrapped up inOk
. So the body of a fallible function is just like the body of an infallible one, except for places where error handling is actually involved.Error returns occur through
?
error chaining, and with a new explicit syntax for error return.We usually talk about the error we are possibly returning, and avoid talking about
Result
unless we need to.
fehler
provides:
An attribute macro
#[throws(ErrorType)]
to make a function fallible in this way.A macro
throws!(error)
for explicitly failing.
This is precisely correct. It is very ergonomic.
Consequences include:
One does not need to decide where to put the
Ok
-wrapping, since it’s automatic rather than explicitly written out.Specifically, what idiom to adopt in the body (for example
{write!(...)?;}
vs{write!(...)}
in a formatter) does not depend on whether the error needs converting, how complex the body is, and whether the final expression in the function is itself fallible.Making an infallible function fallible involves only adding
#[throws]
to its definition, and?
to its call sites. One does not need to edit the body, or the return type.Changing the error returned by a function to a suitably compatible different error type does not involve changing the function body.
There is no need for a local
Result
alias shadowingstd::result::Result
, which means that when one needs to speak ofResult
explciitly, the code is clearer.
Limitations of fehler
But, fehler is a Rust procedural macro, so it cannot get everything right. Sadly there are some wrinkles.
You can’t write
#[throws]
on a closure.Sometimes you can get quite poor error messages if you have a sufficiently broken function body.
Code inside a macro call isn’t properly visible to
fehler
so sometimesreturn
statements inside macro calls are untreated. This will lead to a type error, so isn’t a correctness hazard, but it can be nuisance if you like other syntax extensions egif_chain
.#[must_use] #[throws(Error)] fn obtain() -> Thing;
ought to mean thatThing
must be used, not theResult<Thing, Error>
.
But, Rust-with-#[throws]
is so much nicer a language than Rust-with-mandatory-Ok
-wrapping, that these are minor inconveniences.
Please can we have #[throws]
in the Rust language
This ought to be part of the language, not a macro library. In the compiler, it would be possible to get the all the corner cases right. It would make the feature available to everyone, and it would quickly become idiomatic Rust throughout the community.
It is evident from reading writings from the time, particularly those from withoutboats, that there were significant objections to automatic Ok
-wrapping. It seems to have become quite political, and some folks burned out on the topic.
Perhaps, now, a couple of years later, we can revisit this area and solve this problem in the language itself ?
“Explicitness”
An argument I have seen made against automatic Ok
-wrapping, and, in general, against any kind of useful language affordance, is that it makes things less explicit.
But this argument is fundamentally wrong for Ok
-wrapping. Explicitness is not an unalloyed good. We humans have only limited attention. We need to focus that attention where it is actually needed. So explicitness is good in situtions where what is going on is unusual; or would otherwise be hard to read; or is tricky or error-prone. Generally: explicitness is good for things where we need to direct humans’ attention.
But Ok
-wrapping is ubiquitous in fallible Rust code. The compiler mechanisms and type systems almost completely defend against mistakes. All but the most novice programmer knows what’s going on, and the very novice programmer doesn’t need to. Rust’s error handling arrangments are designed specifically so that we can avoid worrying about fallibility unless necessary — except for the Ok
-wrapping. Explicitness about Ok
-wrapping directs our attention away from whatever other things the code is doing: it is a distraction.
So, explicitness about Ok
-wrapping is a bad thing.
Appendix - examples showning code with Ok
wrapping is worse than code using #[throws]
Observe these diffs, from my abandoned attempt to remove the fehler
dependency from Hippotat.
I have a type alias AE
for the usual error type (AE
stands for anyhow::Error
). In the non-#[throws]
code, I end up with a type alias AR<T>
for Result<T, AE>
, which I think is more opaque — but at least that avoids typing out -> Result< , AE>
a thousand times. Some people like to have a local Result
alias, but that means that the standard Result
has to be referred to as StdResult
or std::result::Result
.
With fehler and #[throws]
|
Vanilla Rust, Result<> , mandatory Ok-wrapping
|
---|---|
|
|
Return value clearer, error return less wordy: | |
impl Parseable for Secret {
|
impl Parseable for Secret {
|
#[throws(AE)]
|
|
fn parse(s: Option<&str>) -> Self {
|
fn parse(s: Option<&str>) -> AR<Self> {
|
let s = s.value()?;
|
let s = s.value()?;
|
if s.is_empty() { throw!(anyhow!(“secret value cannot be empty”)) }
|
if s.is_empty() { return Err(anyhow!(“secret value cannot be empty”)) }
|
Secret(s.into())
|
Ok(Secret(s.into()))
|
}
|
}
|
…
|
…
|
No need to wrap whole match statement in Ok( ):
| |
#[throws(AE)]
|
|
pub fn client<T>(&self, key: &’static str, skl: SKL) -> T
|
pub fn client<T>(&self, key: &’static str, skl: SKL) -> AR<T>
|
where T: Parseable + Default {
|
where T: Parseable + Default {
|
match self.end {
|
Ok(match self.end {
|
LinkEnd::Client => self.ordinary(key, skl)?,
|
LinkEnd::Client => self.ordinary(key, skl)?,
|
LinkEnd::Server => default(),
|
LinkEnd::Server => default(),
|
}
|
})
|
…
|
…
|
Return value and Ok(()) entirely replaced by #[throws] :
| |
impl Display for Loc {
|
impl Display for Loc {
|
#[throws(fmt::Error)]
|
|
fn fmt(&self, f: &mut fmt::Formatter) {
|
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
|
write!(f, “{:?}:{}”, &self.file, self.lno)?;
|
write!(f, “{:?}:{}”, &self.file, self.lno)?;
|
if let Some(s) = &self.section {
|
if let Some(s) = &self.section {
|
write!(f, “ ”)?;
|
write!(f, “ ”)?;
|
…
|
…
|
}
|
}
|
|
Ok(())
|
}
|
}
|
Call to write! now looks the same as in more complex case shown above:
| |
impl Debug for Secret {
|
impl Debug for Secret {
|
#[throws(fmt::Error)]
|
|
fn fmt(&self, f: &mut fmt::Formatter) {
|
fn fmt(&self, f: &mut fmt::Formatter)-> fmt::Result {
|
write!(f, "Secret(***)")?;
|
write!(f, "Secret(***)")
|
}
|
}
|
}
|
}
|
Much tiresome return Ok() noise removed:
| |
impl FromStr for SectionName {
|
impl FromStr for SectionName {
|
type Err = AE;
|
type Err = AE;
|
#[throws(AE)]
|
|
fn from_str(s: &str) -> Self {
|
fn from_str(s: &str) ->AR< Self> {
|
match s {
|
match s {
|
“COMMON” => return SN::Common,
|
“COMMON” => return Ok(SN::Common),
|
“LIMIT” => return SN::GlobalLimit,
|
“LIMIT” => return Ok(SN::GlobalLimit),
|
_ => { }
|
_ => { }
|
};
|
};
|
if let Ok(n@ ServerName(_)) = s.parse() { return SN::Server(n) }
|
if let Ok(n@ ServerName(_)) = s.parse() { return Ok(SN::Server(n)) }
|
if let Ok(n@ ClientName(_)) = s.parse() { return SN::Client(n) }
|
if let Ok(n@ ClientName(_)) = s.parse() { return Ok(SN::Client(n)) }
|
…
|
…
|
if client == “LIMIT” { return SN::ServerLimit(server) }
|
if client == “LIMIT” { return Ok(SN::ServerLimit(server)) }
|
let client = client.parse().context(“client name in link section name”)?;
|
let client = client.parse().context(“client name in link section name”)?;
|
SN::Link(LinkName { server, client })
|
Ok(SN::Link(LinkName { server, client }))
|
}
|
}
|
}
|
}
|
(no subject)
Date: 2022-12-18 06:23 pm (UTC)(no subject)
Date: 2022-12-19 11:58 am (UTC)When I wrote similar wrappers for C# I ended up writing an implicit conversion between T and Result, but it was also pretty kludgy (and Rust doesn't support implicit conversions, of course). Having something like that built in to the language would make life a lot easier.
Can´t really see the issue
Date: 2022-12-22 09:18 am (UTC)I find all the examples without the Ok to be less readable and if I had to maintain such code I would have problems having to discern when values are being packaged to be returned. Having the Ok construct makes thing quite easier. Specially if people are going to have huge match statements that actually output the function's result.
I rather have things explicitly and being reminded by the compiler than implicitly and then wondering why things are not working out to then have to deal with a Macro that is doing stuff to my code...
Luckily we have crates (would not make this statement in C/C++ land) and if you want this kind of magical macros you can just import it as you do now.
(no subject)
Date: 2022-12-23 09:33 am (UTC)Rust is expression-based. Rust leans towards declarative code.
Result is an algebraic sum type that gives you the full power of Monads in a mathematically correct model bound by laws.
You lose all of this, for what? To save yourself from having to write two letters? So you don’t have to bother with purity?
Throw/catch is just more imperative sugaring and control flow. If you want this, Rust isn’t the langauge for you.
Fine for apps, not for libraries
From: (Anonymous) - Date: 2023-01-09 06:09 pm (UTC) - Expand