Image for post
Image for post

Is Zig the Long Awaited C Replacement?

Comparison with previous C contenders such as C++, D, Java, C#, Go, Rust and Swift

many ways my whole programming career feels like a long wait for a replacement to C. 20 years ago I though that was C++. Over time I learned C++ was a complex monster that could never be tamed no matter how many thick best practices books I read.

I think Yossi Kreinin with his C++ Frequently Questioned Answers does a pretty good job of summarizing everything I have come to hate about C++.

So while being a professional C++ programmer I always had a look at the alternatives. The first hopeful alternative was D. D looked promising initially but upon closer inspection I decided that D was really just a cleaned up version of what was fundamentally a bad idea. One of the key problems with C++ its kitchen sink approach to language design. There is just too much in there.

While implementing a simple game engine in C and Lua, I came to realize that it was actually less mental overhead to keep these two languages in the head at the same time than C++. It brought me some renewed love for C. For all its limitations, C is a fairly simple language that gives you a lot of control.

Java and C# was in many ways just attempts at rehashing C++. They may have kept things more simple, but kind of ended up being too caught up in the Virtual Machine and object-oriented programming hype of the 90s. Not saying Java or C# are bad but not really my cup of tea. A lot of that may have more to do with the community around those languages which favor bloated IDEs and over-engineering.

Image for post
Image for post

The Return of C’s Simplicity

Go from Google was a welcome departure from the excesses C++, D, Java and C#. It took us right back to the starting point, to C. Go reimagined what C could have been if it did not venture down the C++ path. What we got back was a simple language that fixed many of the issues I always had with C.

But the story was not over. Closely following Go we got Rust. Initially I thought Rust was really what D should have been all along. A real rethinking of what C++ should have been. Rust kept low level control, high level abstraction mechanism and manual memory management but added unparalleled type safety. It all looked a bit too good to be true.

And frankly I think it was. I remember being able to write some decent programs in Go within about two days. Julia, my current favorite was also somewhat similar. Learning Rust on the other hand felt a lot like learning Haskell. Simply a lot of concepts and theory to understand before you can do anything useful.

If C++ taught me anything, it is to value simplicity, and that is not something Rust does.

Later I came to learn from comment by many Rust users on the internet that Rust repeated one of the cardinal sins of C++. It has really slow compilation time. Nothing destroyed my joy of programming I think than waiting for C++ to compile. And it almost sounds as if Rust is worse. That is a deal breaker.

Image for post
Image for post

Swift — The Language I Wanted to Love

I have been a huge Apple fan for over 20 years now. I loved the Cocoa GUI library and programmed Objective-C long before there was an iPhone and suddenly everybody and their dog was programming Objective-C.

Yes Objective-C was kind of clunky, but it had a certain beauty in its simplicity. Unlike C++ it was a fairly minimalistic addition to the C language. From experience you can actually teach a junior developer Objective-C really fast.

Thus the unveiling of Swift made me think I had reached programming Nirvana. Finally a really modern language that integrated well with Objective-C so we could still use awesome Apple libraries such as Cocoa.

Swift borrowed many ideas from Rust, and in many ways I thought we had finally gotten a sort of Rust for mortals. Swift can be learned in a decent amount of time.

But my experience with Swift has been mixed. Even today I have problems properly articulating what is wrong with the language because it seems to get so many things right.

I ported an iPhone application from Objective-C to Swift. One of my nice experiences was how Swift uncovered a hole bunch of bugs simply due to the strict type system that caught problems invisible in Objective-C which is famously lax about types, at least at compile time.

Compared to C++, C# and Java I would say Swift is the better language. Almost all my specific issues with C++ was solved by Swift. But I realized each time I spent some time with Go, that it was simply much more fun to program Go than Swift. Yet Go has kind of shitty error handling, and it repeated the million dollar mistake of having null pointers. Swift avoided both those problems.

Last time I came back to Swift after spending a lot of time with Julia, it became clearer to see some of the problems with Swift:

Swift Syntax is Poorly Suited for Functional Programming

The Smalltalk inspired syntax continued from Objective-C works very well with object-oriented programming, but is simply terrible for functional programming. When using functions as first class objects you don’t want to fiddle with making sure you got the right parameter names.

An uneasy truce between object-oriented and functional programming. Swift is trying to serve two different masters and suffering for it. When doing very functional style programming you want your functionality to be primarily in free functions. These are easier to pass around and use in a functional setting.

But Swift ended up catering primarily to the OOP crowd, placing functionality primarily in methods. Once you have done a lot of functional programming this becomes jarring.

Where Zig Fits in the Programming Landscape?

So Swift never really worked out as my ultimate all purpose programming language. If I want to program at a higher abstraction level, get high performance and get stuff done I will go with Julia.

But that still leaves an unfilled space for a C like alternative. Julia cannot really replace C. It gobbles memory, cannot produce small binaries, isn’t that suitable for making libraries other languages can use. You would not want to use it to create an OS kernel or do microcontroller programming.

Both Go and Rust got really close to being a replacement of C. Go pulled off the getting the simplicity and a lot of the feeling of using C. But it uses garbage collection which doesn’t make it fully C replacement. It is worth nothing that you still have more control over memory usage in Go than in Java, since you got pointers and can actually create your own secondary allocators.

Rust got the manual memory allocation bit down, but failed in replicating the simplicity and feel of C. Is there perhaps something that fills the space between these two languages?

Indeed there is. That is exactly what I think Zig is. Zig is more complex than Go, but much simpler than Rust to learn and use.

But such a summary of Zig does not do the language justice. Zig brings a lot of new ideas to table which makes a lot of sense and which makes the experiencing of coding Zig quite unique. But before diving into that let us look at the basics.

Getting the Basics Right

If we are to pick up another C like language we cannot repeat the worst of C++ such as atrocious compilation times. How does Zig stack up?

I came across this test by Alexander Medvednikov, the creator of the V programming language. This is a test of compiling a file with a 400 K function:

  • C 5.2s gcc test.c
  • C++ 1m 25s g++ test.cpp
  • Zig 10.1s zig build-exe test.zig
  • Nim 45s nim c test.nim
  • Rust Stopped after 30 minutes rustc test.rs
  • Swift Stopped after 30 minutes swiftc test.swift
  • D Segfault after 6 minutes dmd test.d
  • V 0.6s v test.v

Rust, Swift and D all fail at this. Medvednikov does further tests of these languages with fewer lines, where again Rust does worst as expected.

As you can see in the list, Zig is among the star performers. Although it is hard to not notice that the V language does it all in less than a second. Which reminds me to explore V, in more detail. A quick scan suggests it is sort of Go with manual memory allocation, generics and optionals (null pointer must be explicitly allowed).

Zig Memory Allocation

You cannot be a C contended without doing manual memory management. People who do C style programming want that. If I don’t need that, then I would program Julia instead.

Zig does not offer the sort of over the top safety that Rust does, but what it gains from not doing so is a model which is much easier to grasp and work with for beginners.

Anything that needs to allocate memory in Zig takes an allocator as an argument. So Zig is very explicit about when memory management is needed.

Here is a simple function I wrote which takes a 32-bit unsigned integer n and splits it up into its decimal digits:

fn decimals(alloc: *Allocator, n: u32) !Array(u32) {
var x = n;
var digits = Array(u32).init(alloc);
errdefer digits.deinit();

while (x >= 10) {
try digits.append(x % 10);
x = x / 10;
}
try digits.append(x);
return digits;
}

Notice how the array digits used to hold the individual decimal digits has to be allocated using an allocator, provided as argument to the decimals function.

And this is where Zig really shines. Making sure you don’t forget to deallocate memory is hard in C. And it is easy to end up doing it in the wrong place. Zig copies the defer concept from Go. But in addition to defer it has errdefer. If you don't know Go, then defer is basically a way to defer the execution of a line or block of code until the function exits.

Why is that so great? Because it allows you to make sure some code gets run regardless of whatever convoluted if-else-statement used before exiting the function.

errdefer digits.deinit();

The line above is a twist to the normal Go defer in that it only gets executed in case an error code is returned. Thus if everything is fine, then it will never run.

At the call site, we will use normal defer to make sure we don't forget to deallocate the memory that was allocated for our digits.

const digits = try decimals(allocator, 4123);
defer digits.deinit();
for (digits.items) |digit| {
try print("{},", .{digit});
}

From my limited experience playing with Zig, I would say this is quite a good system. The combination of use of allocators and defer makes you very conscious of where you are allocating and deallocate memory while making it easy to do so correctly.

C Compatible

What ruins a lot of C-like languages is that they don’t play nice with C. By that I mean it should be easy to call C functions from the language and it should be easy to call function on the language from C.

In addition the general way you program should be quite C-compatible so you don’t have to create a large C-abstraction level. E.g. C++ isn’t very C friendly, because a typical C++ library cannot be used from C without extensive wrapping.

Zig however if very C friendly because there are no oddball stuff exposed that C doesn’t get. There are no vtables (table to virtual functions in C++) in structs. No constructors or destructors that C has no clue about how to call. Nor are there any exceptions that C also would struggle with catching.

Using C from Zig is trivial. In fact the Zig creators will claim Zig is better at using C libraries than C itself.

const c = @cImport({
@cDefine("_NO_CRT_STDIO_INLINE", "1");
@cInclude("stdio.h");
});

pub fn main() void {
_ = c.printf("hello\n");
}

As you can see Zig has no problems parsing C header files and including types and functions from C. In fact Zig is a fully fledged C compiler. You can compile your C programs with Zig if you want.

Likewise exposing Zig functions to C is easy. Here is a Zig function taking to 32-bit integers and returning a 32-bit integer.

export fn add(a: i32, b: i32) i32 {
return a + b;
}

By placing export in front of it, we make it accessible to C code we are linking in with our program. In fact our main function is defined in the C code part, and it uses a function defined in Zig.

#include <stdint.h>
int32_t add(int32_t a, int32_t b);

int main(int argc, char **argv) {
assert(add(42, 1337) == 1379);
return 0;
}

This means you could easily start translating parts of a larger C program to Zig and keep compiling it. That is a very powerful feature when porting a program. What made it really easy for me to port from Objective-C to Swift in the past is that I could replace one single Objective-C method at a time with a Swift version, compile and see that everything still worked.

In fact Zig makes it even easier by allowing you to automatically translate a C program to Zig code. This is built into the Zig compiler:

$ zig translate-c foobar.c

Of course this code will not be optimal and likely a bit messy. But it is kind of like using Google translate to do a natural language translation. It is a good starting position that saves you a lot of manual labour. You can fix up the details manually yourself later.

Minimalism

What attracts a lot of people to C programming in the first place is minimalism. This is what Go got right and made it a joy to program. You could easily keep the whole program in your head.

Now if you start reading up on Zig and looking at the source code examples I gave you here it may look complex. There are language constructs that may look odd. One can easily get the impression that it is a complex language.

Thus it is actually useful to clarify all the things Zig doesn’t have to show how small it is:

  • No class inheritance like in C++, Java, Swift etc.
  • No runtime polymorphism through interfaces like Go.
  • No generics. You cannot specify generic interfaces like in Swift checked at compile time.
  • No function overloading. You cannot write a function with the same name multiple times taking different arguments.
  • No exception throwing.
  • No closures.
  • No garbage collection.
  • No constructors and destructors for use with Resource acquisition is initialization (RAII).

However Zig is able to provide much the same functionality by clever use of a few core features:

  • Types can be passed around like objects at compile time.
  • Tagged Unions. Also called sum types or variants in other programming languages.
  • Function pointers.
  • Real pointers and pointer arithmetic.
  • Zig code can be partially evaluated at compile time. You can mark code to be executable at compile time, with comptime keyword.
  • Functions and types can be associated with structs, but are not physically stored in structs, so invisible to C code.

Simulating Generics in Zig

E.g. something akin to templates is create in Julia by utilizing the ability to run code at compile time. Loris Cro has a good article describing this in more detail. I will just use an example from that article to give a quick into to the idea.

We can define e.g. a function called LinkedList which can only be called at compile time, which takes a type for the elements in the linked list, and returns a linked list type holding these elements:

fn LinkedList(comptime T: type) type {
return struct {
pub const Node = struct {
prev: ?*Node = null,
next: ?*Node = null,
data: T,
};

first: ?*Node = null,
last: ?*Node = null,
len: usize = 0,
};
}

This utilizes the fact that structs can be anonymous. You don’t need to give them a name. This function needs a little bit of unpacking however. Notice this part:

pub const Node = struct {
prev: ?*Node = null,
next: ?*Node = null,
data: T,
};

There are a number of Zig specific features at play here which need some explanation. In Zig you can assign values to members of structs when the struct is defined. The members can be fields existing at compile time or runtime. prev: ?*Node = null is an example of a struct field which exists at compile time but which gets a default value of null. What about the crazy ?* prefix then?

In Zig *Node would mean a pointer to an object of type Node just like in C/C++. However since Zig does not allow pointers to be null unless you explicitly allow it, you must add a ? to indicate the pointer can be null.

Node itself is set as a field of the surrounding anonymous struct. However since it is defined as a const it only exists at compilation time. If you inspected the memory of the LinkedList struct at runtime, you would find no area corresponding to Node.

Also keep in mind that while you can use types as any other object at compilation time, they don’t exist at runtime in Zig. So basically what we are doing here is creating a struct with nested types.

Let me use one of Loris Cro’s examples to explain better. First he creates a linked list holding points, and assigns it to a variable only existing at compilation time called PointList:

const PointList = LinkedList(Point);

We can then instantiate an empty list using this newly created type.

var my_list = PointList{};

We didn’t need to specify any initial value for first, last and len because they got default values.

Here we use the nested types to create a Node object to hold our point data:

const p = Point{ .x = 0, .y = 2, .z = 8 };

var node = PointList.Node{ .data = p };
my_list.first = &node;
my_list.last = &node;
my_list.len = 1;

Simulating Interfaces in Zig

While Zig has not keywords for classes, or interfaces like object-oriented languages we can still build our own runtime polymorphism system akin to how C-programmers have already done so for years.

You simply define a struct with function pointers. In the Unix kernel you see something similar done to allow a generic treatment of any file descriptor whether they are a file, socket or pipe.

typedef struct _File {
void (*write)(void *fd, char *data);
void (*read)(void *fd, char *buffer, int size);
void (*close)(void *fd);
} File;

This is not exactly how it is define. I am just doing this from memory. What this allows us to do is have different open functions for files, sockets and pipes. But since they all give us a File struct back, other function can operate on this using these function pointers it contains, and this abstracts away the difference in underlying structure.

In Zig we use a similar approach described in more detail here by Nathan Michaels. Zig offers better features for doing this than C, so you see it use more to create generic iterators, allocators, readers, writers and many more things in Zig.

One may ask, why not build these kinds of things into the language? If you have ever used Lua, you will know some of the advantages of instead giving you the building blocks to create an object-oriented system rather than hardwiring it.

With Zig you could build C++ style object-oriented system, something more akin to Go or even object-oriented programming more similar to a more dynamic language like Objective-C.

This roll-your-own approach can be very powerful. We have seen LISP programmers use this to build object-oriented programming systems in LISP, even creating multiple-dispatch systems akin to Julia.

Final Remarks

To be honest this story is a bit half-baked as I realize Zig is just too big of a topic to cover in some grans sweeping story. There are so many other topics I could cover but which would simply make this story excessively long. Instead I will attempt to write multiple smaller articles focused on different aspects of Zig in the future.

By all means give me feedback on what you want to hear more about.

Written by

Geek dad, living in Oslo, Norway with passion for UX, Julia programming, science, teaching, reading and writing.

Get the Medium app

A button that says 'Download on the App Store', and if clicked it will lead you to the iOS App store
A button that says 'Get it on, Google Play', and if clicked it will lead you to the Google Play store