Zig Code Smells

I understand your concern when it is a production program for end users. But for a simple program used mostly by its author, it is fine to “just terminate the program with a frightening error message”.

4 Likes

At that point you can just terminate where the error happened, no? Why bubble it up?

1 Like

Because sometimes the code of main will be copied-and-paste to a function, so it i better to not decide too much in advance?

Yes, I agree. My point is to think about it. When I’m experimenting with code, it stinks. I hack and try things that will never end up in production. I’ll sometimes write it out in Python before ever touching something like Zig.

Just to reiterate - code smells are not things that apply in all circumstances. As an example, when I first started writing Zig, I didn’t even know about @panic. On writing my first larger project in Zig, I quickly realized that the amount of tries were stacking up, so my code ended up looking like:

try foo();

try bar(try baz());

try...

And that’s because somewhere, deep in the pipeline, something could return an error. I quickly realized something was weird about that code because much of the time, it would just result in terminating the program anyway. This is the situation I’m talking about - when try becomes viral and we pay a syntax/readability tax (as well as having to mark every return type with !).

A good substitution in this case was just assert. That way, in debug, I could trigger a panic but then that gets wiped out if I compile outside of debug. A lot of things that could be an error can often find their home as an assert.

1 Like

I don’t really get all the discontent towards try, it mostly seems like an issue of either not using the available possibilities fully, or expecting some kind of magical solution, or wanting the programmer to be unnecessarily verbose where it doesn’t really help to make anything more secure.

There was some conflation between using the inferred error set !return_type and using anyerror which simply are different things. If you want to avoid your error set to grow to a wide range of errors, that hardly ever happen, than you can just explicitly state your error set in a few strategic places, to section off different zones in your code and thus make sure, that certain things are only allowed to happen in specific code pathes.

If you never specify an explicit error set, always use inferred errors, never handle them, where it makes sense; and thus create a huge mess, than that just means, that you need to deal with the mess you created, hopefully learn from it and next time create less of a mess.

If 90% of your possible errors can happen in 90% of your code, than it is likely that the code is some kind of object oriented mess and the code would be better off, if it was changed towards something more data oriented. Maybe think more in terms of columns and tables, instead of (pointer-) chasing around every row as its own separate object instance.

I don’t think strict ideas like “always handle the error everywhere”, are particular helpful, different code has different tradeoffs, having to mechanically handle the same error where it happens over and over again, with slight variations because every other strategy is deemed less “secure”, seems like it assumes that the programmer will always get it right.
Personally I feel more secure, if I handle errors in fewer but carefully reviewed places, that way I don’t have to repeatedly handle errors in similar ways, which might make me blind to the nuance, if I get tired of the unnecessary busy work, which increases the likelihood that I will introduce a bug in the error handling code itself.

Additionally if I organize my code in such a way, that means I have less code that does error handling, thus I need to review less code to see whether my error handling is correct.

Sure not every kind of error can be grouped up with similar errors, but often it is possible and error sets and try make it easier to do that, which is why I am very happy about having these in zig.

It also makes it easy to create different zones of importance and handle these differently:

  • OutOfMemory error in my code that creates output for debugging my program?
    just catch and fallback to some static string that gives me context to find and fix it quickly
  • Functionpointer used for a userdefined function?
    Hopefully uses an explicit error set, instead of anyerror. If it fails in debug just crash (dev is there to fix it), in production you could differentiate between these functions based on whether they are critical, restartable, allowed to fail silently, etc. You can try to recover, rollback to a previous state, decide to log the error, inform the user, send a crash report…
  • Request/Response
    similar to function pointer
  • Webserver
    try to stay alive, without security getting compromised, for example by terminating/denying requests that look malicious, prefer crashing over leaking data

At every point in the callstack I am able to control how specific or general the allowed errors are and whether I want to handle them right there or only one specific one that is in the current set, etc.
It gives you the flexibility of exceptions, without the drawbacks of not knowing what to expect, or the tediousness of go, but it still requires you to make good decisions about how you write your program.

8 Likes

I strongly agree with the sentiment of this post, especially about considering the design of the code when something becomes viral/obfuscating.

At this point, I think the discussion about try and what it means deserves a different thread… maybe something about helpful error handling patterns?

That said, I want to encourage people to post more things besides just try. Is there anything else that people think indicates an issue with the structure of the code?

1 Like

I just found this gem on this topic:

4 Likes

assuming you don’t return errors from library code, doesn’t this force you to return “reasonable” defaults? I mean, in most cases you don’t have control over every bit of your system so something will error eventually…
I see that this avoids crashes at runtime due to unhandled errors (i.e. just using try falling on your feet), but don’t you buy that “safety” with the danger of something being completely wrong in the end?

You still could decide to use a @panic to create a crash, it is just a pattern to make you think and actually handle the errors.

I think it is similar to how zig allows you to pick pub fn main() void for your main function instead of pub fn main() !void.

With the former you basically state:
I want to handle every error and my program shouldn’t crash, unless I choose to crash it explicitly, or do something that can cause a crash (like divide by zero).

With the latter you say:
I want to handle some errors, while I accept that those that I didn’t handle may terminate the program with an error message (and I may not even care right now what those other errors are).

I think for the latter, it is worth thinking about whether we can make those other errors explicit (for example by defining an explicit error set), when we are working on an application that is meant to be stable and production ready, to make sure we haven’t just forgotten to handle something we actually could have handled.

Even when you are using !void it sometimes makes sense to call a function in there, that uses void and in that function do a group of things where everything should be handled.

1 Like

This is a use case where interfaces could shine.

1 Like

It was proposed: https://github.com/ziglang/zig/issues/1268
@andrewrk response was:

I’m not saying there won’t be interfaces of any kind, ever, but there are no current plans for adding them, and this proposal is incomplete. It does not propose anything specific.
I suggest if you want to use zig, you accept the fact that it may never gain an interface-like feature, and also trust the core team to add it in a satisfactory manner if it does get added at all.

1 Like

Yes, I’m aware. Agni on Zigcord had a thoroughly discussed, solid proposal in the works at some point.

Integer types instead of enum(T) { _ } for handles from systems. Without it you end up with an untyped mess that’s easy to make mistakes with (passing the wrong handle, arithmetic with handles, etc).

That and a missing language feature @distinct(u32) to declare an integer incompatible with u32 for safety when dealing with different units of measure.

5 Likes

Strong types… good stuff. However, you can encode integers using non-exhaustive enums. This prevents me from passing the wrong thing to functions that maybe expect an index to one thing but I handed it another. It’s not quite the same as what you’re suggesting, but it has some of the benefits.

const I1 = enum(usize) { _ };
const I2 = enum(usize) { _ };

pub fn decode(n: I1) void {
    const m: usize = @intFromEnum(n);
    std.debug.print("\nDecoded: {}\n", .{ m });
}

pub fn main() !void {

    // works, prints 42
    const a: I1 = @enumFromInt(42);

    decode(a);
    
    // compile error, decode expects I1, not I2
    const b: I2 = @enumFromInt(42);

    decode(b);
}

Since they’re actually different types too, you can dispatch on them using comptime. Similar to strong types in this sense - disables arithmetic as well until you cast back to numbers.

4 Likes

I’m aware but zig makes this tedious to manage meaning the safe way involves more friction than having everything being effectively untyped for arithmetic.

In short:

Handle the two cases of this.

3 Likes

It’s easy to trigger.

I expect the move to std.io.AnyReader as the sole reader interface to still be harmful to performance on the whole. I ought to assemble some benchmarks to determine if that’s the case.

2 Likes

Interesting subject - I have several thoughts on this myself. We should start a new thread about this if we’re going to continue this discussion.

3 Likes