I think the complaint is more that Rust has seemingly tried very hard to make error handling "simple". But in the process it has managed to invent a whole series of new idioms and special syntax that is alien to pretty much everyone. There's a thread in /r/rust about this same article where you can look and see people suggesting all sorts of ways to write this that are split into clear sedimentary layers depending on when the writer learned the language.
At this point the cognitive load required to read and understand Rust implementations of "typical" practical problems is rather higher than it is for C++. And it seems to be getting steadily worse from my perspective on the outside.
> There's a thread in /r/rust about this same article where you can look and see people suggesting all sorts of ways to write this that are split into clear sedimentary layers depending on when the writer learned the language.
As someone that participated in that conversation, I think that's a pretty inaccurate characterization of it. It's not about when the writer learned the language, but rather, what problem you're trying to solve. If you'll allow me to summarize very briefly (perhaps at the expense of 100% accurary):
* Use unwrap/expect when you don't care.
* Use `try!`/`?` with Box<Error> in simple CLI applications.
* Use `try!`/`?` with a custom error type and From impls in libraries.
* Use combinators (e.g., map_err) when you need more explicit control.
You might imagine that you could use any number of these strategies depending on what you're trying to do, which might range from "a short script for personal use" to "production grade reliability."
All of this stuff was available at Rust 1.0. (Except for `?`, which is today an alias to `try!`.) It all falls out of the same fundamental building blocks: an `Error` trait with appropriate `From` impls.
The one exception to this is that, recently, there has been a surge in use of crates like error-chain to cut down on the code you need to write for defining custom error types and their corresponding `From` impls. But it's still all built on the same fundamental building blocks.
I will say that as someone who was not a real expert at anything when Rust came out, but familiar with a lot, I find that learning c++ these days has the same problems, and so does learning a functional language.
They're all unique in what they do. I'd still say a simple procedural language like C is easiest to read, but 'modern c++' has more idioms and quirks than Rust, in my experience.
And Rust has the advantage that the ecosystem is very well tied together and it has a great community and documentation.
int readConfig() throws IOException, ParseException {
return parseInt(readConfigFile());
}
// given the following
int parseInt(String str) throws ParseException { ... }
String readConfigFile() throws IOException { ... }
The reason i say that is that this:
throws IOException, ParseException
Is essentially a sum type. It says that if this method results in a failure value, it can fail with one of two types of failure values. It might not look like a new type, because in Java, types are almost always nominal, and this is structural, but that's what it is. I think that throw and catch clauses are the only place that Java will let you define an ad-hoc sum type. You have to use polymorphism everywhere else you want a variety of types.
Whereas in Rust, there are no structural sum types, and so the only way to make something resembling a sum type is:
Which means you also have to write the machinery to convert between the types.
I wonder if it would help to have a compiler- or library-defined From impl for all newtype enum variants (or all newtype structs more generally), that makes the variant from its argument. Or maybe it could be derived. It would wipe out a lot of this boilerplate.
There are some crates that add a derive for errors, but most people tend to use error-chain or quick-error. Both give you the boilerplate pretty much for free, with various other advantages. E.g., using error-chain, you can just write the above code as
In case someone cares to understand what this means:
- "An Error trait" means that when you define a new type that will store error information, you have to define how it implements the Error interface.
- "appropriate From impls"... you are trying to wrap a number of error types in your own special error type, you need to tell the compiler how to convert another specific type into your type new type. There is an interface (trait) in the standard library for this purpose called "From". This is done as an alternative to inheritance in an error system. The trait signature looks like this:
Now, in most cases, callers don't care at all about which specific error happened. They just want to print the error and be done with it. And that works just fine, because of the error handling machinery. But if you do want to drill down, then the option is there for you.
> The one exception to this is that, recently, there has been a surge in use of crates like error-chain to cut down on the code you need to write for defining custom error types and their corresponding `From` impls. But it's still all built on the same fundamental building blocks.
One takeaway I got from playing around with Rust (and using GitHub code search to work through my issues) was that coding style will probably differ considerably from project to project, which is very C/C++ like and maybe a good thing for the language, but I found a little disappointing.
I think the complaint is more that Rust has seemingly tried very hard to make error handling "simple". But in the process it has managed to invent a whole series of new idioms and special syntax that is alien to pretty much everyone. ... ways to write this that are split into clear sedimentary layers depending on when the writer learned the language.
That's been my criticism of Rust error handling. Rust's error handling system is very clever. It's logically sound. It manages to make functional programming and error handling play well together.
But it's not user-friendly. For a while, it took far too much code to handle errors. So gimmicks were developed to make the necessary gyrations less verbose. These hide what's going on underneath. Thus the generations of error handling approaches.
Rust tried to avoid the complexity of exception handling, but ended up with something that's more complicated. Python programmers, who have a good exception system, notice this. In Python, you write the main case, and then you write an exception handler to deal with the error case. This works well in practice. Python has an exception class hierarchy. If you catch EnvironmentError, you get almost everything that can go wrong due to a cause external to the program. If you catch IOError, you get all I/O-related errors, including all the things that can go wrong in HTTP land.
With exceptions, if you're using some code that doesn't handle an error well, you can catch the problem at an outer level, get good information about the error, and recover. With error-value returns, after you've come through a few levels of function returns, you're usually down to "something went wrong". (Having written a web crawler, I've found this useful. A huge number of things can go wrong in HTTP, HTML parsing, SSL certificate handling, and the other manipulations needed to read a possibly-hostile web page. A crawler needs to catch all those and deal with them, deciding "try again now", "try again later", "log error and give up", or "try alternative access approach". This makes one appreciate a good exception mechanism.)
Exceptions have a bad reputation because Java and C++ implement them in ways that are inferior to Python's approach. There's no exception hierarchy. Knowing what exception something can raise is very important. Often, you don't.
Rust (and Go) are slowly backing into exception handling, as the panic/recover mechanisms acquire layers of gimmicks to make them more useful. Rust already has unwinding (destructors get run as a panic event moves outward), which is the hard part of exception handling. Thus, exceptions are more a religious issue than a technical issue.
There's no exception hierarchy. Knowing what exception something can raise is very important. Often, you don't.
Java has an exception hierarchy and exceptions as part of method signatures. If anything, Python's exception hierarchy started getting sorted out relatively recently.
As someone who's been doing C++ for a close to few decades I's challenge you on that. In C++ you need to understand:
Exceptions(and the runtime/memory costs they incur by pulling in RTTI)
ERRNO(on relevant *nix platforms)
Lifetimes tied to objects when things fail(this is a big one)
Plus any library-specific hackery(I've seen raw strings as errors before)
In contrast I've been writing Rust for ~1.5 years now and each library I've used is consistent and follows common patterns. Much like a lot of people don't grok functional until they understand the common patterns, so it is with Rust too.
Exceptions actually can need RTTI, though not necessarily all of the RTTI that things like <typeinfo> provide. For some details, see the -fno-rtti flag for GCC, for which the documentation says[1]:
Disable generation of information about every class with virtual functions for use by the C++ runtime type identification features (`dynamic_cast' and `typeid'). [...] Note that exception handling uses the same information, but it will generate it as needed.
That's great and all but when every modern compiler does implement it that way it's not something you can ignore.
Show me a compiler that's used in production which handles multi-inheritance exceptions without allocating extra data and I'll be happy to eat my words :).
The ANSI C++ standard requires exceptions in the first place. The moment we start talking about disabling them, we're not talking about standard C++ anymore.
Which is why people cannot just state "In C++ the thing X happens" without regarding what the standard says, instead of what the installed compiler does.
Language != Implementation.
If the language would be "In GCC the thing X happens when ...", then ok.
> Exceptions don't need RTTI and have only runtime cost when thrown.
No, they add extra control flow edges, which inhibit optimizations, affecting runtime performance. This is a lot of the reason why unwinding is optional in Rust.
> At this point the cognitive load required to read and understand Rust implementations of "typical" practical problems is rather higher than it is for C++.
I code in Rust and C++ every day, and am mostly equally experienced in both (maybe more Rust now, but this wasn't always the case). I disagree. Rust has some ergonomics issues that C++ does not, but the reverse is true too. Looking at rust from C++ you'll only see one and not the other, because you're used to the other.
(And as burntsushi said your characterization of the thread is inaccurate, people are suggesting things that are best for different use cases)
Rust hasn't really tried "hard" to make error handling simple. We have what we had during 1.0, and then we have the ? operator, which always existed as try!().
It didn't invent any of these. This is railway-oriented-programming[1], using Either, leftMap and bind/flatMap, a conversion from Either to Try, with forced recover calls and explicit implicit conversions via typeclasses.
Other than the enforcement by the compiler, this is common in scala and f#, two other languages where raising errors is common.
Railway-oriented-programming is nice because it typechecks, and because it reduces cyclomatic complexity by short-circuiting without forcing you to handle the error until the end of the chain. Now, it is unfamiliar to those outside the FP community, but much of the rust language seems to be unfamiliar mixes of FP and low-level optimisation techniques.
With the Either type you don't even need to do that, but then errors on the left becomes convention instead of an enforced habit. The only odd part is the try macro. That should wrap the Val in a right, to preserve type information. I guess I could be convinced that Either = Error | Id [A].
Maybe I was doing C++ wrong all those years, but I find the cognitive load for Rust about equivalent. The error handling idioms of Rust exist, more or less, in well-composed "--no-exceptions" C++ code. The syntax might be a tad different, but the principles are more or less the same.
I don't get it. .unwrap() turns an undesirable situation into printing an error message and exiting with a nonzero status. There are plenty of situations where that is exactly what you want, and not a bug at all.
For example, in my production Rust code, i deal with all errors in reading and parsing config files with .unwrap() or .expect(). If a program cannot read its config file at startup, it cannot correctly do its job, and so the only correct thing for it to do is to abort.
Prints to where? stderr? Fine for a simple CLI tool, but people might want to do something more complicated when an "unrecoverable" exception occurs.
Take for instance a web framework like Django that responds with a 500 error page with a stack trace when an exception occurs.
There are interesting problems to be solved in the space of error handling, e.g. error handling in asynchronous code. But Rust is not even matching the state of the art achieved by Lisp and Python decades ago.
...then don't use unwrap(). Instead check the error, do whatever handling you want and exit gracefully.
unwrap()'s behaviour is essentially the same thing as an uncaught exception in Python, only you can actually check for where they occur in the code with a simple grep rather than hoping your test suite caught every possible failure case.
One of the hardest things about teaching error handling in Rust is that there is a lot of nuance. It's fun to say "unwrap is evil, don't use it," and more than that, it's not exactly wrong either. The nuanced version is that you shouldn't use unwrap for error handling, but:
1. If you're prototyping or writing a quick throwaway program, then unwrap will neither pick your pocket nor break your leg.
2. If you have an invariant that you either can't (or won't) move into the type system, then unwrap/expect/panic'ing can be appropriate precisely because if that unwrap gets tripped, then that will indicate a bug in your program that should be fixed.
The more succinct advice that I like to use is this: "if end users of your Rust application see a panic, then you have a bug." But this is slightly harder advice to follow because it's a statement about the user experience.
> 2. If you have an invariant that you either can't (or won't) move into the type system, then unwrap/expect/panic'ing can be appropriate precisely because if that unwrap gets tripped, then that will indicate a bug in your program that should be fixed.
Absolutely but using the same mechanism (unwrap/panic) for both types of errors - recoverable and recoverable - can creates confusion.
panic'ing for wrong user input for example as can be seen in example code.
panics aren't for recoverable errors, in any circumstances. It is technically possible to "halt" the unwinding from a panic, but 1) that's intended for C interop, 2) the feature has been deliberately designed to make your life hell if you try to use it to emulate recoverable exceptions, and 3) Rust makes no guarantees that panics will unwind at all, it is entirely legal for a user to configure panics so that they all abort instead.
I think this discussion is muddying the meanings of "recoverable" between "recoverable as a class" and "recoverable in this instance."
Parsing errors are recoverable as a class—you haven't irretrievably corrupted your process memory when you encounter one. Therefore, the parser itself should not panic(); it should just return an option type.
Parsing errors may very well be unrecoverable in a particular instance. The code calling the parser has every right to decide to unwrap() the option type such that a panic() will happen if the parse failed. The code calling the parser is likely business-logic code of an application, and is privy to knowledge like "if there is no configuration supplied here, then later code that tries to consume the configuration will have to crash" and so can decide to early-exit with a user-comprehensible error ("you don't have a config file!") rather than letting the later code crash with some weird error about a config value being Nothing.
At this point the cognitive load required to read and understand Rust implementations of "typical" practical problems is rather higher than it is for C++. And it seems to be getting steadily worse from my perspective on the outside.