I want to begin with a little context because my opinions here shifted a lot while thinking about the involved topics.
First: how Rust handles exceptional conditions. In a language such as Python, you could write a function like this:
There's probably adef divide(divident, divisor): if divisor == 0: raise RuntimeError("Divide by zero") return divident / divisor
DivisionByZero
error in the standard library, but that's not my point. The important thing is that "raising" (or in other languages "throwing") an exception can be handled by the caller, or not (badness of code again not the point):def foo(): try: return divide(3, 0) except RuntimeError: return None def bar(): x = divide(3, 0) print(x)
foo
provides a default value for division by zero, bar
propagates the error to its caller, who then has to handle (or again propagate) it.Rust takes a different approach. Rust doesn't have exceptions, instead it has a
Result
type that's defined roughly like this:A result can either bepub enum Result<T, E> { Ok(T), Err(E), }
Ok
and contain a result of type T
, or an Err
, and contain an error value of type E
. Our function could look like this:Herefn divide(divident: u32, divisor: u32) -> Result
<u32, ()> { if divisor == 0 { Ok(divident / divisor) } else { Err(()) } }
E
is ()
, which means an Err
doesn't carry any additional information. This is how a caller could use this:Here the discrepancies between Python and Rust are very apparent:fn foo() -> Option<u32> { match divide(3, 0) { Result::Ok(value) => Some(value), Result::Err(_) => None, } } fn bar() { let x = divide(3, 0); println!("{}", x.unwrap()); } fn baz() { if let Result::Ok(x) = divide(3, 0) { println!("{}", value); } }
- foo returns
Option<u32>
, not justu32
. That means the caller again has to unpack that value if they need au32
. The Python code would probably lead to an exception later, ifNone
can't be processed somewhere. bar
uses a second error handling mode that is kind of similar to exceptions, but is not the first choice in Rust: it panics.unwrap()
returns the value of a result, but if the result was an error, the panic (usually) kills the thread or process. Panics can be caught, but that's meant for infrastructure code such as thread pools, not for everyday application error handling.baz
swallows the exception: theif let
statement matches only a successful result, so if there's no match, nothing happens.
Notice the question mark. This operator - I'll call it thefn quuz() -> Result<(), ()> { let x = divide(3, 0)?; println!("{}", x); Ok(()) }
try
operator - returns the result immediately if it was an error, or takes its content if it was successful. This early-returning behavior feels a little like raising an exception, but it requires that the function itself returns a result - otherwise it couldn't return an Err
! A side effect is that, even though we don't have a real result, we need to end the function with Ok(())
, i.e. return a successful result without any data.Now back to code colors. This is the point where you should read the linked blog post, if you haven't already. It proposes a language where
- every function is either blue or red
- those colors are called with different syntax
- only red functions can call other red functions
- red functions are "annoying"
async
/await
syntax to call. For the rest, read the linked post. An important point here is transparency: callers have to know function color; higher-order functions may work on all blue functions, but a separate function for red functions (and thus code duplication) might be necessary.At first the argument that async poses a needless separation into two colors seemed intriguing, although I wasn't entirely convinced. But that doubt felt lazy, caused by habit rather than reason. I left it at that, followed some more discussions about possible syntactic options for async/await in Rust, and wrote some actual async Rust code.
Rust tries to get things right. The people behind Rust experiment with features a lot before they're stabilized, and they wouldn't just add an await keyword because that's what other languages do; Rust's syntax should not only be familiar, it should be practical and reduce the possibility for programmer errors.
A concern with
await
is the combination with the question mark. Async operations are often ones that could fail, so you would await them and then check for an error - like this:The first is ugly, but would you expect the second to await first, try second? That's why people were discussing a postfix variant:(await do_something())?; // or await do_something()?;
This reads properly from left to right. And, now I'm getting to the point, it shows that the blue/red distinction is not really unique to the topic of async functions. Error handling, at least the explicit kind using Result, is basically the same:do_something().await?; // or do_something().await!()?; // actually proposed postfix macro syntax, or even do_something()~?; // I made that up, but in principle, why not?
- every function is either blue or red - either you return
T
orResult<T>
- those colors are called with different syntax - you need to extract
Result
s, but notT
s - only red functions can call other red functions - not entirely. You can do one of three things:
- make your function red and use the
try
operator (quux
); - deal with the implications and keep your function blue (
foo
,bar
,baz
); - write buggy code (
bar
,baz
depending on requirements) - red functions are "annoying" -
Result
s are more annoying thanT
s
Result
s are a bad way of handling errors? I don't think so. How do exceptions hold up in this comparison?- every function is either blue or red - if you throw exceptions, that's a difference in contract; I take it as a yes
- those colors are called with different syntax - that's the plus, your higher-order functions don't see any color
- only red functions can call other red functions - nope, but I think that was already debatable
- red functions are "annoying" - not to call, but to call correctly
So
Result
s and (unchecked) exceptions are both valid mechanisms on a spectrum of explicit vs. implicit error handling. await
is on the explicit side of the asynchronicity spectrum; it remains to be seen how implicit asynchronicity can be implemented properly (Go might qualify as an already working solution here). It may not be a good fit for Rust, which chooses the explicit path, but dynamic languages might benefit from a proven solution to this.
No comments:
Post a Comment