Defective Language

Messy error handling in Rust with `try!`

I just read the documentation for std::result in Rust. A friend had pointed it out and said it left a sour taste in his mouth, as it does mine. These are essentially error-carrying monads, in case you’re familiar with Haskell terminology. They are error handling mechanism that is added on top of the core language (that is, not part of the fundamental syntax and semantics).

Nicer?

If we scroll down the doc we find this example, followed with the comment “It’s much nicer!”

1
2
3
4
5
6
7
8
fn write_info(info: &Info) -> io::Result<()> {
    let mut file = try!(File::create("my_best_friends.txt"));
    // Early return on error
    try!(file.write_all(format!("name: {}\n", info.name).as_bytes()));
    try!(file.write_all(format!("age: {}\n", info.age).as_bytes()));
    try!(file.write_all(format!("rating: {}\n", info.rating).as_bytes()));
    Ok(())
}

It certainly is more compact than the example they had before, but there is nothing nice about this code. Consider the following instead:

1
2
3
4
5
6
fn write_info(info: &Info) -> {
    let mut file = File::create("my_best_friends.txt");
    file.write_all(format!("name: {}\n", info.name).as_bytes());
    file.write_all(format!("age: {}\n", info.age).as_bytes());
    file.write_all(format!("rating: {}\n", info.rating).as_bytes());
}

If every statement that could produce an error requires a try! then why not just make it a fundamental part of the language? This mindless repetition of try! and the need to declare io::Result<T> for the return just leaves me feeling the language is missing something.

But but but…

The Rust code works because return values cannot be ignored. Returning a std::result forces the caller to deal with it somehow. The example however does nothing but pass the result back to its caller. I would bet that the vast majority of code dealing with std::result will simply do the same thing: pass the result back to the caller.

This is precisely why exceptions were created in languages like C++. They avoid the needless forwarding of errors from function to function, while also provided a way to intercept them when needed.

My reduced form of their example has the same semantic meaning as the first form, but with less syntax. This makes it easier to understand the purpose of the code. It also makes it easier to handle errors correctly; being an implicit part of the language makes all functions, by default, handle errors in a sane manner.

One popular criticism of exceptions is that they have hidden, or extraordinary program flow. I consider that a weak argument, but I’ve heard it used to justify monads before. If that is somehow the motivation for std::result then I should point out it fails here anyway. try! has the same perceived “problem” as exceptions: it is hiding a conditional and return statement.

A much better use of try!

Exceptions are bulky to handle in C++, and in all languages that came after unfortunately. This is one reason why some people dislike them. Rust could instead choose to clean this up and make try! stop the exception propagation and convert it into the std::result instead.

1
2
3
4
    res = try! error_prone_function(...)
    if !res {
        //handle error contained in `res`
    }

This is a compact way of handling errors in situations where we know what to do with them. It still requires something like std::result in order to contain the result or an error, but it flips the default semantics: everything generates and propagates errors by default instead of nothing.

The mantra

Languages must face the reality that most function calls can fail, and only a handful of functions will actually know how to deal with errors. The vast majority of functions will simply proxy errors from their callees to their callers. It only makes sense to design a language around this situation.

Error handling should be an implicit fundamental part of the language. This doesn’t mean it has to use the “throwing” approach of C++ exceptions, that’s just a technical detail. The compiler can just as easily use return value propagation of errors at the low-level. The high-level language shouldn’t really concern itself with “how” they are implemented, but focus on making them easy to use.

20 replies »

  1. > One popular criticism of exceptions is that they have hidden, or extraordinary program flow. I consider that a weak argument, but I’ve heard it used to justify monads before. If that is somehow the motivation for std::result then I should point out it fails here anyway. try! has the same perceived “problem” as exceptions: it is hiding a conditional and return statement.

    That’s not really what “hidden” means in this context. Consider refactoring a function that used to have no error condition so that it now has an error condition. With exceptions, such a change is silent: the newly added exception will propagate out of possibly countless callers of the function. With std::result, you have to change the signature and explicitly acknowledge the error condition.

    • I agree there could be multiple meanings of “hidden” in this context, but the one about invisible return paths I have seen before. That is, they are invisible to the person reading the code.

      With implicit error handling you don’t have to change anything since all functions can
      raise errors by default. In C++ if you create a function that happens not to throw, the compiler will also give you an error if you fail to handle an error from a function that can. I fail to see how `std::result` is a better approach than this.

    • Invisible return paths are problematic in C++ because C++ code has many invariants that have to be maintained with extra care in presence of nonlocal control flow, in particular memory management invariants and data invariants. In Rust, you do not have to maintain memory management invariants, precisely because Rust lacks nonlocal control flow: you cannot catch a panic*. In this regard, given Rust’s design goals, try! is not a choice, it is a necessity.

      You still have to maintain data invariants in Rust with try!, so no change here.

      * in the same way as you would catch an exception: std::thread::catch_panic accepts a closure with a ‘static bound, which means it would not support the majority of use cases for a try..catch statement.

      > With implicit error handling you don’t have to change anything since all functions can
      raise errors by default.

      Sure. I am saying that this is a problem (even in absence of stricter requirements such as being able to mechanically check memory management invariants), because throwing is a part of the API contract but it is not checked by the compiler.

      > In C++ if you create a function that happens not to throw, the compiler will also give you an error if you fail to handle an error from a function that can.

      Sorry? The C++ compiler will tell you nothing unless you opt in to checked exceptions. If you do, the syntax is even more verbose than try!, and you introduce an orthogonal mechanism that makes it impossible to compose code with mechanisms such as Result::map.

    • One good argument I can imagine in favor of checked exceptions as opposed to Result is that it is very tedious to wrap the Result values when a single function can return several kinds of errors, e.g. IoError or Utf8Error, but I am not sure if it compensates the drawbacks.

    • Certainly C++ has problems when it comes to error handling, I’m not pretending that it doesn’t.

      But I think your concerns are out of proportion to their practical details in C++ code. I’ve done a lot of major projects and coding correctly in the presence of exceptions is not really that hard. There aren’t there many situations that come up where something can go horribly wrong if an exception is thrown.

      Checked exceptions are aweful, but a no-throw clause `throw()` can be used in cases where nothing can be thrown. This should be checked by the compiler, if it isn’t then that compiler is broken.

    • > But I think your concerns are out of proportion to their practical details in C++ code. I’ve done a lot of major projects and coding correctly in the presence of exceptions is not really that hard.

      Whether it’s hard or not is not really relevant here. Rust’s design allows to mechanically eliminate a nontrivial bug class. Even very good programmers will make mistakes, and most people aren’t very good at nonlocal reasoning anyway.

      Besides, even if we assume that everyone is able to write code that performs correctly on every of the combinatorial code paths caused by nonlocal control flow, this doesn’t cover coding in the presence of /changing/ sets of exceptions being raised. How do you robustly refactor code where you have to, generally, examine the entire callgraph in order to understand the impact of adding or removing a throw? Result makes that trivial, especially in combination with exhaustiveness checking.

    • I’ve heard this argument about “nonlocal flow” quite often and it simply doens’t make any sense. There is no such thing as non-local flow. Exceptions can just as easily be reasoned to be implicit `try!` calls like in Rust, thus there is no fundamentally different logic involved.

      If a language offers destructors of `defer` statements it is just as subject to the same complicated flow and block building language as exceptions. Any early return from a function must traverse a graph looking for deferred execution.

      There is no explosion of code paths introduced by exceptions, and the term “non-local flow” has no established meaning.

    • > I’ve heard this argument about “nonlocal flow” quite often and it simply doens’t make any sense. There is no such thing as non-local flow.

      Sure there is: nonlocal control flow is any control flow that is initiated outside of the current function, and thus, at best, requires global analysis to reason about. At worst, when combined with indirect calls, it is impossible to reason about in all cases.

      Which brings me to the next part…

      > Exceptions can just as easily be reasoned to be implicit `try!` calls

      With monadic error handling, given an arbitrary function and the type definitions / function signatures in scope, I always can trivially and precisely list every error that can arise and the path that will then execute. I challenge you to do the same with exception-based error handling.

      > If a language offers destructors of `defer` statements it is just as subject to the same complicated flow and block building language as exceptions

      True. However, the fact that in Rust you cannot recover from a panic means that it is not generally necessarily to pay attention to all the possible code paths that panics introduce. I agree about the `defer` statement though, the complexity is still present in Go.

      > the term “non-local flow” has no established meaning

      This is just false. Open any book or paper on compilers or programming language theory.

    • Exceptional flow has absolutely not problem being modelled as the monadic/try! flow being used in Rust. You can simply view every function C++ as returning a combination of error and result and the caller having an implicit if statement with a further return statement. The flow is entirely controlled at the call point.

      Thus in C++ it’s trivial to list where all the errors can occur in a function and the paths that will be taken. It’s simply a list of all the call-points in that function.

      I stand by my opinion that this “nonlocal flow” is a misdirection.

    • No, it’s not. void f(); void g() { f(); }. What exceptions can g() raise?

      Going further: class c { virtual void h(); void i() { h(); } }. Given a c* obj, what exceptions can obj->i() raise?

      These questions are impossible to answer in C++ (first one, without whole-program analysis; second one, at all) and are trivial to answer in Rust.

    • mortoray, your understanding of C++ exceptions is wrong. C++ has no statically checked exception declaration. The deprecated throw() clauses don’t statically check (a violation results in a runtime call to std::unexpected, which isn’t allowed to return normally). The new noexcept clauses don’t statically check either (violations result in std::terminate being called at runtime).

      A compiler might warn you about calling something that can throw in a noexcept function, but that’s unlikely. You may get a warning when you put a throw expression directly within such a function.

    • That’s very unfortunate then. I can’t imagine the value of a no-throw clause if it’s not to enable compile-time checking. All it does it is escalate exceptions to abort clauses.

    • I would like to point out that Java has checked exceptions precisely for this case.

    • @whitequark, I know it has been a while, but I am unable to catch the idea of “Rust’s design allows to mechanically eliminate a nontrivial bug class.” Could you please explain this a bit? Thank you in advance.

  2. I’ve always enjoyed your articles about errors/exceptions, because exceptions are bit of a pet peeve of mine. I’m happy to say I totally agree with you and especially like your mantra “Languages must face the reality that most function calls can fail, and only a handful of functions will actually know how to deal with errors”. This is so true, but it seems like no one knows or understands it.
    It might be because of a few very common misconceptions that are spread even by the java designers themselves (e.g. “exceptions are for exceptional circumstances”) and the fact they got checked exceptions a bit ‘wrong’.
    I recently wrote an article about this:
    http://blog.bitethecode.com/post/130259363012/exceptions-dont-excist

    It also briefly addresses the problem with Rusts Result type. A proposal exists for a ? operator in Rust that would function like the try! macro, so the syntax would be a bit cleaner, but the main problem still persists.

    By the way, the pun in the title is intended, and please don’t get caught up in details such as “is an exception always an error”. It doesn’t really matter, what matters is the concept of “internal” and “functional” errors.

    The main points to take away are:

    – Errors should be part of a function’s signature (like checked exceptions, and like the Result return type in Rust)

    – It should be possible for the caller to ignore any error, and that error should then propagate up the call stack (so checked exceptions should automatically be converted to unchecked (runtime) exceptions when they aren’t caught or rethrown, With Rust’s try! macro, it should return an internal error instead of just returning the original error)

    – The catch block (or return value pattern matching block) should generally ignore such internal errors and only catch errors that directly originate from the called function (I call these functional errors)

    – Example: when calling a login() function I would like to be able to catch simply ‘Exception’ which could be a case of UserNotFoundError or PasswordIncorrectError to display a useful error message on the login screen that the user should check the username or password and try again.
    I don’t care about any DatabaseNotFoundError, IoError, ConnectionLostError, etc. because I can’t handle such errors originating from deeper functions called by the login function. Those are internal implementation details and need to be dealt with at another level in the application.

    – However: that does not mean DatabaseNotFoundError is unchecked (runtime / internal) by default. For example in a database frontend gui application, when calling a function like query() I would like to catch DatabaseNotFoundError as a functional error to display an error message to the user saying the database specified in their query couldn’t be found.

    – So checked/unchecked functional/internal shouldn’t be defined at the class level. It’s the fact that an error is propagated out of a caller’s function that makes it internal (i.e. it has nothing to do with that function anymore, it’s an implementation detail that signifies something internal went wrong).

    I didn’t mean to hijack your post and make it about exceptions! :) But I was equally amazed at the verbosity of the try! macro in such a nice brand new language. And your mantra just hit the nail on the head.

    • I’m glad you enjoy my articles, it looks like there’ll be a lot more coming relating to error handling. The major divergence in views between many programmers and failure of all languages to implement it well, means there is a lot to be discussed. I’d like to get it right for Leaf.

      I understand the desired to distinguish between internal and functional errors. Unfortunately I’ve been looking at this for a long time now and still have no practical way to do this. The nature of polymorphic implementations of virtually any service leads to a severe inability to know precisely why something has failed.

      `login()` is a good example. I think it’s fair to want to check for very specific errors, like user unknown and password invalid. But we have to be very careful with naming and semantics. As you wrote, `UserNotFound` is not something you should handle. It doesn’t mean the user is unknown, it just means they weren’t found, maybe the DB lookup just failed and that’s why the user is not found.

      Perhaps there is a distinction between positive and negative errors: contrast between a user who definitely does not exist and to a lookup that just happened to fail for unknown reasons.

    • > The major divergence in views between many programmers and failure of all languages to implement it well, means there is a lot to be discussed

      Agreed, it took me a long time to ‘see the light’ regarding exceptions and even longer to grasp / come up with the concept of functional and internal errors.

      > The nature of polymorphic implementations of virtually any service leads to a severe inability to know precisely why something has failed

      This is something I’m also still pondering over, but I think it has more to do with the actual handling of the error (finding out what kind of error it was and doing something usefull other than crashing the program) than it has with throwing the error. It’s mainly the throwing part I’m concerned with at this point.

      > `UserNotFound` is not something you should handle. It doesn’t mean the user is unknown, it just means they weren’t found, maybe the DB lookup just failed
      > [..] contrast between a user who definitely does not exist and to a lookup that just happened to fail for unknown reasons.

      Yes exactly! You’re right in that second part, but not the first. In the login() function UserNotFound should always signal that the requested user doesn’t exist. Everything else went OK, we did the database lookup, no errors there, but the user just doesn’t exist. So it’s no use trying that same user again, but you could try another user.
      If for any reason something else should go wrong, like the database connection failing, that would be a ConnectionFailed error propagating from the query() function called by the login() function. That is not part of the API of login(). That can only ‘fail’ if the user cannot be found or the password is incorrect. Any other failure is something internal that went wrong. So ConnectionFailed is a functional error for the query() function, but becomes internal when propagated out of the login() function.
      That’s the error we don’t want to handle, except in 2 cases:
      – Only to display to the user “Something internal went wrong, the error has been logged and we’ve been notified, better luck next time”.
      – If we have a plan B, for example trying the login() function again but with a different database

      Because in the ConnectionFailed case, there’s no use trying the login() function again.. at all, even with a different user, because the connection will still fail (assuming it wasn’t just a fluke).

      The types of the exceptions are also irrelevant in a way. They could all just be something like “CheckedException” (with a message) and be converted to “UncheckedException” when propagated (not handled or rethrown as functional). So you could just call login(), catch CheckedException and know it was either the user not found or password incorrect error. It doesn’t matter to us*, because we won’t differentiate in the displayed message to the user. The catch block however won’t catch any errors that come from deeper functions like query(). Although connection failed started out as a CheckedException, it became UncheckedException when it propagated out of login().

      * in cases it does matter, type might not be the best way to differentiate between different errors, but that’s a different discussion

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out / Change )

Twitter picture

You are commenting using your Twitter account. Log Out / Change )

Facebook photo

You are commenting using your Facebook account. Log Out / Change )

Google+ photo

You are commenting using your Google+ account. Log Out / Change )

Connecting to %s