Functional Programmers need to take a look at Zig.

✨ Discover this awesome post from Hacker News 📖

📂 **Category**:

💡 **What You’ll Learn**:

I’ve been tinkering around with Zig to explore what’s possible with comptime.
Whenever I evaluate a new language I use three axes:

  1. How well can I express my ideas in this language. Or in other words, how easy
    is it for me to express the domain of the program. This is a test on how much
    noise is applied to the ideas I want to express in the program. Noise is
    anything that must be written for the program to function that is not
    relevant to the domain. For example, the canonical example of noise is the
    need to do manual memory management. We must allocate memory for the program
    to run, but this is orthogonal to the program’s domain; its an implementation
    detail.
  2. What facilities does the language provide me to create
    correct-by-construction systems and how easily can I program the type-system.
    This is essentially a test on how well I can program the language itself or
    how well I can create a deep embedding.
  3. What is the mean-time to a surprise. In the study of vacuum systems (think
    outer space) there is a concept called the mean-free path length or just
    mean-free path. The mean-free path of a vacuum system is the average
    distance a particle can travel in the system without experiencing a
    collision, it is essentially a metric of how good the vacuum is. When I apply
    this concept to programming languages I think of it as “How much code can I
    write before my implementation differs from my understanding of the system I
    am implementing”. This is why I frame this metric as a “surprise”; its “how
    many lines of code can I write until I experience a surprise”. And a surprise
    is a delta between what I think I’ve implemented and what I’ve actually
    implemented.

Enter Zig. I’m interested in Zig for a few reasons. First, I suspect that
comptime is a simpler and more flexible system to achieve a lot of the
type-system programming I’ve seen in the Haskell-verse and I’ve done enough
Haskell (over 10 years) that programming the type system is now a hard
requirement for me to take any language seriously.

Second, I am desperately trying to avoid writing a functional systems language.
This is probably a blog post in its own right but the programming language
industry has not grokked the meaning of monads. Monads are not some kind of
obscure math-y thing that only the big brains think are necessary. No, instead
monads are a fundamental abstract algebraic description of imperative
programming as a computational context. They allow a programming language to not
have a built-in notion of time (among other things). So if I want an imperative
programming language I can implement MonadCont (the continuation monad), if I
want a logic programming language I can implement LogicT (a monad that has
non-deterministic semantics and backtracking). Not having a built-in notion of
time means that my language is de-facto more expressive, allows users to mold
the language to their needs, and improves the optimization ceiling compilers for
that language can achieve.

So how does this connect with systems programming? Well, I’ve been radicalized.
I’ve learned enough performance-oriented programming to be dissatisfied with the
common functional languages (Haskell, OCaml, Common Lisp/Clojure, Scheme)
because each of these languages are predicated on the existence of garbage
collection and heaps. I think we are at the tail end of a large scale experiment
with garbage collection. We can now look back on the last 30 years and conclude
that garbage collection does communicate immense value by reducing noise, but
the tradeoff is that the one ends up with a forest of pointers into the heap and
that will always create a performance ceiling for the program and language
implementation.

To exacerbate matters, I think there is a cognitive risk to garbage collection.
Garbage collection makes it too easy to not think about or care about the
underlying machine and runtime system. This has created a generation of
developers who never gained or have lost the knowledge of how programs actually
execute on a computational machine. Or to use less flowery language, just look
at the era of software that garbage collectors have ushered in. Programs are
bloated, slow, and wasteful compared to the literal super-computers that are
running them. Surely we can do better.

Furthermore, I think the value proposition of garbage collectors has changed.
The first garbage collector was innovated in LISP in 1957, but once they gained
prominence in 1995 due to Java they proliferated, and for good reason. However,
the machines of 2026 are much different than the machines of 1995 (but our
languages aren’t ). Since 1995, compute on a CPU has grown something like 10,000
times faster while memory access timing has lagged. That was not the case
in 1995. In 1995 these were roughly comparable. So we are in a situation where
we are using languages designed for the machines of yesteryear that do not
consider the machines of today. As an industry we (largely) have stopped
innovating on new languages.

I once saw a talk by Steven Diehl that asked Where the next Programming Language
will come from? that beautifully described the sad state of things. His main
point is that the incentives for programming language innovation are at best
misaligned and at worst non-existent. He states that we can assume there are
three groups of people that are capable of innovation: Academics, Industry, and
Hobbyists. But academics have no incentive to do the real-world engineering
required to make a viable programming language, and any academics who decide to
try are committing career suicide. Industry cannot fund any long-term projects
(due to its culture of shareholder-value maximization) and are tied into sticky
network effects. Hobbyists (generally) don’t have the time nor the economic
means to make something real; which takes decades of full time work to
accomplish. And so we are stuck with a local maxima.

Okay now back to Zig. I’m bullish on Zig because Zig (and its BDFL Andrew
Kelley) are innovating and have the courage to innovate. Here are some
innovations. Zig discourages the forest of pointers approach and encourages
better manual memory management through Arenas and Allocators. This means that
users have much more control over the memory management of their programs. This
is just one reason why stuff written in Zig is so damn fast; Zig programs tend
to exploit the machines of today better than the machines of yesteryear. Zig
0.16 just released and reworked the IO system to an interface design, here is
the example from the release notes:

const std = @import("std");
const Io = std.Io;

pub fn main(init: std.process.Init) !void 🔥

You know what I see when I look at this code? I see http_client as existing in
a Reader monad that contains an allocator and an IO interface. This is
exactly how the IO monad (and for that matter IO#) works in Haskell. The
fact that the Zig people came up with this independantly speaks not just to
the universal nature of monads (and the algebraic structures of programming
languages) but also tells me that they are absolutely on the right track. Kudos
to that team! I even mentioned as much on the orange website which set off a
great discussion for those that are interested.

My last example is comptime. Recall that I want to be able to create
correct-by-construction programs. This means that I need nominal typing. Well a
Haskell-like newtype. In Zig is just a singleton struct:

struct PlayerHealth 🔥

Nice! Okay what about a sum type. Easy that’s just a union:

  const std = @import("std");

    fn Maybe(comptime T: type) type {
    return union(enum) {
        value: T,
        nothing,

        const Self = @This();

        pub fn just(the_val: T) Self   { return .🔥; }
        pub fn nothing() Self          { return .nothing; }

                        pub fn map(self: Self, comptime B: type, f: fn (T) B) Maybe(B) {
            return switch (self) {
                .value => |v| .{ .value = f(v) },                 .nothing => .nothing,
            };
        }
    };
}

Very nice! What about typeclasses? Again comptime and structs are the way:

    const std = @import("std");

        fn Eq(comptime T: type) type {
                        if (!@hasDecl(T, "eql"))           @compileError(@typeName(T) ++ " must implement eql(T, T) bool");

      return struct {
          pub fn eql(a: T, b: T) bool { return T.eql(a, b); }
          pub fn neq(a: T, b: T) bool { return !T.eql(a, b); }
      };
    }

        const Point = struct {
      x: i32,
      y: i32,

            pub fn eql(a: Point, b: Point) bool {
          return a.x == b.x and a.y == b.y;
      }
  };

    pub fn main() void {
    const EqPoint = Eq(Point);
    const a = Point{ .x = 1, .y = 2 };
    const b = Point{ .x = 1, .y = 2 };
    const c = Point{ .x = 3, .y = 4 };

    std.debug.print("a == c: {}\n", .{EqPoint.eql(a, c)});      std.debug.print("a == b: {}\n", .{EqPoint.eql(a, b)});  }

You’ll notice that this is a but clunky; we have to instantiate a Eq(Point)
and then call EqPoint.eql. EqPoint is essentially the typeclass dictionary
that Haskell creates behind the scenes, and so in Zig we have to manually pass
that dictionary around. The good part is that this comports with Zig’s “no
spooky action at a distance” philosophy, typeclass dictionary passing is
spooky-action at a distance. But we can make this more ergonomic by just
deriving Eq on our Point type:

const std = @import("std");

fn EqClass(comptime T: type) type {
    if (!@hasDecl(T, "eqlImpl"))         @compileError(@typeName(T) ++ " must implement eqlImpl(T, T) bool");

    return struct {
                pub fn eql(a: T, b: T) bool { return T.eqlImpl(a, b); }
        pub fn neq(a: T, b: T) bool { return !T.eqlImpl(a, b); }
    };
}

const Point = struct {
    x: i32,
    y: i32,

        pub fn eqlImpl(a: Point, b: Point) bool {
        return a.x == b.x and a.y == b.y;
    }

        pub const Eq = EqClass(@This());
};

pub fn main() void {
    const a = Point{ .x = 1, .y = 2 };
    const b = Point{ .x = 1, .y = 2 };
    const c = Point{ .x = 3, .y = 4 };

    std.debug.print("a == c: {}\n", .{Point.Eq.eql(a, c)});      std.debug.print("a == b: {}\n", .{Point.Eq.eql(a, b)});      std.debug.print("a != c: {}\n", .{Point.Eq.neq(a, c)});  }

Not bad. Its less ergonomic than Haskell for sure but I think that’s a good
thing. In fact, I look at this, squint really hard and see something like an ML
module system hiding behind the scenes. I could go on. In fact, in Zig you can
use structs to create closures, to curry, and therefore write higher-ordered
functions all without a garbage collector (if that interests you check out the
standard libraries sort function). But I’ll save that for another post.

Needless to say I’m bullish on Zig. I think Zig is doing so much right. Zig has
what I need and want to express my domain and ideas succinctly (this was 1. in
my original criteria). Zig’s comptime has very good support for programming the
type system and creating correct-by-construction programs. I still have to
experiment more with it, but I think it is atleast in the ball park of Haskell
and easier to program at that (this was 2.). Finally, Zig’s “no spooky action at
a distance” means I have not once been surprised about the semantics in my
experimentation. I’m sure there will be edge cases but I can confidentally say
that Zig has a greater mean free path length than languages that its often
positioned against: Rust, C++ (for sure). I love Haskell and its been my go to
language for the last 10-13 years but god damn I see a lot of Zig in my future.

{💬|⚡|🔥} **What’s your take?**
Share your thoughts in the comments below!

#️⃣ **#Functional #Programmers #Zig**

🕒 **Posted on**: 1777540086

🌟 **Want more?** Click here for more info! 🌟

By

Leave a Reply

Your email address will not be published. Required fields are marked *