Zig: The Bad Parts
(updated )
Table of contents
Technical problems §
I think all of these will be solved before 1.0.0 except for interfaces, which is unfortunately the most painful one I’ve come across.
No interfaces §
This is Zig’s biggest weakness. It dances around it with
comptime T: type
and anytype
, but those can quickly break down.
For example, how can I tell the caller that I expect T
to be constrained to
additive types?
fn foo(comptime T: type, a: T, b: T) T { return a + b;}
Well, I guess I can compile foo("hello", "world")
:
test.zig:4:14: error: invalid operands to binary expression: 'pointer' and 'pointer' return a + b; ~~^~~
…that’s not very helpful.
There’s two bad solutions:
- Write a comment that
T
must allowT + T
and hope your readers read it.
/// Please only use `T`s that support addition...fn foo(comptime T: type, a: T, b: T) T {
- Rewrite the function using type reflection:
fn foo2(comptime T: type, a: T, b: T) T { switch (@typeInfo(T)) { .comptime_float, .comptime_int, .int, .float, .vector => return a + b, else => @compileError("type " ++ @typeName(T) ++ " does not allow addition"), }}
Now the compile error reads:
test.zig:10:17: error: type []const u8 does not allow addition else => @compileError("type " ++ @typeName(T) ++ " does not allow addition"),
…but you are relying on the compiler to specify constraints AND enforce them. It’s like having no separation of power between the legislative and executive branches of government!
You may think these examples trivial, but some patterns are just unruly. Take readers and writers for example:
fn MyReader(comptime Reader: type) type { // what methods can I use on `Reader`? return struct { // what do you expect this function to do??? // what's a ReadError and how do I add my own errors???? pub fn read(self: @This(), buffer: []u8) ReadError!usize { } };}
Ugh. IO in Zig using Readers and Writers has been the worst I’ve experience in
any language, bar none. Even C++ does better. The error casting problem leads
to such bloated binaries that std.io.GenericReader
and std.io.GenericWriter
were invented to type-erase the error. Sure would be nice if there was a clear
interface that covered most use cases!
No extendable structs §
You cannot extend structs:
const std = @import("std");
const Animal = struct { // you can make a new type with these fields via `@Type` health: u32, fur: bool, // but you cannot make a new type with this function fn heal(self: *@This(), n: u32) void { self.health += n; }};
const DreamPig = @Type(.{ .@"struct" = .{ .layout = .auto, // you can extend `Animal`'s fields .fields = @typeInfo(Animal).@"struct".fields, // but its declaration are completely ignored on the following line!!! .decls = @typeInfo(Animal).@"struct".decls, .is_tuple = false,}});
const pig = DreamPig{ .health = 32 };pig.heal(3);std.debug.print("{}\n", .{pig});
gives:
:25:8: error: no field or member function named 'heal' in 'test.DreamPig' pig.heal(3);
Well, I guess I’ll keep dreaming of my DreamPig
. Here are the bad solutions:
- Use a field for the parent:
const Pig = struct { // I guess pigs _contain_ animals. // They are certainly NOT animals! animal: Animal,};
- Cleverly use conf
usingnamespace
until it’s removed. - Use code generation. This is what I decided on for flatbuffers. It’s not ideal when users want to modify their structs and then later regenerate their code.
Only integer error values §
Sometimes you need to bubble up an error value besides an integer to another programmer or the end user. The default error handler only allows integers:
fn verify(password: []const u8) !bool { if (password.length < 8) { return error.TooShort; // this is an integer } return true;}
Pretend for a moment that we computed that password and want to show the invalid computation. You guessed it, we have more bad solutions:
- Just log it. If you’re writing a library you’ll want to expose some
log
options:
fn verify(password: []const u8) !bool { if (password.length < 8) { log.err("password {s} is < 8 bytes", .{ password }); return error.TooShort; // this is an integer } return true;}
- Create a custom Result type and abandon the nice
try verify()
syntax:
const Result = union(enum) { too_short: []const u8, good: void,};
fn verify(password: []const u8) Result { if (bar < 0) { return .{ .too_short = password }; } return .{ .good = {} };}
- Use Rust or C++.
Bugs, bugs, and more bugs… §
I’ve had some flagrant errors over the years, and there’s still some open even though I haven’t touched the language in over a year.
They’re really flagrant. Like “printing is broken” and “I cannot multiply integers.” The multiplying integers was solved a couple weeks ago.
confusingnamespace
§
This hurts code readability since I don’t know which usingnamespace
a
declaration comes from. Here’s the
discussion to remove it from the language,
which will likely happen.
Confusing top level declarations §
I find
“source file structs”
a confusing abuse of @import()
rather than syntatic sugar that avoids typing
const Foo = struct {
. It also happens to only work for defining a struct
and
does not work with other containers like a union
.
Confusing builtins §
Although the docs don’t say, builtins are a mix of hardware instructions
(like @prefetch
), and language extensions (like @TypeOf
).
Some are downright confusing. What’s the difference between
@mod
and %
? I’ll show you:
fn getF32() f32 { return 3.0;}
const a = 1 % 3;const b = getF32();const c = 1 % b; // error:// remainder division with 'comptime_int' and 'f32':// signed integers and floats must use @rem or @mod
…and what’s the difference between @rem
and @mod
? The docs help out if
you look at the examples:
@rem(-5, 3) == -2(@divTrunc(a, b) * b) + @rem(a, b) == a@mod(-5, 3) == 1(@divFloor(a, b) * b) + @mod(a, b) == a
Yeah, we could probably do without @rem
. These builtins are constantly
changing, so at least the maintainers know it’s a problem.
Here are some more:
- What does
@fieldParentPtr
do? It’s about 5 pointer casts. You’ll have no clue until you read someone else using it. - Why is
@FieldType(T, f)
preferential to@Type(@field(T, f))
? - Which builtins are for GPUs and how do they work?
- Why are there
log(v)
,log2(v)
, andlog10(v)
instead of justlog(base, v)
? Same forexp
andexp2
. - Why must I use a builtin like
addWithOverflow
to access the overflow bit instead of destructing a 2nd argument with+%
or+|
?
Allocator is a mistake §
Writing std.mem.Allocator
gets real old real quick. It’s nice for reading
what functions allocate on the heap, but so is a new
operator.
Let me rebut each point of choosing an Allocator (which I will not repeat):
- If you’re making a library, your domain-specific problem probably lends itself to certain sizes and amounts of allocations. The library author should pick the best allocator for that. Dynamic allocators are only useful in the rare case that sizes and amounts are specified by the user AND it’s performance critical.
- If I’m linking libc, it has a no-fuss allocator that’s faster than Zig’s equivalent.
- This is a pain and now users have to worry about thread safety when choosing an allocator!
- I concede, the most interesting use of
Allocator
s are replacing many small allocations with a large ahead-of-time fixed one that can be on the stack or on the heap. However, these are often domain specific and on the heap, and you can see point #1 for that. - See #4.
- Idk why they repeated #5. This point should be removed from the docs.
- This problem is a test runner problem, not an allocator problem.
- See #7.
- USUALLY NONE OF THE ABOVE APPLY, PLEASE GIVE ME A DEFAULT GLOBAL ALLOCATOR.
- The allocator implementation is a separate problem from its abstraction.
Ironically, the recent SmpAllocator is a global allocator. But you still have to pass it around.
LLVM compiles slowly §
This will be fixed eventually, but in the meantime, LLVM’s code quality is usually not worth the wait.
> zig buildLLVM Emit Object...
Oh, and if you want to contribute to Zig or bootstrap it on a new platform, you’ll need to compile a custom fork of LLVM. That requires C++, Python3, and an good 15m of a modern CPU’s time.
Finally, upgrading LLVM in Zig is a massive pain.
Social problems §
A lot of these are to be expected when a nerd gains authority. Herein lies why I’ve stopped using Zig for about the past year.
Mission statement §
Andrew turned Zig into a non-profit in 2020, which is a good thing. But let’s break down its full mission statement:
The mission of the Zig Software Foundation is to promote, protect, and advance the Zig programming language,
Protect it from whom? Users suggesting bad features? Admins merging PRs that hurt developers?
to support and facilitate the growth of a diverse and international community of Zig programmers,
Diverse in what sense? Ethnicity? Why does that matter to a programming language?
and to provide education and guidance to students,
What education to what kind of students? Don’t they have parents and mentors to guide them?
teaching the next generation of programmers to be competent, ethical, and to hold each other to high standards.
Why only the next generation? Competent in what sense? Handling all error paths in their code? Ethical according to whom? US law firms specializing in copyright law? What standards? The Zig style guide?
Zig needs a new mission statement that aligns with zig zen
and the reason the
language started in the first place.
Bus factor §
Zig’s bus factor is one Andrew Kelley. I’ve listened to all his talks, most of his livestreams, and read 1000s of his chat messages. I agree with 95% of his technical takes, which is really special.
While I trust his technical leadership, I’m not so sure about his social leadership. What drives Andrew’s non-technical decisions? How is he managing the few developers he employs? Why did a $5k bounty on an issue make him and Loris write this angry post?
While the non-profit does hold him accountable for the money he spends, I do wish there was a board to hold him accountable to a new ZSF mission statement that aligns with what users want from the language.
Contributor neglect §
I’ve done probably ~1mo worth of unpaid work on Zig’s stdlib and generally have been treated poorly. I contributed only when I faced bugs.
I faced a bug in the TLS client. The existing code was very difficult to read and understand. The client code failed to abstract the TLS layer which makes sharing it with the TLS server untenable.
So I wrote a giant patch. It worked and was better than status-quo. Similar giant patches have been merged before, but the unofficial crypto maintainer with merge permissions wanted it broken up into smaller patches. I reluctantly agreed.
At first the ball was rolling, but then nearly 4mo passed for a small patch that he asked for. During that period the maintainer was active on Github… I guess he forgot multiple times or didn’t care.
Meanwhile, extremely similar large patches are self-merged.
My poor contribution experience didn’t end there, though. While implementing the TLS server, I had to touch some buggy DateTime functions that may actually contain a CVE. Rather than just solve my local problem I wanted to solve it for the whole stdlib which had three different implementations. I wanted to do so without compromise — making it as fast and generic as possible.
So I researched the state of the art for date math (which is pretty cool!) and spent a couple weeks implementing and testing it.
I commented on the existing DateTime proposal with my findings and opened a PR that I hope would move the conversation forward. Andrew did not comment on the proposal, but did reject the PR and asked for me to create yet another downstream DateTime library. But wait! This response contradicts the first point of his hatred for bug bounties:
Bounties foster competition at the expense of cooperation.
Actively encouraging competition downstream excludes contributors from collaborating upstream.
I’m sure all this could be solved in 10 minutes of talking, but as you’ll see in the next section, I gave multiple opportunities for this to happen.
Championing issues §
It’s not clear how to win support for issues. You would think a problem as simple as “print today’s date” would garner some support, but in Zig you cannot do this without a 3rd party library or doing complex date math yourself.
Championing solutions §
Take a blessed issue, like add std.crypto.tls.Server.
What’s the official path for implementing it upstream? It’s clearly not as simple as “write, test, and open a PR” since that’s been done twice without progress towards merging.
Do I wait around for bi-annual reviews on small PRs?
Do I write angry comments on Github and IRC until someone with authority answers?
Please foster collaboration §
I’ve opened issues, PRs, and talked in Discord about these issues. There’s been no collaboration by any maintainers.
The reality is that pieces of my PRs will continue to end up in the codebase over time, but without crediting me. I guess that’s what the Mission Statement means by:
teaching the next generation of programmers to be competent, ethical, and to hold each other to high standards.
Suggestions §
Here’s some basic steps the ZSF can take:
- Start a MAINTAINERS file.
- Maintainer response to open PRs within a month.
- Maintainer response to open issues within a year (or sooner if you pay).
If any of those steps were taken, I wouldn’t be writing this.