What Should We _Remove_ from Zig?

I love Zig very much, and I see a lot of posts on what is still missing. I appreciate so much for the aesthetic insistence (in a positive sense) on keeping Zig as a simple language, and the excellent balance of choosing what to add in every iteration.

I’m curious about a reversed question:

What do you think we should remove from Zig?

Specifically:

  • What do you think is out of place in the language syntax / semantics?
  • What do you think is out of place in the standard libraries?
  • What do you think is out of place in the idiomatic patterns and ecosystems?
4 Likes

I guess most people wouldn’t agree, but I think code is better without try. try makes it way too easy for errors to just bubble up, and I like using it when I want to be lazy. But I’ve been realizing that just letting errors bubble up isn’t really a complete error-handling strategy. Even if you need to pass errors upward, in most cases we still need to report the error context promptly, and abusing try makes it easy to lose that context.

7 Likes

I agree that more inertia in error handling would drive programmers into writing flatter code for IO / exceptional cases. It’s a good thing. In Go if written carelessly the errors just bubble up through layers of function calls endlessly, so people like to write flatter code and extracting inner processing logic away from the effect-ful wrappers.

But specifically in Zig error traces are kept from the throw point to the catch point so what you said about keeping context isn’t strictly missing :thinking:

References:

1 Like

errorReturnTrace is certainly a very nice feature. But it is disabled in ReleaseFast by default, which seems to imply that error tracing is primarily something you want while debugging rather than something a performance-oriented production build should retain.

However, if I choose to return an error rather than panic, that usually means the failure is expected to be handled by the program even in its final, optimized build.

There’s nothing wrong with Zig’s default here, and we shouldn’t turn on error tracing in ReleaseFast because of it. An error trace isn’t the same as error context, and we don’t need tracing in production, but we still need to preserve sufficient error context.

1 Like

Yeah, hard disagree on that. While it’s true that programmers shouldn’t overuse try, it’s still a really good language feature.

Fundamentally, there are 3 things you can do when an error occurs:

  1. Handle it (takes effort).
  2. Pass it up the stack.
  3. Silently ignore it.

In C, passing errors up the stack takes effort (you need to change the function signature, not just add ! in front of it), and ignoring errors is the default option. Guess what the lazy/tired/hurried programmer does?

Then, once you realise you were ignoring an important error, you have to change the function signature at each level from where the error happens to where you can actually handle the error. It’s a massive PITA.

Bubbling-up the errors is much better, because you can come back later and insert error-handling code at whichever level you want. None of the other functions need to be changed.


It was difficult, but I thought of one thing that could be worth removing:

Don’t allow (or severely restrict) implicit type coercion from usize/isize to other integers, regardless of bit width - require @intCast() to be used.

The reason for this came up when I was writing code intended to run on a Raspberry Pi. I was writing and testing it on a 64-bit machine, and had everything working nicely. But when I tried to compile for a 32-bit architecture I got a whole host of compile errors from implicit coercion that suddenly didn’t work.

In my case it was simple to fix, but it’s easy to imagine this happening in a complex library that the author only tested on a single computer, and having to vendor end edit the dependency solely to make it compile for a slightly different architecture.

To emphasize, if I had been forced to add the @intCast() earlier on, I could have cross-compiled without a single change to the code. All I had to do was add a CLI flag. That’s awesome.

17 Likes

lang:

std:

10 Likes

It’s undeniable that easily overlooking errors is a big trap in C, but in Zig, ignoring errors has become really hard. Even without try, programmers can’t just ignore errors.

On the other hand, if missing error context is easy to overlook, the cost of fixing it later isn’t necessarily cheap. We may still have to change function signatures and propagate additional information through multiple layers in order to preserve the context we actually need.

Of course, if we always write code recklessly, we’ll always have to pay some refactoring cost later; nothing comes for free. It’s that we should introduce some friction at the right places from the start, so that we’re less likely to write code that throws away information we’ll need later.

First time I ever saw the idea of removing append and equivalents, but I gotta say, I think I’m sold after reading the blogpost.

I honestly wouldnt’ve thought I could ever be sold on the idea of an arraylist that doesn’t have an append method, but @matklad manages to do it. Same way the Zig team managed to convince me that unmanaged containers are better.

The things (users of) this language manage to get me to believe.

8 Likes

I’d likely remove something related to error.Canceled, maybe io.recancel(). It exists because error.Canceled can be swallowed, but almost never should be.

Make recancel default and force callers to either swapCancelProtection or use something like an io.uncancel().

4 Likes

Isn’t it really easy to ignore errors?

foo() catch {};
bar() catch unreachable;

The reason you don’t see it very often is because they are both more cumbersome than try. That’s my point; zig makes the path of least resistance the better of the two lazy options, and adds friction to the worst option.

Also, when you forget to handle errors, the compiler error ends with:

note: consider using 'try', 'catch', or 'if'

If you just want the error to go away, you’ll be inclined to pick the first option, which leads to the best ‘lazy’ code.


Yeah, it’s seems really weird at first, but it makes a lot of sense when you think about it. If there’s a wrong way and a right way to use a data structure, why have an API that makes the wrong way easier?

It’s not just for error handling - doing lots of little resize operations means more opportunities to fragment the heap, leading to premature OOM errors. It’s one of the reasons embedded systems avoid heap allocation.

No kidding. If you told me a few years ago that leaking memory was a really good way to write programs, I would have assumed your preferred language had a garbage collector and your brain did not. Now main(init: std.process.Init) comes with a free arena allocator and I love it.

9 Likes

I think compiler errors for tabs in comments/multi-line strings are out of place. I think it’s good that Zig has a formatter, but at the syntax level there should be no stance towards tabs vs spaces in my opinion. And the current solution is a mess, you cannot comment out valid Zig code (if it contains tabs) or put valid Zig Code in a multi-line string.

23 Likes

Implicit coercion of usize and isize to/from other integer types; this would make a lot of code much more portable.

5 Likes

Interesting idea. Worth exploring IMO

1 Like

[*c] pointers, especially now that Translate-C has been relegated from a compiler feature to an “official third-party” package. (I used to be against this idea but I’ve since come around.)

[*c] pointers is one of the weirdest constructs in the language because they simultaneously behave as single-pointers, multi-pointers and integers. They also complicate generic @typeInfo reflection code because they break otherwise useful assumptions like that optional (non-allowzero) pointer types have the same size as and are just as legal to use in extern contexts as the same pointer type with the ? removed.

foo: ?[*]T is a much more honest translation of C T *foo. We simply don’t know whether the pointer can be null or how many values it points to, so therefore it makes sense to translate it into the most conservative pointer type possible. The awkwardness of needing to write ptr.?[0] to dereference them will encourage users to respecify values returned from Translate-C-translated functions into more appropriate and correct pointer types.


Other tiny things that don’t exactly hurt anyone but which I think could be safely removed:

  • \t and \r escape sequences. If Zig wants to wage a holy war against tabs and carriage returns, it might as well go all the way. Requiring \x0a and \x0d sends a good message that users should avoid these control characters unless required by legacy formats/protocols.
  • Octal literals like 0o755. They might have served a purpose in the early days of computing but nowadays they are nowhere near useful enough to deserve a dedicated syntax in a modern language. There are better and more readable ways to model UNIX file permissions, such as by using packed structs.
17 Likes

To be totally honest, I think anytype feels like a lazy hack. It always feels so clunky and unelegant which doesn’t fit the rest of the language at all. And in the most common usecases for it that I know of, it’s there to make up for some deficiency that Zig shouldn’t have in the first place:

  • It’s used to make some generic functions shorter, because in Zig type parameters always need to be passed explicitly. So with anytype you can do function("") instead of function([]const u8, ""). But this is an arbitrary limitation that no other language with generics I know of has. Even other languages that don’t differentiate between generic and actual arguments, like Odin, don’t need the type to be passed explicitly.
  • It’s used as a replacement for variadic arguments. But Zig should just have variadic arguments in the first place. Creating a custom bespoke tuple type every time you want to format a string is way more hacky than just letting you pass a variable number of arguments to a function.
  • Duck typing style comptime generics. Using anytype for these sucks because it means the header of the function is usually a bunch of comptime code validating that the thing you passed in can be used correctly and to log better compiler errors if it can’t - and even doing that it’s still very awkward because you always need to pass -freference-trace=20 when building to actually see the call that caused the error. This would be so much better handled by a comptime “trait” system, or just a better way to implement the “validation” checks.

There’s a proposal that wants to get rid of it by introducing |T| syntax, and it’s a step in the right direction, but it only solves one of these issues (the first), and it also wants to get rid of @TypeOf with it, which I still really don’t understand why.

8 Likes

I think it is planned to remove it at some point https://codeberg.org/ziglang/zig/issues/36082#:~:text=Pointers,someone%20for%20help

Personally I see this as a half measure.
While it’s true that using ensureUnusedCapacity() and appendAssumeCapacity() makes your code more perfect, in my mind users who are savvy enough to “reserve first” don’t really have any use for ArrayList, and could be satisfied with functions like std.mem.concat.
So, in my mind, the case is more for deleting ArrayList entirely, which is a much more interesting and controversial discussion.

People like ArrayList and it’s strongly ingrained in the language, because many programmers see this “list” kind of structure as something that programming languages just… need.
Zig is already bohemian enough that it could say “lists are bad, reserving first is better”, but the question is whether its existing users, and prospective new users, would agree. Many would, many wouldn’t.

Personally I think the ideal ArrayList is something which is framed explicitly as a convenience structure for people who want lists that you can rapidly append single items to, and it could have something like an automatic “capacity paging” system to allocate less often.
Which would work something like this:

pub fn append(self: *ArrayList, alc: std.mem.Allocator, item: T) error{OutOfMemory}!void {
  if(self.capacity % page_size == 0){
    @branchHint(.unlikely);
    try self.ensureTotalCapacity(alc, self.capacity + page_size);
  }
  self.appendAssumeCapacity(item);
}

The question with this design is, “What’s the most ideal page size to minimize unused memory?”
I say 256 is probably a good start, but seeing as how the amount of wasted memory depends strongly on @sizeOf(T), it’d probably be wise to make page_size a comptime expression based on @sizeOf(T).

Another thing you could do, in the interest of communicating intent precisely, would be forcing the user to specify the page size as a function argument, and providing a std.ArrayList.default_page_size(T) function for users that have no opinion.

4 Likes

For my part, I’m not looking to remove anything from the Zig language, I’m looking at it as a stable foundation to build my own safe applications.

Example: I’m moving from Allocator+ArrayList to a PinnedArena+PinnedArray. A “Pinned” arena here being a wrapper for an ArenaAllocator that only exposes .alloc() and .create(). There is no early free()/destroy(), there is no resize(), there is exactly one lifetime for everything it creates. An “array” made from that can’t resize or move, it’s more like a bounded list on the heap. It’s not as flexible as an arraylist, but there are certain errors that you cannot make with it. The PinnedArena is not as flexible as an arena, but everything it creates has exactly one lifetime, everything ends at exactly the same moment, it’s simpler for me to reason about, and any attempt to get the internal unsafe api with it is caught by a linter.

The point though is that I can do all that and create simple & safe programs without asking Andrew to make changes to his compiler that I still hope can become the next C.

I mean, I’m sure there are some things worth removing and some things worth adding but since many patterns that can be enforced at the program level, I’m not too bothered about them.

1 Like

I think Inferred Error Unions are almost always a mistake to use, and wouldn’t be sad if they were no longer allowed. They fall squarely into the category of “Things that are easy to do, but also actively make the code worse”.

I do think they can make sense in two contexts: in describing the return type of main and for functions that are only used in tests, but I would still “vote them off the island” if it were under consideration.

7 Likes

Not sure if I understand you correctly, but don’t we have this already?

https://ziglang.org/documentation/0.16.0/std/#std.array_list.Aligned.ensureTotalCapacity

Implements super-linear growth to achieve amortized O(1) append operations.

2 Likes