Zig; what I think after months of using it

13 Likes

Thanks for the write up. It can always be good to get a critical look in the mirror.

Having read your article, I think it comes down to some fundamental differences in views on programming. I don’t mean that in a bad way. I mean it in a way that we just have different values and goals when it comes to programming that influences what languages we like to work in.

As an example let’s take a look at the no destructors part of the article. I personally like that Zig does not have destructors. The defer patern works well and I know exactly what code is going to be run. Part of simplicity is not hiding complexity. I like knowing what my code is doing and not having implicit function calls that hide the complexity of what I’m doing from me.

When it comes down we might just disagree, and I’m glad you are enjoying Rust.

13 Likes

I would have loved something like a # linear marker that would cause a linear resource to be dangling if it’s not eventually consumed by a function taking it with the marker as argument

Has this been discussed before? Something like a type qualifier (similar to const) that causes an error when you leave the scope of a variable without returning/passing it somewhere. Without a complex type/borrowing system it couldn’t be completely correct in every case, but would still be helpful to prevent accidentally forgetting to deinit things, even if you have to manually cast it away when necessary.

1 Like

FWIW, in my experience, literally all Zig code that uses try/defer/errdefer a bunch is buggy. Like, after 3 minute of search, there’s an fd leak on error here:

ah, there’s an fd leak even if there’s no error.

5 Likes

Browsing quickly through the text, I mostly agree with the good and less good things of Zig.
What I like most is the readability (except the dreaded ā€˜anytype’). I moved from Rust to Zig, because I think Rust is (1) unreadable and (2) has a borrowchecker which I am completely not interested in. (I like the freedom Zig gives to do anything).

1 Like

Welcome to Ziggit! It sounds like you have more low-level experience than I do, but I am curious to hear some expansion on some of your points.

Specifically, contracts and comptime. Personally, one of the reasons I stopped learning rust was because I didn’t want to have to learn how to write macros- adding AST traversal to an already complex language was a bridge too far for me.

While I’m not yet 100% convinced that comptime can fully replace the functionality of macros, it does seem to match the 80/20 rule for me, and possibly more.

Similarly, I do see the value in traits, but I think a paradigm shift could get me to live without them.

AFAICT, you can write programming contacts in Zig using comptime to build interfaces. You can’t do it as easily as you can with traits, and you can’t share partial contacts in the same manner, i agree that it can be irritating to dig around in abstracted code and find what you’re looking for but ultimately is it that you can’t build contracts, or is it that it’s harder to share code between contracts?

Sorry if my tone is aggressive, like I said I have less experience, and im just trying to wrap my head around the issues.

That was my experience my first few years with Zig as well. I’ve become better over time (as you’d expect) by obsessively following the same pattern everywhere, but asking fallible humans to manage resources as well as a machine is reminiscent of the old folk tale John Henry and his battle against a steeldriving machine. He won the battle by a single nail but died of exhaustion afterwards…not sure what the lesson there is for Zig programmers :^D

I’m not willing to give up simplicity (and explicitness) by introducing hidden function calls like destructors, but nobody should be under the illusion that this is a solved problem. The author mentioned linear types as a possible alternative and I’m holding out hope for that, but it seems like too big of a change to ever introduce to Zig.

In the meantime there are a couple smaller things we could do:

  1. Throw an error on fd leaks in debug mode, just as we do with memory leaks.
  2. Find a better way to test error paths. Most of my resource management mistakes are in error paths because my unit tests don’t trigger them enough.
9 Likes

Same here. My pattern is that I am terrified of resource management in Zig, and try to go out of my way to not have to manage resources. It is a good pressure to design software better, but its a bit like learning to swim in a sea full of sharks :stuck_out_tongue:

7 Likes

I originally came across this article when ThePrimeagen reacted to it on stream. The main take away I had from it was the closing remark. Specifically, this one:

I’m also fed up of the skill issue culture. If Zig requires programmers to be flawless, well, I’m probably not a good fit for the role.

Perhaps my own bias is showing here, but this has never been my experience from this community (or any Zig community). I suspect there was a human element to writing this critique. While some of his points of criticism are valid (e.g. Rust Results are really convenient for propagating additional info in error paths), others feel invalid and quite subjective.

The most egregious example is the authors section Memory safety is highly underestimated and fallacious. I have read @andrewrk’s blog the author references, but I feel that this writing was really spun into a topic much bigger than Andrew was intending. I don’t know any reasonable Zig enthusiast that would sincerely argue that Zig is safer than Rust. Instead, those enthusiasts would argue trade-offs and say what Zig looses in safety, it makes up with < INSERT FAVORITE FEATURE >.

This section of the blog read to me as a Straw man argument from an author deeply entrenched in ideological tribalism.

But I want to be self-reflective and noticed my own bias. Perhaps I’m ideologically entrenched in the tribe of Zig and it’s blinding me from seeing the critique in a neutral light. :man_shrugging: What do you all think?

9 Likes

I would also add here the principle of locality that helps to tackle complexity. In Zig you are less likely need to jump around the codebase to understand what a given block of code is doing. With destructors (hi C++) and traits (hello Rust) one has to be all over the place to mentally unwind the hidden logic.

6 Likes

That’s fair. Defer is easy to miss, even for experienced devs (not that I’m one), but I like that it pushes you to think about things as bigger chunks, rather than individual items that need to be built and un-built one at a time.

5 Likes

I understand this sentiment exactly, and your thoughts/analysis were near identical to my own, though you managed to articulate them better than I could have.

I do not for a moment believe that the author wrote the article in bad-faith, quite the opposite, they seemed to truly approach the subject in the most neutral way they could. That said, the impression that I got from their conclusion and many of their criticisms (a few of which are valid) could be reduced to down to ā€œZig is not Rustā€, which they view as a negative against the language.

As you stated, I am unsure if this is their bias for Rust, my own for Zig, or a mixture of both, but it was the impression that I walked away with after reading the article. I understand the perspective of the author was one coming from the Rust world, but in my opinion, the entire article could be summarized with the following bullet-points:

  • Pros: Zig is like Rust in these ways. It also has arbitrary-sized numbers.
  • Cons: Zig is not like Rust in these ways
3 Likes

I’ve gotten in the habit of doing focused passes looking for this sort of bug, and yes, I do commonly find them. It might take you more than three minutes (hey it might not) to find another one in my public code, but I do very much doubt that it’s all properly picked over.

That said, if I’ve gone over a function specifically linting for those problems, it’s generally solid. There are some blunt instruments available like errdefer comptime unreachable to leave tripwires for checking again after further modifications, but generally speaking any change to a function / file means it’s a ā€˜dirty page’ and it will need to be gone over again. It doesn’t work to try and do this while developing, it’s a decided shift in mindset.

As it happens I don’t think this is acceptable, we shouldn’t just shrug, tell everyone to git gud, and leave it at that. It’s a growing preoccupation of mine that Zig needs a validation system: some way to annotate intention in the code, hopefully better than magic comments, and an open-ended compiler hook to run validations in an attempt to prove, or disprove, those statements.

What I’m not interested in is any attempt to add resource management to the type system. Rust is right there, showing what that looks like, advantages and disadvantages both. Occasionally someone who has the Rust missionary zeal shows up (hi @Md5) and tells us why Zig should be Rust, and I always wonder: why? What’s wrong with Rust that Zig should become it? Would it end up being a better Rust? Great, do that to Rust, skip the middleman.

The memory policy enforced by affine types has known advantages, but, it covers a tiny sliver of design space, and any honest Rust enjoyer will admit that they’ve had to make substantial changes to their design plans to satisfy the type system. Generally this comes with bold assertions that the original idea was a mistake, how much of that is accurate and how much Stockholm syndrome I do not know.

It’s not resource management which is hard, it’s defect-free programming. High assurance software is hard! I hope Zig will continue to make progress on the entire problem, rather than nerfing the language by imposing an acceptable but highly opinionated and brittle solution on one part of it.

11 Likes

Related, one thing I realized recently is that IO-as-an-interface is also the obvious place to put resource leak checks. For example just like we have leak checking in DebugAllocator we can have file descriptor leak checking in Io implementations.

It’s also potentially an area where we can have something like checkAllAllocationFailures but for testing multiple kinds of scheduling orderings. Unit tests can be repeated with different, deterministic scheduling orderings.

Also fault injection, making the Nth I/O operation fail with some kind of error.

I think when we have all these things, combined with the integrated fuzzer working well, it’s going to be a new era of Zig.

31 Likes

Great write-up always interesting to hear from someone using Rust. The only point I’d push back on is the idea that simplicity isn’t a feature.

Zig is basically C with modern conveniences. Like C, you’re playing in manual mode you have to be careful, stay mindful, and keep things simple. It’s not for everyone but at least it’s real. That’s what pushed me away from C++ and Rust. They pretend to be simple, but they are very complex machinery.

In C++, I’ve had too many days where I felt productive, feeling like I was getting shit done, only to realize I spent the whole time wrangling headers and boilerplate. I’d feel good because the code didn’t crash immediately until I turned on UBSan and saw the mess.

Worse, the knowledge I gained in those languages didn’t translate over others. It was all C++ specific. With Zig or C, there’s less to learn, and what you do learn actually carries over. You don’t waste time debating which of 20 ways to solve a problem is best. You just write the code and move on.

6 Likes

This I’m glad to hear. I’ve tried to figure out ways to make a ā€œtestAllErrorPathwaysā€-type system (generalizing testAllAllocationFailures, basically) work using just comptime, and I don’t think it’s possible, because declarations and functions live outside of the type reification system. Barring code generation it would take compiler support to get it.

The allocation failures version works because Allocator is an abstract interface, so it makes sense that doing that for I/O would make it possible there as well.

1 Like

I have to concur about the shadowing for exactly the same reasoning.

I disagree about strings simply because everybody disagrees how strings should be implemented in every language. Everybody wants ā€œsomebodyā€ to implement a string, but there is no convergence as to what features ā€œstringā€ should implement and what guarantees should be provided. Because of that, strings don’t belong in the language; strings belong in a library.

ā€œdeferā€ is okay, but it is definitely error prone and limited (lexical scoping can often get in the way of your ability to run the defer in the correct spot–especially for errdefer). I’m increasingly of the opinion that destructors/finalizers randomize performance sufficiently on modern processors that you should acknowledge that needing them is badly done garbage collection and you really should be using a language with GC, instead. This is also reinforced by the fact that languages with destructors/finalizers always gain a body of complex idioms for how to program in such a way to sidestep them/make them deterministic.

Instead of destructors, what I’m wondering if I’d like is an explicit annotation bit on a variable with runtime checks that say ā€œHey, if this variable drops out of scope with this bit still set, shout.ā€ That way it would be up to the programmer to opt-in to the check by tying ā€œresource->variableā€ and be forced to unset the bit to indicate that the resource has been handled or acknowledged by passing it out.

4 Likes

I’d like to expand on this point by saying that simplicity isn’t just a feature, it’s an incredibly difficult and hard fought one- particularly in a project as ambitious as Zig is.

When people propose minor additions to the language and get immediately shot down, I’m not sure they realize the long term impact that those additions can have on a language.

One day you’re accepting a minor convenience change, the next day another, and pretty soon you wake up to realize your language has turned into c++ (if I understand my history correctly).

I applaud the Zig team for sticking to their vision, and I do think that the language should be kept simple, as long as you aren’t bending over backwards to make things work.

Look at how long it took golang to accept generics.

5 Likes

First I’d like to thank @Md5 for sharing his findings, in particular I’d like to thank the sober tone he did it with. It’s clear from the effort he has put into his blog post that he wants nothing more than to be a zig fanboy, but alas it seems the distance from what zig is and what he wants is too far.

There are a few key take aways that I find ridiculously accurate, I simply love his # on return values suggestion that tells the caller and the compiler that the return value is responsible for owning resources and as such must be stored or properly disposed of by the user.

If you don’t take the # then please could we without calling it a destructor just say that every struct that doesn’t contain a deinit has a deinit no-op that makes defer something.deinit(); valid syntax even if it does absolutely nothing except making it a safe way to say I’m only using this value in this scope and I don’t care, just get rid of it. Of course there are the structs where this cannot work, say a struct that owns memory but doesn’t have a reference to the allocator, those cases can then add a comptime deinit with a compiler error. Complete with the proper steps to dispose of them. That would really cut my learning curve in half on most libraries.

I don’t particularly agree with many of his Rust points, last time I looked at Rust code my eyes hurt. He is right that it will protect you from memory errors, but at what a cost? That is just painful! Give me the debug allocator any day! Again I totally buys his premise that Rust works, it’s just not for me.

In fact I’d say I love the solutions in the zig language. The whole deal about getting rid of async by simply allowing you to set up your io stack to do fibers behind your back… without changing a single line in your implementation, what’s not to love? That beats async any day of the week!

On the other side I’m very surprised when I look at the Writer structs and indeed the newly proposed IO stuff. The lengths you go to in order to avoid interfaces, classes and anything called OOP. And yet you’re making structs that takes code from other structs and uses VTables to glue it all together so that the caller can abstract away the difference. That at it’s core is what OOP is doing. Yes most OOP languages does a lot more than that… a lot more I don’t want zig to add! But similar to allowing functions to be nested in the struct (the very first step of OOP) let’s agree that if we need to make wrapper structs and with VTables (or interfaces as I like to call them) for the std library, then perhaps it would be nice to add some sort of language feature to facilitate this instead of forcing everyone to make this rather cumbersome exercise.

I hope that when @andrewrk chose the wording ā€œIO-as-an-interfaceā€ it meant he is aware of this. The latest version of struct with VTable could be used to avoid adding it as a language feature, but why keep calling it a digging implement instead of a shovel? When deciding on what feature to add, I really hope they consider some sort of contract that will help people to avoid using anytype for stuff that clearly has a contract, a well implemented interface pattern would be my personal preference, but don’t let that limit you, same as with IO, take your time, let it simmer and then make an excavator instead of a shovel.

2 Likes

Yep jumping around the codebase is a recipe for logical flaws. In the end, zig is explicit and rust has lots of implicitness. I think adding safety to explicit is always possible but taking away implicitness is not always possible, so zig should not be trapped in a local medium.

IMO zig should not add linear types. As per my zig clr experiment, refinement types can be added to zig ~without changing the language (if you have access to AIR and you accept the use of a checker tool). Refinement types are a superset of linear types.

ā€œHey, if this variable drops out of scope with this bit still set, shoutā€

This is checkable in zig at compile time with refinement types.

2 Likes