Future of error handling in Mojo

What are the plans related to handling errors in Mojo?

Current state with raises is not ideal as it is not obvioud in what way function can raise (but still better than Python when everything can raise in many various ways). Also handling errors via try except and traditional stack unwinding is verbose and costly.

I’d like to see sth more similar to Rust apprach

But I’d like to see Mojo not only offer the Rust approach, but also provide the speed of try/catch in C++ compared to Python’s try...except by stack unwindings.

Hybrid one can be cool, but without sacrificing performance as Rust did

Yes. I hope Mojo can implement both of them.
But the cost of try/catch in C++ is minor and endurable for most cases.
As a Python and C++ developer, I’ve got used to the EAFP programming style that has been broadly accepted in the Python community.:joy:

IIRC Mojo doesn’t handle errors like either C++ or Python. It secretly handles them like values.

Consider this code:

@no_inline
fn foo(i: Int) raises -> String:
    var d: Dict[Int, String] = {1: 'hello', 2: 'world'}
    return d[i]

fn main() raises:
    var a = foo(10)
    print(a)

Here in the IR you see that foo doesn’t actually just return a string, but also an optional error value, and does a branch based on whether an error occurred or not

define internal { [2 x i64], i8 } @"playground::main()"() #0 {
  ...
  %4 = call { i1, { ptr, i64 }, { ptr, i64, i64 } } @"playground::foo(::Int)"(i64 10)
  %5 = extractvalue { i1, { ptr, i64 }, { ptr, i64, i64 } } %4, 0
  %6 = extractvalue { i1, { ptr, i64 }, { ptr, i64, i64 } } %4, 2
  store { ptr, i64, i64 } %6, ptr %1, align 8
  %7 = extractvalue { i1, { ptr, i64 }, { ptr, i64, i64 } } %4, 1, 0
  ...
  ; exit normally or print error
  br i1 %5, label %20, label %21

From a performance perspective. I don’t think this would be much different than Rust, but still uses try/catch semantics like C++ and Python

2 Likes

Mojo errors don’t do stack unwinding, and iirc typed throw is in the roadmap

Could you share more of what you’re unhappy about and what the “rust approach” means to you? For context, Mojo “raising” is efficient. The one thing that isn’t efficient is that the Error type is hard coded, and hasn’t been optimized.

If the internal implementation was explained (seems to be the right choice), I am still curious about the future direction of error handling API:

  1. Python like try-except with raising
  2. Rust like monadic error handling (pattern matching, combinators, etc.) with returning result enum
  3. Both / Something other?

You might be surprised to hear that Rust’s error-handling model can actually be substantially slower than Mojo’s model in certain situations. Let’s assume that we have a struct TenKB that stores 10 kilobytes of data inline. Now, consider the difference between:

fn foo() raises -> TenKB:
    return TenKB() if thing() else raise 'error'

var x: TenKB
try:
    x = foo()
except:
    x = default_value

versus:

fn bar() -> Optional[TenKB]:
    return Optional(TenKB()) if thing() else Optional(None)

var x: TenKB = bar().or_else(default_value)

The statement x = foo() is able to write 10 kilobytes directly to the memory of x, whereas the statement x = bar().or_else(default_value) writes 10 kilobytes to the payload of a temporary instance of Optional. The or_else method is then invoked on this temporary variable, and it copies the 10 kilobytes either from the Optional or from default_value. In either case, a ton of additional work is being done. If you’re doing that work in a hot loop, you could easily be slowing down your program’s execution by 2x.

In short: Optional has at least one major disadvantage, and it’s not accurate to say that it’s a better error handling model than exceptions. Optional is better than invisible exceptions that are implemented using stack unwinding, e.g. Python and C++. This is the model that the Rust designers were competing against. But Mojo exceptions are not invisible, and they aren’t implemented using stack unwinding, so most of the traditional arguments about why exceptions are problematic don’t apply to Mojo.

It’s worth repeating: Switching from raises to Optional can actually make your Mojo code slower, so I don’t recommend doing this, unless you have a solid justification.

2 Likes

I don’t think this holds for Rust in general. Even with an Option<Pin<…>>, values are written straight into the destination. I believe our lack of the same optimisation in Mojo stems from not modelling sums (Optional, Variant, or even Tuple) correctly.

I think this misses my point. My point was that in Mojo, we don’t need to hope that an optimizer will eliminate the copy for us. Instead, we can just write the program such that no copying takes place to begin with. Notably, Mojo doesn’t require TenKB to be Copyable to compile foo. But to compile bar, it does. (The reason being: It’s not possible to define or_else such that it works for a non-copyable/non-movable payload.)

Just look at the LLVM IR that a raising function generates: it still returns a “tuple” before inlining. The compiler does exactly the same, yet our surface language makes it very difficult to express this if we used a Tuple (because it’s hard coded in the lib) and often forces a copy in the more typical movable or copyable cases.

I think that part of the complaint is the inability to control the other tuple member, right now it’s just a boolean and (iirc) the error is written to a global. I’d like the ability to raise an enum or sum type of error information, so that the user can tell whether the function is simply allocation failure aware or if the function is potentially going to toss up some IO errors.

1 Like

The good news is that all approaches are currently possible,
an raising function result could be stored in an Optional,
and an Optional could be .value() that raises ! :partying_face: