tvu-compare

a comparison of rust and zig

  1. Introduction and Context
    1. What is this utility supposed to do?
  2. Rust
    1. Lifetimes
    2. Self-Referencing
    3. Deserialization of the TOML
    4. Macros
    5. Error Handling
    6. Traits and Generics
    7. Package Management
  3. Zig
    1. comptime and generics
    2. Allocators
    3. Interacting with i3 and stdlib concerns
    4. Build system and package management
  4. Comments on scientific and numerical code
  5. Conclusion

source code

Introduction and Context

I have spent most of the past 6 years working with the Julia programming language. Julia is a "high level" garbage-collected language with a strong emphasis on generality and extensibility. While Julia is miraculously well-suited for the types of applications for which it was originally conceived (scientific and numerical computing), it has some serious, even prohibitive limitations when it comes to applications that require short run-times and low latency. While it is hoped that these limitations are not fundamental (a lengthy discussion for another time, and another blog post) Julia also suffers from another limitation it shares with many other languages: garbage collection.

Recently, as I have taken on some projects to pry my autonomy from the clutches of the powers-that-be which uniformly view the proposition of an individual human owning their own computer as an inconvenience, sin or threat, I have found myself needing many linux command line utilities or daemons. At the moment, Julia is woefully inadequate for creating command utilities as it requires loading a massive (> 200 MB) run-time. In addition, while I may not be taking on many projects for which garbage-collection is a prohibitive feature for the foreseeable future, I am nonetheless quite conscious of the way in which garbage collection is a fundamental limitation and I would like to get re-acquainted with some non-garbage-collected languages.

For the first 8 or so years of my programming experience, while I was an undergraduate and later graduate student, working in the experimental and later theoretical high energy physics communities, my "go-to" language was C++. This experience led me to the same opinion of C++ as many of its practitioners: I both respect and despise it. Time to spend substantial time learning C++ alternatives.

I have therefore chosen two languages, rust and zig for writing a simple command line utility. The idea was to implement exactly the same utility in both languages, and then judge which I prefer. I began by implementing minimal functionality, later I will expand on it with the "winning" language.

In this blog post I would like to recount some of my experiences.

I am not going to draw any conclusions about which language is "better" in the absolute sense. While their domain of applicability is no doubt similar, each offers unique advantages. Rust, of course, is the only language in existence which offers run-time memory safety without garbage collection. Clearly anyone for whom this kind of safety is a major concern but cannot tolerate latency must consider rust. Zig is quite a different approach in that it has a very strong emphasis on simplicity (which rust certainly lacks) and attempts to answer the question "what if C didn't suck?".

What is this utility supposed to do?

The utility I needed is for my "TV machine". I have a rather normal x86 linux desktop machine (on a micro-ITX board in a small case) permanently hooked up to my television, and I use this machine to consume all media which I'd prefer to consume sitting in my recliner watching a television as opposed to sitting at a desk in front of a desktop. The main issue I need to overcome is that I need everything to be controllable with some kind of remote control rather than a keyboard or mouse. Fortunately, devices called air mouses, particularly those which can point via accelerometer are cheaply available and widely compatible so there isn't really that much I need to do. Still, I don't want to have to use this thing to navigate lots of fiddly menus. My desktop setup is heavily keyboard-based in part because I find GUI menus annoying. This will also serve as a prelude to the much more formidable task of setting up a linux phone which I can use as my one-and-only smart phone, which I hope to work on over the course of 2022.

My setup for the TV machine simply uses the i3 window manager in a specific way. In particular, I have different video streaming sources permanently placed in their own i3 workspaces. This allows me to quickly swap between any screen I might be using with a single button. I do however, also require a menu to open applications which are not already running and place them in the appropriate workspace. For example, I might start the machine, load up netflix, freetube and steam (in big picture mode) via a menu, leave them open, and then navigate between them with a button press that I have configured in my i3 config file. You can find other configuration for this project here. For graphical menus I'm using eww, a rust utility for easily creating graphical widgets.

Anyway, there are a number of command line utilities I can write to help with this, but the most vital one is for setting up the menus. I need it to do a few things:

I should emphasize that for this rust-zig exercise I did the bare minimum. More functionality can be added once I'm more willing to commit to a single language. In particular, the utility is NOT a task manager, and is not a daemon, so the TOML is parsed fresh every time the program runs and we simply ask i3 for a list of existing workspaces rather than actually trying to determine what programs are running or which windows are open. This will lead to faulty behavior if windows are misplaced, but in practice that never happens for me, so this functionality is perfectly adequate for the time being.

Rust

As I've already noted, rust is quite unique in that it offers run-time memory safety without garbage collection. As a long-time C++ user, I tend to think of the way this works as "everything is a smart pointer that the compiler will check to guarantee safety", but this is perhaps not the most enlightening mental model for how rust works. Rust introduces what to my knowledge is an entirely novel mechanism: lifetimes. Of course, lifetimes already exist at least in theory in all languages, it is simply the code which is executed from when some memory is allocated to when it is freed, but in rust lifetimes are formal property of the language and must be managed explicitly as liftimes (instead of implicitly via pointers) by the programmer. The idea is that the compiler can check lifetimes at compile time to make sure nothing bad happens at run-time.

Lifetimes

I find it somewhat odd that rust's very extensive documentation does not place a bigger emphasis on the concept of lifetimes, since they are essential to how the entire language works and one of its most novel features. Frankly, the need to manage lifetimes can be quite unpleasant, and can seem to place a significant extra burden on the programmer. The rust compiler can validate your code once lifetimes are assigned but it cannot assign lifetimes on its own in all cases. For example, every struct containing references must have explicit lifetime annotations.

My conclusion that this is a significant extra burden on the programmer may not be entirely objective. Since rust is the only programming language which does this, my lack of familiarity with the concept undoubtedly caused me to spend more time worrying about it than I would were I more experienced. Again, it's not as if this is a foreign concept to other languages, but as a new user of rust I find the presentation so distinct that whatever mental pathways I have developed for this concept in other languages seem to give me some trouble in rust.

Still, it is not that hard for me to imagine contrived scenarios in which a complicated program could involve referencees with a bewildering variety of subtly different lifetimes which I don't think any amount of experience would make me feel comfortable with. For example, it is at least possible to write rust code like this:

struct Outer<'a, 'b, 'c, 'd> {
    field1: Inner1<'a>,
    field2: Inner2<'a, 'b>,
    field3: Inner3<'c, 'd>,
    some_string: &'c str,
}

For those unfamiliar, 'a is the rather bizarre notation rust uses for lifetimes, so in this example 'a, 'b, 'c', 'd are (not necessarily distinct) lifetimes.

Dealing with something like Outer seems like it would be an absolute nightmare, but we should ask just how contrived this example is. In any context I can easily invent, it would make sense for most of these lifetimes to be the same, simplifying the template. It's also worth considering how complicated is the series of allocations and deallocations which would require the use of thise many lifetimes which are distinct in the most general case, and how likely would we be to cause a segfault if we were not using rust and forced to address the issue with lifetimes?

I don't rightly know the answers to these questions. Certainly, from the perspective of an experienced C++ programmer (even one who hasn't really used C++ in years like me) rust at least gives the appearance of adding significant complexity to using some pointers which we imagine (perhaps wrongly) are simply not-that-big-a-deal.

Self-Referencing

The most concerning limitation I ran into that I ultimately attribute to rust's lifetimes model is how complicated it seems to be to self-reference. The problem is as follows, suppose you have a struct which owns some data, but the owned data shows up in multiple places? In my particular example, the most obvious use case for this was a hash map in which the keys are strings which also appear in data in the values. In any other language, there isn't much to consider here (unless said data is mutable, which it's not) just use a pointer. Rust really does not seem to like this approach. The problem seems to be that there is no way to promise rust that you will deallocate the entire struct properly. As far as rust is concerned, if you deallocate the data now you have dangling references, and code that does this is not allowed to compile. There is no way to promise rust that these references are not even going to be around after you deinitialize the object.

There are, of course, ways around this (for example, placing the data in a heap-allocated Box) but these introduce significant complexity for the sake of doing something pretty simple. I'm not sure if it's possible to get around the self-referencing problem without heap allocations. In my case, I simply gave up and copied the miniscule amount of data I had.

The self-referencing problem is the most significant fundamental limitation I've encountered in rust.

Deserialization of the TOML

Pretty much all serialization/deserialization in the rust ecosystem seems to revolve around the serde package. serde itself seems mostly to have to do with creating rust structs from serialized data. The parsing was of course done by a TOML-specific package.

Macros

At this point I need to discuss the extremely extensive use of macros in rust. For reasons I don't understand, rust has two different macro syntaxes, macro!(args) and #[macro(args)]. The latter is called an "annotation" and always precedes a function or struct which acts as a final argument (I think it was intended to look like C pre-compiler code, for some reason). Much to my surprise, rust code tends to use a lot of macros. Coming from Julia, a language which, like rust, simultaneously has native meta-programming and a complicated syntax tree, this had me a bit alarmed. In Julia forums, when new users start asking about macros, usually the first advice we give is something like "you probably don't need a macro here so don't use one". Macros are a wonderful feature for a language to have, but they should be use judiciously. Rust's built-in pattern matching leaves most macro code slightly less horrific than most Julia macro code, but only slightly.

Even in this very simple project I used quite a few macros (though I didn't have to write any). For one, structs which implement a trait which can be inferred from the struct alone can declare these in a macro rather than re-writing a minimal implementation. A prominent example is #[derive(Debug)] which implements a printable string describing the struct.

In our case here, we needed #[derive(Serialize, Deserialize)] to implement serialization and deserialization via serde. There was a bit of a complication here in that my objects did not exactly correspond to how they are represented in the TOML. I judged it easier to implement this simply by creating a "placeholder" struct which does exactly correspond to the TOML objects, and then creating the useful object from that. For example

#[derive(Deserialize,Serialize)]
struct TomlChannel {
    name: String,
    cmd: Vec<String>,
}

struct Channel {
    name: String,
    cmd: Command,
}

Command is a type in the rust stdlib for executing external processes. It was not trivial to get the list of strings to deserialize to one of these directly. Note that, as in most languages, you can't actually add methods to types which are declared in other libraries.

There are a number of more complicated macros available in serde for dealing with this sort of thing, but in my case it was vastly simpler to just declare an intermediate struct.

Error Handling

In both rust and zig errors are values returned from functions in union types, though rust additionally has the panic! macro, so I believe it would be correct to say that rust technically does have exceptions but typically one would not write code to "throw an exception".

Most rust functions return Result types which act as union types of errors with whatever would otherwise be the return type. Rust contains the ? syntax, for example

let mut i3 = I3::connect()?;

which unwraps the value except in the case of an error in which instead that error is returned from the enclosing function.

By default, most functions return the stdlib Result type which leads to a bit of a problem: the error types must be explicitly declared in the function definition. This becomes unmamageable if the function can return a variety of errors from calls inside it. The anyhow package addresses this by creating a more inclusive Result type which I used in my program.

I personally really dislike the degree to which rust and zig force the programmer to worry about error handling. The way I see it, things should not error, if they do, stop everything and print a stack trace, please don't bother me with it. On the other hand, I do have to admit, that the constant preoccupation with error handling makes a certain amount of sense for the kinds of applications these languages are designed for; certainly it's reasonable to expect command line utilities to give succinct error messages and codes rather than freaking out and printing a stack trace.

Traits and Generics

Inheritance is one of the worst things about C++, a confusing mess of unexpected complexity that you are usually better off avoiding. Rust takes a much saner approach: traits. A trait is just a set of methods a struct is guaranteed to implement. I certainly find rust's approach to generics a lot more intuitive than many other parts of the language. Functions and types can be declared essentially as templates with the ability to specify that a type must implement a certain trait.

Sometimes type constraints get a little more complicated. For example, Iterator is a trait, but an Iterator can return any type, and different return types may have different traits. Rust's language for describing traits is rich enough that it allows traits of the return values to be specified, for example the following appears in my code

pub fn command_from_strings<I: Iterator>(mut v: I) -> Command where I::Item: ToString {
    let mut cmd = Command::new(v.next().expect("tried to create command from empty array").to_string());
    for a in v { cmd.arg(a.to_string()); }
    cmd
}

Here I::Item means the return type of the iterator, and I'm specifying that this return type must have the ToString trait so that .to_string() may be called. As far as I know, no, rust does not allow you to simply call to_string() and hope that the argument has appropriate trait, regardless of whether this can be inferred at compile time. In other words, without the where I::Item: ToString the above will result in a compiler error, even if the return type indeed has .to_string() and this was inferrable at compile time. Note that in rust all iterators are mutable, which is why v is mut. (This is also the case in zig; it took me some getting used to because in Julia iterators are almost always immutable. Mutable iterators are sometimes incredibly convenient.)

There is no equivalent to ineheritance in rust. Traits with implementations that can be fully inferred from a struct istelf can implement the #[derive(Trait)] macro to avoid the boilerplate. Rusts implementation of generics via traits is general enough that I have a very hard time imagining any serious limitations to it (at least within a single library, again, you can't implement methods on structs which are defined in another library). Because the compiler forces you to be strict with trait bounds, as far as I am able to tell, the programmer needn't concern itself with whether types are fully inferrable at compile time.

Package Management

Rust's package manager is the famous cargo. Cargo has clearly had a huge influence on the design of package managers. Cargo feels quite familiar to me as the Julia package manager Pkg.jl is modeled on cargo, even down to the quite similar TOML files. The centralized repository for cargo crates (packages) is crates.io.

I don't have that much to say about cargo other than that, from a user perspective at least, it works extremely well, especially for rust-only dependencies. Unfortunately, I have experienced quite a few cargo build failures due to missing system libs and it is sometimes annoying to track down what must be installed via the OS package manager. This is never a problem for rust libs, only external (mostly C or C++) libs.

Zig

Zig is an attempt to answer the question "what if C didn't suck?". Indeed, in one of the presentations linked to on the zig website by its creator Andrew Kelley, he walks through some C code describing how he felt it should be "fixed". As one might expect, this approach leads to (at least superficially) a far simpler language than rust.

comptime and generics

One thing about C (and C++, for that matter) that clearly needs to be disposed of are pre-compiler directives. Zig's answer to this is a consistent language that has identical syntax whether it is guaranteed to occur at compile time or not, but with the keyword comptime for annotating code (or arguments) which must be executed (or known) at compile time. I find the comptime keyword quite wonderful in that it simultaneously allows for perfectly consistent syntax, even code re-use between compile time and run time, and a fully explicit description of what is happening at compile time and what is happening at run time.

Perhaps the most novel feature of zig is that this comptime keyword is, by itself, enough for generics. I find this to be one of the coolest things about zig, so let me give an extended example (note that, at the time I'm compiling this, the tool that's generating the code highlighting here, highlight.js doesn't have zig, so we'll just have to put up with whatever approximation we get from C highlighting)

const std = @import("std");

const A = struct {
    fn saysomething(self: @This()) void {
        std.log.debug("hello from A", .{});
    }
};

const B = struct {
    fn saysomething(self: @This()) void {
        std.log.debug("hello from B", .{});
    }
};

fn f(x: anytype) void {
    x.saysomething();
}

fn g(comptime T: type, x: T) void {
    comptime var z: i64 = undefined;
    comptime {
        if (T == A) {
            z = 0;
        } else {
            z = 1;
        }
    }
    std.log.debug("this was computed a long time ago {d}", .{z});
    x.saysomething();
}

pub fn main() !void {
    const a = A{};
    const b = B{};

    f(a);
    f(b);

    g(@TypeOf(a), a);
    g(@TypeOf(b), b);
}

Calling zig run on the above code gives

debug: hello from A
debug: hello from B
debug: this was computed a long time ago 0
debug: hello from A
debug: this was computed a long time ago 1
debug: hello from B

This is really cool. First of all, if zig is able to infer that code is valid at compile time as in f(a); f(b); it just does it (i.e. we did not have to worry about any equivalent to rust's traits. Also, in g(@TypeOf(a), a); g(@TypeOf(b), b);, we were able to explicitly "dispatch" at compile time. In other words, we needed to do some stuff at compile time depending on the type T and we were able to "just do that".

Ok, big deal, why is this exciting? What I find so impressive about the above code is that we have achieved (compile time) generics using ONLY the comptime keyword, i.e. generics are just a side effect of comptime.

Despite my enthusiasm, I am simultaneously a bit apprehensive about how far this can be extended to stuff that can't be fully inferred at compile time. For what it's worth, for the kinds of applications for which zig is intended, one probably can and should avoid such a situation about 19/20 of the time, but I am wary of how ugly things might get when it just isn't possible. For example, how much extra work are we going to have to do in IO code where we have lots of data coming in at run-time and we don't know the types of any of it?

I don't yet know the answer to this. It's worth pointing out that a library implementing generic run-time interfaces can be found here. It didn't take a lot of code, though it certainly seems a bit hackish, and I can't shake the feeling that there's a missing language feature here somewhere.

Allocators

Another unique feature of zig is that it does not treat memory as a hidden global state. Instead, there are structs dedicated to managing memory known as allocators. One intriguing consequence of this is that different programs are free to take completely different approaches to memory management. For example, there is such a thing as a FixedBufferAllocator which allows you to use stack allocated buffers, page_allocator for allocating entire pages of memory, or c_allocator which is just the allocator that C uses. The possible benefits of this approach are a bit beyond my domain of expertise, so I can't exactly come up with many fun things to do with these, but it has certainly raised my awareness of the extremely opaque way in which most languages (even C!) manage memory.

You can also see why that is the case: a ton of functions in zig will take a pointer to an allocator as an argument (technically, there is nothing stopping you from using globally defined allocators and omitting these arguments).

Freeing memory is sometimes done with deconstructors, other times with the allocator itself (e.g. by calling alloc.free(ptr)).

The language has a defer keyword for executing code in reverse order at the end of a block. At first this seemed a little silly to me (and it seems to mildly violate zig's overall philosophy of explicitness) but it is so frequently useful for de-allocation that in retrospect it seems like a good feature. I'm sure it will plug its fair share of memory leaks by making de-allocation harder to forget. For example

const b = try alloc.alloc(u8, 128);
defer alloc.free(b);

Allocates a 128-byte buffer and frees it at the end of the encosing scope.

Interacting with i3 and stdlib concerns

As zig is a very young language, it doesn't have anywhere near the ecosystem of rust. In my case, a needed library that was not available was something for interacting with the i3 window manager. Fortunately the i3 IPC is extremely simple, so I decided to implement it from sratch. I implemented only what I needed, in particular I do not deserialize all the i3 JSON return types into zig structs. Fortunately, a JSON stdlib already exists for parsing the JSON returned by i3, so I made use of that. The rest of it was just connecting to and read/writing to/from a unix socket, which is simply achieved with the zig stdlib.

The stdlib documentation is quite lacking. The stdlib itself seems to have changed significantly recently. I had to fork the zig-toml repo to update a TOML parser I found written in zig to work with the latest stdlib. The stdlib source code is pretty easy to read, which certainly helped endear me to the language.

Build system and package management

The zig developers are obviously aware that, for the foreseeable future, zig will need to do a lot of calling into C or C++ libraries. The language has a build system for helping with this, as well as managing other zig dependencies. The build system is just an stdlib, but the zig CLI seems to have been designed with the build system in mind. This means you don't have to deal with makefiles when compiling or linking your zig code, even if you have C or C++ dependencies, which is, of course, wonderful.

Zig does not have an stdlib package manager, but because it is very easy to link zig source files, and thanks to the existing build system, it is relatively easy to implement one. There is a 3rd party package manager for zig called gyro, which, though fairly minimal at this point, seems to work fine, despite the extremely dubious decision to use and entirely novel YAML-like markdown language for package descriptions.

Comments on scientific and numerical code

Even though this particular project certainly does not fall into this category in any way, as this is my area of expertise (even as a career "data scientist" most of my code more-or-less falls in this category) I couldn't resist imagining what it would be like writing the types of code I am most used to with rust or zig. After all, before Julia, your only options for writing, say a set of low-level linear algebra kernels would be C, C++, Fortran, assembly... but now rust and zig are options as well.

I am certainly not going to start writing scientific or numerical code in rust any time soon. It probably would not be as bad as, say, writing this kind of code in JavaScript, but it would be its own special kind of torture. Certainly the constant grappling with Rust's error handling would quickly become maddening. The aforementioned self-referencing problem would be a more fundamental limitation, though if you are dealing with e.g. very large matrices it is taken for granted that everything is heap allocated, and I suppose some kind of use of rust's Box (or similar) would be appropriate. While I can see traits being incredibly useful in that context, I imagine they'd be a mixed blessing: rust forces you to fully describe trait bounds, as far as I can tell it is not willing to make any inferences for you, and it's not that hard for me to imagine over-use of traits making code unnecessarily complicated.

There is one huge advantage that rust has over all the other alternatives mentioned above: bona fide first-class functions. Functionals are central to physics, a fact that will haunt anyone trying to write scientific code in C++, but rust at least has the bare minimal tenet of functional programming, and it also supports closures. By the way, have you seen rust's bizarre λ syntax? Not sure what they were smoking when they came up with that one.

Writing such code in zig on the other hand would be... maybe not so bad? After all, I wrote code like that in C++ for years... though, don't get me wrong, it absolutely sucked. I've pretty much been saying that zig is "C, but not shit", which in some circles would automatically put zig into consideration for stuff like low-level linear algebra kernels. After all, with the unfortunate rise in popularity of Python, the vast majority of numerical code in use in most scientific communities today is written in C, not C++. My aforementioned concerns about zigs generics (despite how much I like how they work) come to mind here, but since the most serious limitations would seem to involve types only known at run-time, this only seems like a serious worry for scientific/numerical computing where IO is involved. There are a few other annoyances, such as zig's disdain for operator overloading. I suppose I have to concede that forbidding this makes some sense for the kinds of applications zig was designed for, though I am always frustrated that designers of low level languages don't seem to appreciate why operator overloading is important for writing generic code. If you have some code that says a + b that works for floats, but not complex floats, then, well, that's a real problem (pun intended).

Lack of functional programming features in zig is a bigger concern, though, to be fair, it's a few steps ahead of C, Fortran and probably also C++ in this regard. Zig does not have classes, methods appearing in a struct are not special, merely using the struct name as their namespace. Zig also has (typed!) function pointers which therefore work the same way for every function. Therefore zig kind-of-sort-of has first class functions. (Contrast this to C++ which has function pointers in principle, but because the language is so obsessively object-oriented and class methods most certainly are not the same thing as global functions, in practice trying to do functional programming with C++ function pointers is like herding cats.) Zig does not, however, have any form of lambdas or anonymous functions (which is a little weird considering that in zig, even all structs are technically anonymous) and does not support closures.

Conclusion

If you have read the above it has probably become apparent by now that I had a hard time maintaining very much enthusiasm for rust. If I had to choose one word to describe the language, it would be "pedantic". By default, the programmer is burdened with the most laborious error handling I have ever encountered (though to be fair, this burden is greatly lessened with the use of a reasonable library, such as anyhow), meticulous management of lifetimes with a syntax that I cannot seem to get used to, the need for fully explicit trait bounds wherever they are used, and an alarmingly heavy use of macros.

That said, I'm not sure I can give cogent arguments that many of those things should have been otherwise. This is certainly not a JavaScript or even a Python: Rust's designers definitely knew what the hell they were doing, and I expect rust's proponents have detailed and reasonable justifications for almost anything I find unpleasant about the language. Indeed, there is clearly a huge emphasis on correctness that goes beyond memory management. The aspect of rust about which I am most deeply dubious is the heavy use of macros: they are a herald of rust's pedantry ("If you don't use these macros you are going to have to write a shit-ton of boilerplate code.") and can betray the Byzantine idiosyncrasies of the language and some of its libraries.

Rust's ecosystem seems quite healthy and it has a huge, robust stdlib. You can find libraries to do almost anything: there were, for example, multiple different crates available just for communicating with i3 and cargo makes it about as easy as it can be to swap between them.

Zig, on the other hand, is a lot more fun. Once you get used to its few odd quirks, zig code is extremely easy to understand, and it feels like it has largely achieved its goal of being a "C that doesn't suck". I can't help but feel that if I had set out to "fix C" I would have come to many of the same conclusions as Andrew Kelley, so, despite its novelty, zig feels surprisingly familiar. The biggest concern I have about zig is whether its innovative approach to generics is "enough". No, I'm not entirely sure what I mean by "enough", but it seems fair to say that "run time generics" aren't really a thing in zig right now, and I'm a bit nervous that after working with the language significantly more, serious limitations to its approach to generics may become apparent. I cannot say the same of rust's traits and templates, the only significant limitation I can see there is lack of extensibility.

So when should a person use rust and when should they use zig? After all, my conclusion here is certainly NOT "everyone should use zig instead of rust". The languages have similar goals, so this will usually come down to personal preference. Obviously, if, for whatever reason, you are ultra-paranoid about memory safety issues but cannot tolerate garbage collection, then it's almost as if you are obligated to use rust. That said, of course rust cannot magically guarantee that you will always write "correct" code. It's also not as if every piece of zig, C or C++ code you write is constantly at risk of segfaulting. In my C++ days, I can't say that the kinds of memory issues that rust was designed to solve were very often a serious concern for me (though, it's worth pointing out that I was doing scientific/numerical stuff that tended not to force me to do nearly as much allocating and deallocating as a more complicated run-time might).

I expect to be using zig quite a bit more in the future, and I'm looking forward to seeing how it develops. Writing identical code in these two languages turned out to be quite an educational experience; both languages are well worth learning. Without a doubt, it will not be the last time I write rust code, even if I don't turn to it as some kind of default. In fact, I'm sure I will be figuring out how to call rust code from zig before too long.