Is `try` really needed?

I recently followed the write a terminal editor tutorial from here: https://viewsourcecode.org/snaptoken/kilo/ in zig 0.16.0 and since there is a lot of writing, which can fail, to stdout() there are A LOT of try keywords in my code (Flashback to .unwrap() and .expect("..") in my rust code). And it got me thinking why exactly do we need try?

We need to deal with the errors anyway why not just automatically raise the error if one occurs (which in reality is what you do in most cases) and have just code like catch for when we deal with it now? This would cleanup the code a lot in my opinion.

Example from:

fn test_method() !void {
  let v1 = try method1();
  try method2();
  try method3();
}

TO:

fn test_method() !void {
  let v1 = method1(); // If error automatically propagates.
  method2() catch |err| {
	  // DEAL WITH ERROR here no auto propagation.
  }
  method3() catch-to result; // Treat result later (Adds bew keyword).
  // result above would be the same error union type as the method return type.
  // Alternatively
  const result !: method3() // !: is like = but cathes the error union.
}

Of course there can be a much better keyword then !: It’s just a placeholder for discussion.

Have a dedicated keyword for when you DON’T want to propagate the error instead of a dedicated keyword for when you DO would really cleanup a lot of code from my point of view.

I think this still hold for the zig zen of communicating intent clearly as you see right there where the error is treated if not you know it bubbles up the chain.

I would like to hear some opinions on this topic from you all.

1 Like

try is a visual marker for function calls that can fail, and conversely the absence of indicates that a given function call cannot fail. if you make it implicit, then when reading code you will not immediately know if a given function call will cause control flow to change or not, and that would suck.

42 Likes

If you find try annoying then it probably means that you are not handling your errors properly. If you find yourself bubbling all the errors up to main(), then you are no better off than using catch @panic everywhere, which is not a good error handling strategy.
e.g. if reading a file fails and you bubble up the error then the user will just see a completely useless error.FileNotFound or error.AccessDenied as the program crashes.
However if you handle the error locally by e.g. logging the file path together with the error and panicing (or ideally continuing if possible, in a GUI application you could also display an error message box), then the user can go into their file system and fix the file permissions or whatever.

11 Likes

If try was to be removed from the language, that would be the end of me using it. :slight_smile:

Most of the time, I don’t need to handle errors at the lower levels, so try is used often. They bubble up, and are handled where needed.

That plus errdefer are one of the main advantages of Zig over Go for me.

8 Likes

zig always uses keywords for control flow, this would violate that consistency.

This is better, but it still inverts the relationship of keywords and controlflow, there is no existing control flow that does this.

how do you tell if a function does not error? (without reading its definition)

4 Likes

May do something like rust does for macros but for error functions so they all terminate in a !.
This way no extra hassle as they you usually autocomplete the name anyway.

fn add_overflow(a: usize, b: usize) !usize { ... }
add_oveflow!(10, 10);
1 Like

But this is exactly my point.

This just removes the need to type try everywhere. It would not affect the flow at all.

But then you are in the C++ territory, and you invented a new way of doing exceptions, slower one than the established practice. The value of try is that I can see what can fail and what not. I can’t explain it, but there is a huge mental relief when I read some Go/Zig code, and I’m 100% sure the execution can’t be interrupted by an exception.

9 Likes

I don’t mean to bubble all the way up to main.

But take the terminal editor I gave as an example.
All the interface for a terminal editor is a series of writer.print(...) for text and terminal escape sequences. Since is it an editor you will do buffered writing in a lot of functions, because preferably you would split the UI logic in quite a few parts, which make a lot of write call.
All writes can fail. I really doubt for such a case you would want to treat every failure of a write.
You would bubble them up to a "render" function and treat them there, but if the UI gets complex enough there would be quite a few calls in that function.

2 Likes

totally agree here.
I responded to another comment about this, but maybe do something like rust does for macros and functions that can return an error end with a !.

That is far too easy to mis, and you can’t rely on syntax highlighting to make it easier as that is tool the programmer may or may not like or use.

Having try before the call is just so much more readable, I would say it is the best position for such a keyword/symbol.

This thread seems to mostly just be personal preference; zig values reading over writing code, for any change you would have to show it to be easier to read or justify why it’s okay to sacrifice some readability, and you would have to convince Andrew of that.

15 Likes

The rule in Zig for what is decided to be a special character operator (+, *, /, etc.) vs a keyword operator (return, try, catch, while, and, or, etc.) is that the latter may divert control flow, where the former may not. try may divert control flow because try foo is essentially a shorthand for foo catch |err| return err, and both catch and return are control flow operators.

Technically try is not needed, because you can replace every usage with catch + return. It’s just more convenient, and that replacement would land Zig at basically the same if err != nil { return err } pattern that people so often complain about in Go.

The “control flow operators must be keywords” rule is why the logical and and or operators are keywords, instead of the common && and ||. Due to their short circuiting nature, they may technically divert control flow :slight_smile:

16 Likes

For that exact reason I wish the path of least resistance for try was “handle some errors”. I think simply treating try as a slightly better panic is too easy with the current syntax. Zig usually isn’t shying away from using friction to encourage better code, and I think try is a missed opportunity.

With that in mind, here’s my pet Zig syntax proposal:

const value = try failable() {
   error.A => { ... },
   error.B => { ... },
};

Equivalent current syntax:

const value = failable() catch |err| switch(err) {
   error.A => { ... },
   error.B => { ... },
   else => |e| return e,
}

Using the proposed syntax, this is equivalent to the old try:

const value = try failable() {};

My arguments for this being an improvement:

  1. At least in code I write, catch |err| switch(err) is the default error handling pattern, and this would be able to replace it in a lot of cases with something that is less visually noisy.

  2. It encourages less bloat-prone error sets by making narrowing automatic. The proposed syntax would eliminate “forgetting to narrow” in the case where you handle some but not all errors:

const value = failable() catch |err| switch(err) {
   error.A => { ... },
   error.B => { ... },
   else => return err, // could be a narrower set but isn't
}
  1. Not handling errors becomes slightly noisier, and the syntactical differences between handling and not handling errors are reduced, which encourages local error handling.

No doubt the hardest part of getting any changes into Zig.

I have no hope I’ll change the needed mind(s) here. :wink:

3 Likes

Good error handliing in general is already quite a task, heavily depending on what a program or lib is for.
I like try. Readable and short.
The only thing I stumbled upon is that detailed information gets lost about the error (no info on which Error “enum” it was).

I don’t really like your design. Although you seemingly tried to reconcile “throwing up” and “in-place handling,” the essence of your design still follows the “implicit throw priority” concept. Once an API decides to adopt an in-place handling priority style, it may end up missing certain errors due to the implicit throw behavior of this design.

I believe that whether to propagate errors from low-level APIs or handle them locally depends on the current module’s expectation of how much knowledge about this module’s error details its upper-level modules should possess. If the current module expects that the upper-level module should understand all the specific details of its errors, then it should lean towards recording diagnostic information and propagating the error (by the way, if you plan to supplement diagnostic details during propagation, you still should not implicitly propagate automatically). On the other hand, if the current module expects that the upper-level module does not understand all the underlying details of its errors, it is preferable to handle them locally using a dependency-injected error handler.

Regardless of which approach is used, I do not think implicit propagation as a default is appropriate, especially in scenarios where local handling is desired, because errors that are missed and implicitly propagated can be very difficult to detect.

Therefore, I believe that having propagation as a distinct syntax, separate from catch, is valuable.
(Actually, to be more extreme, removing try and requiring catch |e| return e, I don’t have much objection either.)

Edit: But I think your idea of internalizing the switch into the catch block is not bad; I just hope that the upward propagation is not implicit, meaning that the else must be explicitly written.

2 Likes

I dont expect zig to change this, but I quite like your idea, I will steal it if I ever implement a language.

I half agree with @npc1054657282, I would like propagating unhandled errors be explicit, but I wouldn’t want to make unconditional propagation more verbose.

2 Likes

+1

catch |err| switch (err) is such a common pattern that a shorter syntax would be useful. If we need the capture, then usually we also need the switch.

1 Like

I would like to note that there is a language-design consensus that you want some sort of explicit syntax for propagating errors:

4 Likes

Because one of Zig’s goals is No hidden control flow.

TBH I think this is a non-issue. try makes it obvious that the code being called can return an error AND that you’re just passing it up to be handled by the caller. I like the fact that I can prototype using try only and then a quick search through my code shows exactly the places where I can decide to handle the error differently.

10 Likes

That would le be the best end of you using try? Of course it would… Sorry for the lame joke.