If your API already maps to orthogonal sets of errors, or if it's in active development/iteration, you might not get much value from this. But good & specific error types are great documentation for helping developers understand "what can go wrong," and the effects compound with layers of abstraction.
it isn't always the case, of course, but it also isn't always NOT the case.
Maybe it would make sense to consider the API a function is presenting when making errors for it; if an error is related to an implementation detail, maybe it doesn't belong in the public API. If an error does relate to the public function's purpose (FileNotFound for a function that reads config), then it has a place there.
When in doubt, I tend to prefer "wrapper style" errors for libraries, so the caller can match at whatever level of specificity they care about. As a toy example:
match read_config() {
Err(ConfigError(FileNotFound) => println!("No configuration file found. Supported config directories are: {}", get_config_dirs()),
Err(ConfigError(IOError(filename, msg))) => println!("Access error for config file {}: {}", filename, msg),
Err(ConfigError(ParseError(e))) => println!("Parse error: {:?}", e),
Err(ConfigError(e)) => println!("configuration error: {:?}", e),
Ok(config) => {...},
}
The calling application could start with just the generic error message at the end, and over time decide to add more useful behavior for some specific cases.Of course, then the problem becomes "what are the useful or natural groupings" for the error messages.
Something I didn't find an ergonomic solution to though was pulling out common errors; I wanted to say, pull out any std::io errors that occurred at the top level, but all I found was to match and expand every error case to pull them out.
I considered maybe a derive/trait based approach would work, but it was too big a hammer for that project.
The point there is the error is not just an error in isolation, but it has an attached error category as well. And the error categories can compare errors from other categories for equivalence.
So for example, say you have an error which contains (status_404, http_result_category), then you can compare that instance with (no_such_file_or_directory, generic_category), and because http_result_category knows about generic_category, it can handle the comparison and say that these are equivalent[2].
This allows you to preserve the quite detailed errors while also using them to handle generic error conditions further up.
That said, doing this for every function... sounds tedious, so perhaps not worth it.
[1]: https://www.boost.org/doc/libs/1_88_0/libs/system/doc/html/s...
[2]: https://www.boost.org/doc/libs/1_88_0/libs/system/doc/html/s...
Though, I suppose with something broader like IOException the situation is different.
try {
open file
read some bytes
read some more bytes
}
makes sense, as they all relate to the same underlying resource being in a good state or not.The main page of the doc explains pretty well how things can be wrapped up: https://crates.io/crates/error_set You define the data structure, and it'll take care of generating the From impls for you, so you can generally just do `f()?` when you don't care about the specifics, and `match f1()` and destructure when you do.
This means that each function only cares about its own error, and how to generate it. And doesn’t require macros. Just thiserror.
You would most likely have had to navigate up and down the caller chain regardless of how you scope errors.
At least this way the compiler tells you when you forgot to handle a new error case, and where.
You can avoid this issue if you deeply nest the error types (wrap on every level). It you change an error that's not deeply-matched anywhere, you only need to update the direct callers. But "deep" errors have some tradeoffs [1] too
Open sum types? I’m on the fence as to whether they should be inferrable.
The whole point (in my mind at least) of type safe errors is to know in advance all if the failure modes of a function. If you share an error enum across many functions, it no longer serves that purpose, as you have errors that exist in the type but are never returned by the function.
It would be nice if the syntax made it easier though. It's cumbersome to create new enums and implement Error for each of them.
Like, function 1 fails for reason A or B. Function 2 fails for A or C. You call both functions. How do you pattern match on reason A, in the result of your function?
In Go, there’s a somewhat simple pattern for this, which is errors.Is().
struct Error {
leaf_error: A | B | C,
context: Vec<String>,
}For example, let's say the function frobnicate() writes to a file and might get all sorts of file errors (disk full, no permissions, etc.). It seems like those have to be wrapped or embedded, as the article suggests.
But then you can't use the "?" macro, I think? Because wrapping or embedding requires constructing a new error value from the original one. Every single downstream error coming from places like the standard library has to go through a transformation with map_err() or similar.
You can use ? when you implement From (to get an automatic Into) which which can be as easy as a #[from] annotation with thiserror. You can manually implement From instead of inlining the map_err if you so choose. Then you are only ever using map_err to pass additional contextual information. You usually end up using ? after map_err or directly returning the result.
It may be that you do in fact have to throw your hands up, and propagate _something_ to the caller; in those cases, I find a Box<dyn Error> or similar better maintains the abstraction boundary.
The only thing I disagree with is error wrapping. While I agree that the main error should not expose dependencies, I find it useful to keep a `cause` field that corresponds to the inner error. It's important to trace the origin of an error and have more context about it. By the way, Rust's [Error] trait has a dedicated `cause` method for that!
[Error] https://doc.rust-lang.org/nightly/core/error/trait.Error.htm...
It is the most common approach, hence, status quo.
> I create one error type per function/action. I discovered this here on HN after an article I cannot find right now was posted.
I like your approach, and think it's a lot better (including for the reasons described in this article). Sadly, there's still very few of us taking this approach.
I do too! I've been debating whether I should update SNAFU's philosophy page [1] to mention this explicitly, and I think your comment is the one that put me over the edge for "yes" (along with a few child comments). Right now, it simply says "error types that are scoped to that module", but I no longer think that's strong enough.
[1]: https://docs.rs/snafu/latest/snafu/guide/philosophy/index.ht...
Your answers on stack overflow and your crates have helped me so much. Thank you!
If it internally has a `InnerFuncErr::WriteFailed` error, you might handle it, and then you don't have to pass it back at all, or you might wrap it in an `OuterFuncErr::BadIo(inner_err)`or throw it away and make `BadIo` parameterless, if you feel that the caller won't care anyways.
Errors are not Exceptions, you don't fling them across half of your codebase until they crash the process, you try to diligently handle them, and do what makes sense.
So you don't really care about the union.
Your function typically has a specific intent when it tries to call another function. Say that you have a poorly designed function that reads from a file, parses the data, opens a DB connection and stores the data.
Should I really expect an end-user to understand an error generated by diesel/postgres/wtfdb? No, most likely I want to instruct them to generate debug logs and report an issue/contact support. This is most likely the best user experience for an application. In this case, each "action" of the function would "hide" the underlying error––it might provide information about what failed (file not found, DB rejected credentials, what part of the file couldn't be parsed, etc), but the user doesn't care (and shouldn't!) about Rust type of diesel error was generated.
To answer your question specifically, I might go without something like this:
#[derive(Debug, Error, Clone)]
pub enum MyFunctionError {
#[error("unable to read data from file: {0}")]
ReadData(String),
#[error("failed to parse data: {0}")]
Parse(String),
#[error("database refused our connection: {0} (host: {1}, username: {2})")]
DatabaseConnection(String, String, String),
#[error("failed to write rows: {0}")]
WriteData(String),
}
Obviously this is a contrived example. I wouldn't use `#[from]` and just use `.map_err` to give internal meaning to error provenances. `DatabaseConnection` and `WriteData` might have come from the same underlying WTFDbError, but I can give it more meaning by annotating it.When building a library, however, yes, I most likely do want to use `#[from] io::Error` syntax and let the calling library figure out what to do (which might very well giving the user a userful error message and dumping an error log).
It could be this one: https://sabrinajewson.org/blog/errors
I feel like structural typing or anonymous sum types would solve this problem without macros.
I mean given A = X | Y and B = X | Y | Z, surely the compiler can tell that every A is a B?
That being said, I think a limited version of the feature that disallows the the same type with divergent lifetimes or even all generics would still be useful and well liked.
Especially attribute style macros which apply to variants of an enum allow a lot of code expansion which reduces boilerplate a lot.
Debugging, especially when it lacks the tooling of a Lisp or Scheme REPL/IDE.
Rust probably should have had a set of standard error traits one could specialize, but Rust is not a good language for what's really an object hierarchy.
Error handling came late to Rust. It was years before "?" and "anyhow". "Result" was a really good idea, but "Error" doesn't do enough.
let mut x = 1
x = false
is x a usize? a bool? a usize | bool? let mut x = if some_condition { 1 } else { false }
is x x a usize? a bool? a usize | bool?One could make inference rules about never inserting unions without explicit intervention of user types. But then you get into (IMO) some messy things downstream of Rust's pervasive "everything is an expression" philosophy.
This is less of a problem in Typescript because you are, generally, much less likely to have conditionally typed expressions. There's a ternary operator, but things like `switch` are a statement.
So in production code Typescript, when presented with branching, will have a downstream explicit type to unify on. Not so much in Rust's extensive type chaining IMO. And then we start talking about Into/From and friends....
I don't really think that you want a rule like `(if cond { x: T } else { y: U}) : T | U` in general. You'll end up with _so many_ false negatives and type errors at a distance. But if you _don't_ have that rule, then I don't know how easily your error type unification would work.
Cant you do something like let mut x: Result<Either<Foo, Bar>, Error> in Rust? Same thing, just more ceremony?
Just like.... if you infer the union then all your type errors are going to shift around and you'll have to do more hunting to figure out where your stuff is. And my impression is that Rust has a lot more expression inference going on in practice than TS. But just an impression.
let mut x = 1
x = false
In TS, x is inferred as usize, second line is an error. let mut x = if some_condition { 1 } else { false }
In TS, x is inferred as usize | bool.Is there something specific to rust that makes this less clear that I'm missing?
So inferring as an untagged union is not wrong of course! It's just that if you are always inferring the type of an if expression to A | B, then this will also happen unintentionally a lot.
And so at the end of some code, when you actually use x, then you'll see an error like "expected usize, got usize | bool". In the case that this was a mistake, you're now looking at having to manually figure out why x was inferred this way.
In typescript your "if expression" is a ternary expression. Those are quite rare. In rust they're all over the place. Match statements are the same thing. Imagine having a 10 clause match statement and one of them unintentionally gives a different type. There's even just the classic "semicolon makes the branch into a ()"!
So always inferring a union across branches of an if expression or a match means that your type errors on genuine mistakes are almost never in the right spot.
Of course we can annotate intermediate values to find our way back. Annotating intermediate values in a chained expression is a bit miserable, but it is what it is.
Decent typescript tends to not have this problem because there are few syntactic structures where you need to evaluate multiple branches to figure out the type of an expression. And the one big example (return values)... well you want to be annotating the return value of your functions in general.
Rust is in a similar space for Result types, at least. But I don't think it generalizes at all. If you start inferring union types, and combine that with trait resolution, I _think_ that we'd end up with much less helpful error messages in the case of actual mistakes, because the actual location of the error will be harder to find.
let mut x = if some_condition { 1 } else { false }
// bunch of code
return f(x) // expected usize, got usize | bool
TS gets away with this stuff because JS's object model is simple (TS doesn't need to do any form of trait resolution!) and the opportunities to introduce unions implicitly are relatively few in TS code in general.And this isn't even really getting into Rust needing to actually implement untagged unions if they had them! Implicit tagging feels off in a language very serious about not having expensive hidden abstractions. But how are you going to guarantee bit layout to allow for the differentiation here?
I'm saying all of this but I'd love it if someone showed up with a good untagged union proposal to Rust, because I _like_ the concept. Just feels intractable
> So much ceremony over something that could be Foo | Bar | Error
Is not really a good idea and he can see this done in Zig.
type FooOrBarOrError = Foo | Bar | Error;
Then that could desugar to: enum FooOrBarOrError {
Foo(Foo),
Bar(Bar),
Error(Error),
}
And it could also implement From for you, so you can easily get a FooOrBarOrError from a Foo, Bar, or Error; as well as implementing Display, StdError, etc. if the components already implement them.I actually wonder if you could implement this as a proc macro...
Foo | Bar makes sense when Foo and Bar are logically similar and their primary difference is the difference in type. This is actually rather rare. One example would be a term in your favorite configuration markup language along the lines of JSON or YAML:
type Term = String | List<String>;
or perhaps a fancier recursive one: type Term = String | Box<List<Term>>;
or however you want to spell it. Here a Term is something in the language, and there are two kinds of terms: string or lists.But most of the time that I've wanted a sum type, I have a set of logical things that my type can represent, and each of those things has an associated type of the data they carry. Result types (success or error) are absolutely in that category. And doing this wrong can result in a mess. For example, if instead of Result, you have SuccessVal | Error, then the only way to distinguish success from error is to literally, or parametrically in a generic, spell out SuccessVal or Error. And there are nasty pathological cases, for example, what if you want a function that parses a string into an Error? You would want to write:
fn parse_error(input_string: &str) -> Error | Error
Whoops!Ok' and Err' as nominal type constructors which are unioned:
struct Ok<T>(T);
struct Err<E>(E);
fn parse_error(input_string: &str) -> Ok Error | Err (ErrorA | ErrorB | ErrorC...)
Or make a sum type enum Result<T, E> {
Ok(T),
Err(E),
}
fn parse_error(input_string: &str) -> Result<Error, (ErrorA | ErrorB | ErrorC...)>
The error types are unioned for easy composition but you have a top level sum type to differentiate between success and failure.I can also imagine it resulting in horrible compilation times and/or generated code bloat in a language+toolchain like Rust that insists on monomorphizing everything.
You can use anyhow::Error everywhere. If you need to "catch" a specific wrapped error, you can manually document it on the "throwing" method and downcast where you "catch". It's very similar to exceptions (checked-but-unspecific `throws Exception` in Java), but better, because Result and ? are explicit.
Every Rust project starts by looking into 3rd party libraries for error handling and async runtimes.
If we just added what 95% of projects are using to the standard library then the async runtime would be tokio, and error handling would be thiserror for making error types and anyhow for error handling.
Your ability to go look for new 3rd party libraries, as well as this article's recommendations, are examples of how Rust's careful approach to standard library additions allows the ecosystem to innovate and try to come up with new and better solutions that might not be API compatible with the status quo
color-eyre is better than anyhow.
Too much innovation gets out of control, and might not be available every platform.
As a result, I find error handling in Go to be pretty cumbersome even though the language design has progressed to a point where it theoretically could be made much more ergonomic. You can imagine a world where instead of functions returning `(x, err)` they could return `Result[T]error` - an that would open up so many more monadic apis, similar to whats in Rust. But that future seems to be completely blocked off because of the error handling patterns that are now baked into the language.
There's no guarantee the Rust team would have landed on something particularly useful. Even the entire error trait, as released, is now deprecated. `thiserror`, the most popular error crate for libraries wasn't released until 2019.
Also as you can seen by sibling comments, the beauty of 3rd party dependencies is that each dev has a different opinion what they should be, so any given project gets a bunch of them.
>Errors as values is a quite old idea, predating exceptions, mainstream just got a bit forgotten about how we used to code until early 2000's.
I'm not sure these two sentences are in disagreement with each other.
>the beauty of 3rd party dependencies is that each dev has a different opinion what they should be,
The hope is in that some time in the future, the community will eventually coalesce on a superior option with the appropriate patterns. Because the mainstream had forgotten how to use errors as values, my point is, its more likely that Rust 1.0 would have baked in a poor solution.
True, almost. Not mine
Makes me sad
Excuse me what?
> This means, that a function will return an error enum, containing error variants that the function cannot even produce.
The same problem happens with exceptions.
On no_std, I've been doing something like the author describes: Single enum error type; keeps things simple, without losing specificity, due the variants.
When I need to parse a utf-8 error or something, I use .map_err(|_| ...)
After reading the other comments in this thread, it sounds like I'm the target audience for `anyhow`, and I should use that instead.
Personally, I think I prefer thiserror style errors everywhere, but I can see some of the tradeoffs.
You use, too many, commas, in your, writing. It’s okay to have a long sentence with multiple phrases.
Thanks for sharing your thoughts.
This does add the “complexity” of there being places (crate boundaries in Rust) where you want types explicitly defined (so to infer types in one crate doesn’t require typechecking all its dependencies). TS can generate these types, and really ought to be able to check invariants on them like “no implicit any”.
Rust of course has difference constraints and hails more from Haskell’s heritage where the declared return types can impact runtime behavior instead. I find this makes Rust code harder to read unfortunately, and would avoid it if I could in Rust (it’s hard given the ecosystem and stdlib).
Which brings me to my other big gripe with Rust (and Go): the need to declare structs makes it really unwieldy to return many values (resorting to tuples, which make code more error prone and again harder to read).
I have some desire to make an RFC for limited cross-item inference within a single crate, but part of it wouldn't be needed with stabilized impl Trait in more positions. For public items I don't think the language will ever allow it, not only due to technical concerns (not wanting global inference causing compile times to explode) but also language design concerns (inferred return types would be a very big footgun around API stability for crate owners).
The author is a fan of Asterix I see :)
I don't know what the solution is. And Rust is definitely a lot better than C++ or Go. But it also hasn't hit the secret sauce final solution imho.
It's simple, really. `?` coerces errors if there's an `impl From<InnerError> for OuterError`:
fn outer() -> Result<(), OuterError> {
inner()?;
Ok(())
}
When OuterError is your own type, you can always add that impl. When it's a library type, you're at its mercy. E.g., the point of anyhow::Error is that it's designed to automatically wrap any other error. To do that, anyhow provides an impl<E> From<E> for anyhow::Error
where
E: Error + Send + Sync + 'staticIt’s many things. But simple ain’t one of them =D
> If the value is Err(e), then it will return Err(From::from(e)) from the enclosing function or closure.
Without a specific example, I can't help you further.
[1]: https://doc.rust-lang.org/stable/reference/expressions/opera...
You have to anyway.
The return type isn't to define what error variants the function can return. We already have something for that, it's called the function body. If we only wanted to specify the variants that could be returned, we wouldn't need to specify anything at all: the compiler could work it out.
No. The point of the function signature is the interface for the calling function. If that function sees an error type with foo and bar and baz variants, it should have code paths for all of them.
It's not right to say that the function cannot produce them, only that it doesn't currently produce them.
Similarly, JavaScript seems to do OK, but I miss error levels. And C seems to also have OK error conventions that aren't too bad. There's a handful of them, and they're pretty uncontroversial.
Macros seem to be wrong in every language they're used, because people can't help themselves.
It's like a red flag that the language designers were OK giving you enough rope to hang yourself with, but also actively encourage you to kill yourself because why else would you use the rope for anything else?
All of that, because Go keeps ignoring a basic feature from the 1970s [1] that allows to you express the "or" relationships (and nullability).
APIs that are easy to use incorrectly are bad APIs.
[1]: https://en.wikipedia.org/wiki/Tagged_union#1970s_&_1980s
[1] https://home.expurple.me/posts/go-did-not-get-error-handling...
I like it better then python and go.
For example,
enum ConfigError {
Io(io::Error),
Parse { line: usize, col: usize },
...
}You could argue it would be better to have a ParserError type and wrap that, and I absolutely might do that too, but they are roughly the same and that's the point. Move the abstraction into their appropriate module as the complexity requests it.
Pretty much any error crate just makes this easier and helps implement quality `Display` and other standard traits for these types.
And you don't punish your compile times.
The macro for everything folks are making Rust slow. If we tire of repetition, I'd honestly prefer checked in code gen. At least we won't repeatedly pay the penalty.
Another promising solution for that is caching proc macro expansions in the compiler.
"How I reduced (incremental) Rust compile times by up to 40%": https://web.archive.org/web/20250311042037/https://www.coder...
No dogma. If you want an error per module that seems like a good way to start, but for complex cases where you want to break an error down more, we'll often have an error type per function/struct/trait.
Huh?
#[derive(Debug, thiserror::Error)]
enum CustomError {
#[error("failed to open a: {0}")]
A(std::io::Error),
#[error("failed to open b: {0}")]
B(std::io::Error),
}
fn main() -> Result<(), CustomError> {
std::fs::read_to_string("a").map_err(CustomError::A)?;
std::fs::read_to_string("b").map_err(CustomError::B)?;
Ok(())
}
If I understand correctly, the main feature of snafu is "merely" reducing the boilerplace when adding context: low_level_result.context(ErrorWithContextSnafu { context })?;
// vs
low_level_result.map_err(|err| ErrorWithContext { err, context })?;
But to me, the win seems to small to justify the added complexity. low_level_result.context(ErrorWithContextSnafu { context })?;
low_level_result.map_err(|err| CustomError::ErrorWithContext { err, context })?;
Other small details:- You don't need to move the inner error yourself.
- You don't need to use a closure, which saves a few characters. This is even true in cases where you have a reference and want to store the owned value in the error:
#[derive(Debug, Snafu)]
struct DemoError { source: std::io::Error, filename: PathBuf }
let filename: &Path = todo!();
result.context(OpenFileSnafu { filename })?; // `context` will change `&Path` to `PathBuf`
- You can choose to capture certain values implicitly, such as a source file location, a backtrace, or your own custom data (the current time, a global-ish request ID, etc.)----
As an aside:
#[error("failed to open a: {0}")]
It is now discouraged to include the text of the inner error in the `Display` of the wrapping error. Including it leads to duplicated data when printing out chains of errors in a nicer / structured manner. SNAFU has a few types that work to undo this duplication, but it's better to avoid it in the first place.
jgilias•7mo ago
I’m getting a bit of a macro fatigue in Rust. In my humble opinion the less “magic” you use in the codebase, the better. Error enums are fine. You can make them as fine-grained as makes sense in your codebase, and they end up representing a kind of an error tree. I much prefer this easy to grok way to what’s described in the article. I mean, there’s enough things to think about in the codebase, I don’t want to spend mental energy on thinking about a fancy way to represent errors.
Waterluvian•7mo ago
Please correct me if I’m misunderstanding this, but something that surprised me about Rust was how there wasn’t a guaranteed “paper trail” for symbols found in a file. Like in TypeScript or Python, if I see “Foo” I should 100% expect to see “Foo” either defined or imported in that specific file. So I can always just “walk the paper trail” to understand where something comes from.
Or I think there was also a concept of a preamble import? Where just by importing it, built-ins and/or other things would gain additional associated functions or whatnot.
In general I just really don’t like the “magic” of things being within scope or added to other things in a manner that it’s not obvious.
(I’d love to learn that I’m just doing it wrong and none of this is actually how it works in Rust)
jgilias•7mo ago
itishappy•7mo ago
Waterluvian•7mo ago
So maybe what I’m remembering about Rust was just seeing a possible but bad convention that’s not really used much.
57473m3n7Fur7h3•7mo ago
Still generally prefer the plain non-macro declarations for structs and enums though because I can easily read them at a glance, unlike when “go to definition” brings me to some macro thing.
burnt-resistor•7mo ago
Declarative macros (macro_rules) should be used to straightforwardly reduce repetitive, boilerplate code generation and making complex, messy things simpler.
Procedural macros (proc_macro) allow creating arbitrary, "unhygienic" code that declarative macros forbid and also custom derive macros and such.
But it all breaks down when use of a library depends too much on magic code generation that cannot be inspected. And now we're back to dynamic language (Ruby/Python/JS) land with opaque, tinkering-hostile codebases that have baked-in complexity and side-effects.
Use magic where appropriate, but not too much of it, is often the balance that's needed.
quotemstr•7mo ago
Overuse of macros is a symptom of missing language capabilities.
My biggest disappointment in Rust (and probably my least popular opinion) is how Rust botched error handling. I think non-local flow control (i.e. exceptions) with automated causal chaining (like Python) is a good language design point and I think Rust departed from this good design point prematurely in a way that's damaged the language in unfixable ways.
IOW, Rust should have had _only_ panics, and panic objects should have had rich contextual information, just like Java and Python. There should also have been an enforced "does not panic" annotation like noexcept in C++. And Drop implementations should not be allowed to panic. Ever.
God, I hope at least yeet gets in.
zbentley•7mo ago
It could have gone that way, but that would have “fattened” the runtime and overhead of many operations, making rust unsuitable for some low-overhead-needed contexts that it chose to target as use-cases. More directly: debug and stack info being tracked on each frame has a cost (as it does in Java and many others). So does reassembling that info by taking out locks and probing around the stack to reassemble a stack trace (C++). Whether you agree with Rust’s decision to try to serve those low-overhead niches or not, that (as I understand it) is a big part of the reason for why errors work the way they do.
> There should also have been an enforced "does not panic" annotation like noexcept in C++. And Drop implementations should not be allowed to panic.
I sometimes think that I’d really love “nopanic”. Then I consider everything that could panic (e.g. allocating) and I like it less. I think that heavy use of such a feature would lead to people just giving up and calling abort() in library code in order to be nopanic-compatible, which is an objectively worse outcome than what we have today.
quotemstr•7mo ago
So add an option not to collect the debugging information. The core exception mechanism remains.
> Whether you agree with Rust’s decision to try to serve those low-overhead niches or no
It's not a matter of Rust choosing to serve those niches or not. It's the language designers not adequately considering ways to have exceptions and serve these niches. There's no contradiction: it's just when Rust was being designed, it was _fashionable_ to eschew exceptions.
> Then I consider everything that could panic (e.g. allocating) and I like it less. I think that heavy use of such a feature would lead to people just giving up and calling abort() in library code in order to be nopanic-compatible,
Huh? We don't see people write "noexcept" everywhere in C++ to be noexcept-compatible or something. Nopanic is for cleanup code or other code that needs to be infallible. Why would most code need to be infallible? I mean, panic in Drop is already very bad, so Rust people know how to write infallible code. The no-failure property deserves a syntactic marker.
dontlaugh•7mo ago
Rust got errors right, with the possible exception of stdlib Error types.
quotemstr•7mo ago
https://www.open-std.org/jtc1/sc22/wg21/docs/papers/2019/p07...
Your post is a fantastic example of the problem I'm talking about: you're conflating a concept with one implementation of the concept and throwing away the whole concept.
Language design and implementation are different things, and as an industry, we used to understand that.
Expurple•7mo ago
Agree.
> I think non-local flow control (i.e. exceptions) with automated causal chaining (like Python) is a good language design point
Stronly disagree: https://home.expurple.me/posts/rust-solves-the-issues-with-e...
> There should also have been an enforced "does not panic" annotation like noexcept in C++.
noexcept DOES NOT mean that the function can't throw an exception! It just means that, when it does, it aborts the program instead of unwinding into the calling function. Quoting cppreference [1]:
> Non-throwing functions are permitted to call potentially-throwing functions. Whenever an exception is thrown and the search for a handler encounters the outermost block of a non-throwing function, the function std::terminate is called
> And Drop implementations should not be allowed to panic. Ever.
Should `panic=abort` panics be allowed in Drop? They are effectively the same as std::process::exit. Do you want to mark and ban that too?
[1]: https://en.cppreference.com/w/cpp/language/noexcept_spec.htm...
quotemstr•7mo ago
50% at least of these tiresome "here's why exceptions suck" articles begin by talking about how "try" is un-ergonomic. The people writing these things misunderstand exceptions, probably never having actually used them in a real program. These writers think of exceptions as verbose error codes, and think (or pretend to think) that using exceptions means writing "try" everywhere. That's a strawman. Exceptional programs don't need error handling logic everywhere.
The article's author even admits at the end that Rust's error handling is garbage and forces programmers to do manually ("best practices around logging" --> waste your brain doing a computer's work) what languages with decent exception systems do for you.
> noexcept DOES NOT mean that the function can't throw an exception! It just means that, when it does, it aborts the program instead of unwinding into the calling function
Well, yeah. It means the rest of the program can't observe the function marked noexcept throwing. No... except. Noexcept. See how that works?
> Should `panic=abort` panics be allowed in Drop? They are effectively the same as std::process::exit. Do you want to mark and ban that too?
Aborting in response to logic errors is the right thing to do, even in destructors.
Expurple•7mo ago
> 50% at least of these tiresome "here's why exceptions suck" articles begin by talking about how "try" is un-ergonomic.
Idk about the other articles, but mine doesn't begin with that. The first argument in the article is this: "exceptions introduce a special try-catch flow which is separate from normal returns and assignments" (when there's no entrinsic reason why errors shouldn't be returned and assigned normally). The first mentioned implication of that is ergonomics, but I immediately follow up with the implications regarding reliability and code clarity. See the "Can you guess why I used an intermediate variable" toggle. Later, I also bring up a separate disadvantage of having to manually document thrown unchecked exceptions.
> The people writing these things misunderstand exceptions, probably never having actually used them in a real program.
I've supported C++ and Python applications in production.
> pretend to think that using exceptions means writing "try" everywhere.
Nope: "[propagation] is a very common error-handling pattern, and I get why people want to automate it".
> That's a strawman. Exceptional programs don't need error handling logic everywhere.
Where does the article say otherwise? You're the one pulling a strawman here.
> The article's author even admits at the end that Rust's error handling is garbage
You're free to make that conclusion. In the end, the tradeoff is subjective. But it's not the conclusion that I make in the article.
> forces programmers to do manually ("best practices around logging" --> waste your brain doing a computer's work)
That's true. But languages with unchecked exceptions force you to manually check the documentation of every method you call, in order to see whether it can throw any exceptions that you're interested in catching. And that documentation can simply be incorrect and let you down. And the set of exceptions can silently change in the next version of the library (the compiler won't tell you). And refactoring your code can silently break your error handling (the compiler won't tell you). And manually verifying the refactoring is really hard because you can't use local reasoning (`catch` is non-local and "jumps" all across the layers of your app).
It's a tradeoff.
saurik•7mo ago
Expurple•7mo ago
Sure, that's often the case. That's why dynamically-typed anyhow::Error is so popular.
But I really care whether a function can raise at all. This affects the control flow in my program and composability of things like `.map()`. `Result` is so good because it makes "raising" functions just as composable as "normal" functions. When you `.map()`, you need to make a decision whether you want it to stop on the first error or keep going and return you Results with all individual errors. Rust makes it very easy and explicit, and allows to reuse the same `.map()` abstraction for both cases.
> a language that forces people to deal with local error handling.
It does that for the reason above: explicit control flow. See the "Can you guess why I used an intermediate variable" toggle in the article.
It doesn't mean that you have to do full "local error handling" on every level. 99% of the time, `?` operator is used. Because, as you've said, 99% of the time you just want to propagate an error. That's understood in the Rust community and the language supports it well.
When you need to wrap the error for some reason, `?` can even do that automatically for you. That's what makes anyhow::Error so seamless and sweet. It automatically wraps all concrete library errors and you no longer need to deal with their types.
Basically, `Result<T, anyhow::Error>` is `throws Expection`. But, like, ergonomic, composable and actually useful.
o11c•7mo ago
throwaway894345•7mo ago
dingi•7mo ago