Is Zig the Secret Weapon Your Project Needs?

Created by Andrew Kelly, Zig prioritizes simplicity, low-level hardware control, and empowering programmers to focus on debugging their applications rather than the complexities of the language.

Is Zig the Secret Weapon Your Project Needs?

In recent years, there has been a growing need to find a replacement for C, which is a 50-year-old programming language that still forms the backbone of much of our software infrastructure. However, this is no easy task as C is ubiquitous, and its successors, such as Rust and Go, face a significant challenge in supplanting it. This is where Zig comes in. It is an ambitious new language that aims to replace not only C but also C++, Rust and Go in systems programming and to supplant tools like LLVM that underpin C's dominance. This makes Zig a fascinating option for language enthusiasts.

Zig was created by Andrew Kelly, who wanted to build a digital audio workstation. Unsatisfied with existing languages due to their trade-offs between control and complexity, he sought a better solution. Audio processing requires languages that offer precise hardware control, and while C and C++ do provide low-level control, they are not perfect. C lacks robust metaprogramming, while C++ is often seen as overly complex and abstraction-heavy.

Kelly desired a language that delivered low-level access without the baggage of C or the complexity of C++. This led to the creation of Zig, which focused on empowering the programmer to debug their application rather than the language itself.

Hallmark of Zig

Zig is a programming language that takes a unique approach to systems programming. It aims to be self-sufficient, unlike other languages that either compile to C or use the C standard library. One of the standout features of Zig is its focus on true cross-compilation. This means you can build a Zig project for any target architecture from any host architecture. For instance, you can build a Zig project for an ARM device on an x86-64 machine.

Moreover, Zig's cross-compilation capability extends to C and C++ code as well. The built-in C/C++ compiler makes it possible to handle cross-compilation scenarios for projects mixing Rust and C or Go and C. This is a remarkable capability that is not typically found in compilers.

Zig's unique feature of cross-compilation has made it indispensable in situations like AWS Lambda, where Rust relies on specific versions of libc. Zig enables Rust developers to compile their lambdas to target that specific library smoothly.

C Interoperability in Zig

Importing C Headers

Zig allows seamless importing of C header files (.h). This auto-generates the bridging code, making C definitions accessible directly from Zig.

#include <stdio.h>

int printf(const char *format, ...);

@import("std")which brings in Zig's standard library, needed for basic things like error handling.

const std = @import("std");
const c = @cImport({
    @cInclude("stdio.h");
});

pub fn main() !void {
    try c.printf("Hello from Zig!\n");
}

@cImport(...) is where the magic happens. It lets you interact with C code.

@cInclude("stdio.h") inside the @cImport block, this directly includes the C header file. This makes the declarations within it (like printf) available to your Zig code.

Null-Terminated Strings

Zig string literals are inherently null-terminated, ideal for C compatibility. The standard library offers functions to manage null-terminated strings even though Zig's primary string types are not.

Zig offers multiple ways to work with null-terminated strings to maximize both safety and C interoperability:

  • Literals are Null-Terminated

Zig string literals (like "Hello, World!") include an implicit null terminator at the end. This means you can pass them directly to most C functions expecting null-terminated strings without any conversion.

  • C-style Pointers

The type [*:null]const u8 represents a null-terminated string pointer. Use this when you need to interact with C APIs that explicitly take this type of pointer.

  • Slices

Slices are Zig's preferred string type. They are a pointer and a length. They do not have a null terminator by default. This is safer, preventing accidental buffer overruns. You can use functions in the Zig standard library to convert between slices and null-terminated strings as needed.

Safety Improvements Over C

  • Pointer Arithmetic: While Zig allows pointer arithmetic (useful for OS interaction), it discourages it for regular use by requiring explicit conversions between pointers and integers in the type system.
  • Null Pointers: Optional pointers clearly signal whether a pointer can be null.
  • *String Handling: Diverse pointer types clearly convey expectations in the type system:
    • A single item pointer: *const u8
    • An array of unknown length: []const u8
    • Null-terminated strings: [*:0]const u8

This prevents subtle errors such as passing a non-null-terminated string to a function expecting one.

Zig for Rust Developers (and Others)

Zig's focus on type-safe C interaction could make it a compelling alternative for those who find Rust's borrow checker challenging. It provides more control and type safety than C without Rust's steeper learning curve in memory management.

Using defer

The defer keyword (similar to Go) ensures proper resource cleanup when functions exit, even with multiple exit points. This improves on C, which often requires messy goto-based cleanup code.

const std = @import("std");

pub fn main() !void {
    var file = try std.fs.cwd().createFile("hello.txt", .{});
    defer file.close(); // This runs no matter what

    try file.writeAll("Hello from Zig!\n"); 

    // ... more code. Even if an error occurs here, the file is closed.
}

defer keyword similar to GO

Zig's String Strategy

I believe that this topic deserves its own section. Many of the most popular programming languages face difficulties in handling Unicode complexities in a user-friendly manner. For instance, Rust's or Go's loop can confuse users by returning code points or scalar values instead of actual characters. This issue may be due to the size and complexity of the Unicode standard, which is essential to capture the subtleties of human language. Consequently, even experienced programmers may encounter difficulties in defining what constitutes a "character" in these languages.

However, Zig takes a different approach. While it recognizes the inherent complexities of the Unicode standard, it doesn't try to hide them from the programmer. Instead, Zig provides distinct string types for different use cases, allowing for both safe text handling within most Zig code and precise, lower-level manipulation when interfacing with C or when full Unicode awareness is necessary. This emphasis on clarity makes Zig a refreshing choice for users who have become frustrated by the Unicode complexities exposed in other programming languages like Rust and Go.

String literals in Zig

const print = @import("std").debug.print;
const mem = @import("std").mem; // will be used to compare bytes

pub fn main() void {
    const bytes = "hello";
    print("{}\n", .{@TypeOf(bytes)});                   // *const [5:0]u8
    print("{d}\n", .{bytes.len});                       // 5
    print("{c}\n", .{bytes[1]});                        // 'e'
    print("{d}\n", .{bytes[5]});                        // 0
    print("{}\n", .{'e' == '\x65'});                    // true
    print("{d}\n", .{'\u{1f4a9}'});                     // 128169
    print("{d}\n", .{'💯'});                            // 128175
    print("{u}\n", .{'⚡'});
    print("{}\n", .{mem.eql(u8, "hello", "h\x65llo")});      // true
    print("{}\n", .{mem.eql(u8, "💯", "\xf0\x9f\x92\xaf")}); // also true
    const invalid_utf8 = "\xff\xfe";      // non-UTF-8 strings are possible with \xNN notation.
    print("0x{x}\n", .{invalid_utf8[1]}); // indexing them returns individual bytes...
    print("0x{x}\n", .{"💯"[1]});    // ...as does indexing part-way through non-ASCII characters
}

string_literals.zig

Understanding Zig's CompTime

No Runtime Type Information

Zig, like C, doesn't keep track of types while the program runs. However, it provides powerful compile-time type analysis capabilities.

Generics the Zig Way 

Instead of special syntax or 'diamond brackets', Zig implements generics using functions that take a type as input (comptime T: type) and return a new type. This code runs at compile-time, just like a normal Zig function.

const std = @import("std");

pub fn main() !void {
	var arr: IntArray(3) = undefined;
	arr[0] = 1;
	arr[1] = 10;
	arr[2] = 100;
	std.debug.print("{any}\n", .{arr});
}

fn IntArray(comptime length: usize) type {
	return [length]i64;
}

Protection

The compiler limits compile-time execution to prevent infinite loops (giving up with an error if a quota is exceeded).

Programmer Experience

Natural Feel

Zig's approach feels intuitive, especially for programmers with Lisp experience. It's like writing macros, with a focus on treating code as data for metaprogramming.

Beyond Lisp Macros

While Zig's comptime has limitations compared to the full power of Lisp macros, it provides a more structured way to reason about types. You can introspect types with functions like typeInfo to obtain data structures describing their structure.

Imagine you want to write a function that can serialize basic data structures into a textual format (like JSON, but custom). For this, you'll need to be able to analyze different struct types at compile time and decide how to represent their fields.

const std = @import("std");

// A simple struct to represent a Person 
struct Person {
    name: []const u8,
    age: u32,
}

fn serialize(comptime T: type) type {
    const info = @typeInfo(T);

    // Hypothetical code to loop through fields
    for (info.fields) |field| {
        if (field.field_type == .int) {
            // Serialize integer field
        } else if (field.field_type == .string) {
            // Serialize string field
        } else {
            // Error: Unsupported field type
        }
    }
    // ... rest of the serialization logic 
}

fn main() !void {
    // Let's serialize a Person!
    const person_data = serialize(Person); 
    std.debug.warn("Serialized: {}", person_data); 
}

Why Zig?

Zig's core principles are simplicity, clarity, and direct control. This translates into clear benefits for programmers:

  • C Interoperability with Safety: Seamlessly import C headers, use defer them for cleanup, and manage pointers with type-safe precision. No more messy C workarounds and mystery crashes!
  • Memory Management Made (Somewhat) Easier: Allocators are explicit, helping you audit usage. Debug builds have leak detection, and you can even opt for intentional memory leaks in release builds for maximum performance.
  • Compile-Time as Your Metaprogramming Playground: Write macros like you're in Lisp, but with the structure of Zig and powerful introspection. Craft custom compile errors for bespoke type-checking (fn equals(comptime str: []const u8, ...)`.

Getting Started: Find Your Learning Path

  1. Official Website: Start at https://ziglang.org/. The "Learn" section has everything you need to install Zig and links to resources.
  2. Ziglings: Do you love hands-on learning? The Ziglings: https://github.com/ratfactor/ziglings tutorial challenges you to fix tiny, broken programs, gradually teaching you core Zig concepts. Think "Clojure Koans" for Zig!
  3. Language Reference: If you like deep dives, the website's single-page language reference concisely explains Zig's syntax. It's a great reference to keep handy.

Beyond the Basics

Remember, we've only scratched the surface of Zig's capabilities. Its ambition to replace foundational tools like C and LLVM is a long journey. But for those seeking a language that empowers you to control your code and the tools around it, Zig is a project to keep your eye on.

Your Hardware Experiment Awaits

Thinking back to my Arduino frustrations. Zig could be the language that finally gives us the smooth embedded programming experience we've been craving. And for you, that might translate to other low-level projects!

The Zig Zen

Before I leave you, remember the Easter egg: type zig zen at your command line. This will reveal Zig's philosophy, a great reminder of what makes it unique.

Let me know what you discover in the world of Zig!