Zig: The Bad Parts

(updated )

The bad parts.
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.

Square peg, round hole
Sometimes you really need interfaces.

For example, how can I tell the caller that I expect T to be constrained to additive types?

no-interfaces.zig
fn foo(comptime T: type, a: T, b: T) T {
return a + b;
}

Well, I guess I can compile foo("hello", "world"):

zig run no-interfaces.zig
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:

  1. Write a comment that T must allow T + T and hope your readers read it.
no-interfaces.zig
/// Please only use `T`s that support addition...
fn foo(comptime T: type, a: T, b: T) T {
  1. Rewrite the function using type reflection:

Pig reflection
When you reach for type reflection, things get ugly.

kinda-interfaces.zig
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:

zig run no-interfaces.zig
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:

my-reader.zig
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:

no-extensions.zig
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:

zig run no-extensions.zig
: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:

Pig
There's an animal inside this Pig. It is NOT an animal.

  1. Use a field for the parent:
parent-field.zig
const Pig = struct {
// I guess pigs _contain_ animals.
// They are certainly NOT animals!
animal: Animal,
};
  1. Cleverly use confusingnamespace until it’s removed.
  2. 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

I have problems.
And they all fit into an integer.

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:

error-values.zig
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:

  1. Just log it. If you’re writing a library you’ll want to expose some log options:
error-log.zig
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;
}
  1. Create a custom Result type and abandon the nice try verify() syntax:
error-result.zig
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 = {} };
}
  1. Use Rust or C++.

Bugs, bugs, and more bugs…

Lots of ladybugs
The past and current Zig codebase.

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:

confusing-builtins.zig
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-vs-mod.zig
@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:

Allocator is a mistake

I need my space
...from my global allocator.

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):

  1. 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.
  2. If I’m linking libc, it has a no-fuss allocator that’s faster than Zig’s equivalent.
  3. This is a pain and now users have to worry about thread safety when choosing an allocator!
  4. I concede, the most interesting use of Allocators 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.
  5. See #4.
  6. Idk why they repeated #5. This point should be removed from the docs.
  7. This problem is a test runner problem, not an allocator problem.
  8. See #7.
  9. USUALLY NONE OF THE ABOVE APPLY, PLEASE GIVE ME A DEFAULT GLOBAL ALLOCATOR.
  10. 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.

i've spent hours looking at this
> zig build
LLVM 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.

LLVM compiling

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:

Form 1023
Don't you love taxes?

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 hope you don't have to deal with calendars in Zig!

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

Gladiator
Me for DateTime and TLS.

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:

If any of those steps were taken, I wouldn’t be writing this.