upvalue.io
now | posts | contact

Trialing Zig and Rust with a Tcl interpreter

  • Tcl and picol
  • How a simple Tcl interpreter works
  • C++
  • Zig
  • Rust
  • In the end
PUBLISHED
August 16, 2025
UPDATED
September 11, 2025
TAGS
#otium
PREVIOUS A minimal TRMNL setup to display your own stuff
NEXT Just put the whole backend in the frontend

Recently I’ve been researching some operating systems and programming language design and wanted to try out a few of the newer options for an implementation substrate.

I decided that writing a simple Tcl interpreter would be a fun project to get a better idea of what’s out there. Making a little scripting language helps you exercise a number of important basics (string handling, reasonably complex structures, memory management, first class or not so first class functions) and at the end of it you have a fun program that you can also exercise the language’s ecosystem with.

picture of Tk, Tcl's GUI toolkit

Pictured: not this Tcl implementation

My background here is some hobby experience with C and C++. I’m definitely interested in knowing where I could’ve done better, please send your roasts to my email.

If you want to skip to the end, here’s the code: C++, Zig, Rust.

#Tcl and picol

Tcl is a programming language where everything is a string. This puts it into the Lisp/Forth category where uncompromising simplicity also makes it relatively easy to implement a kernel that can then be metaprogrammed upward until you have some pretty high level constructs on humble beginnings.

antirez’s picol is a fantastic exemplar of a small Tcl which gets to a working Tcl interpreter in 556 LoC of C. To give an idea of what picol/Tcl can do, the fibonacci number sequence calculator:

proc fib {x} {
    if {<= $x 1} {
        return 1
    } else {
        + [fib [- $x 1]] [fib [- $x 2]]
    }
}

puts [fib 20]

#How a simple Tcl interpreter works

I used picol as the main reference for how to write a Tcl implementation. In Picol and my three implementations here, there are three major components:

  • Parser: tokenizes the source code
  • Interpreter: evaluates the source code by building arrays of strings to be called with commands
  • Standard library: Even primitives like proc and if are defined as simple functions rather than syntactic forms that need to be special cased

While the above code sample has a lot of familiar looking syntax (braces and brackets and so on), it’s actually mostly strings. Braces are a sort of string, so it parses to something like this:

escape token: proc
separator token
escape token: fib
separator token
string token: x
separator token
string token: ...fibonacci function body

Once we have this stream of tokens, the interpreter handles executing them. One aspect of Tcl that was a little mystifying to me initially is that the interpreter doesn’t really behave like an AST-walking interpreter that goes into a tree and comes out with a value.

In Tcl, it’s more of a string concatenation engine. Evaluating the tokens results in an array of strings, and each line is a command that gets called with that array of strings as its argument. The output of all this can be read at a central result string.

While there’s a little bit of syntax for common tasks like getting a variable’s value ($x), the simplicity and stringiness is what enables Tcl’s wild metaprogramming. In the above fibonacci example, proc and if are just given their bodies as a string, and call the interpreter on that string when appropriate.

#C++

Code

Plunging ahead without understanding the Tcl very well and learning new languages like Zig at the same time proved to be a little confusing. So I started with a C++ version.

As antirez points out, a lot of the C code is simply string and container handling functions that aren’t provided in the standard library. If you’re willing to lower yourself to using the STL you can avoid needing to deal with reallocing arrays and such.

Using std::string refs and std::string_view removed some unnecessary string cloning in the interpreter, making it a couple seconds faster on calculating fib(35). Most manual string allocation and copying is gone because of RAII, and it’d be possible to further reduce the usage of new/delete.

Binding to C and adding dependencies

One little wrinkle I decided to throw into my interpreters was using a third party library (for argument parsing) and linenoise (for the REPL) to see how using dependencies and calling out to C code feels.

In C++, using Linenoise was trivial since it’s written in C and even includes header guards to make sure it’s C++ safe.

Then I pulled in the argh single header library. Header-only and especially single header libraries are popular in C/C++ world; due to the lack of a commonly accepted package manager and build system, it can be painful to include libraries with non-trivial builds in another project.

#Zig

Code

I started off with Ziglings which is a fun little way of learning a language. I was able to get through it in a few hours and retained syntax a lot better than my usual method of trying to do things and then searching after running into errors.

Allocator choice and memory management

Zig comes with a couple of in-built allocators to choose from. It turns out the default allocator, GeneralPurposeAllocator is not written with performance in mind (and in fact has been renamed to DebugAllocator in 0.15).

My impression from reading this GitHub issue and others was that Zig’s creator and community prefer to use other memory management strategies:

as you become an advanced programmer you start to learn about better memory management techniques that makes GPA (ed note: General Purpose Allocator) performance irrelevant

It hardly matters for our toy interpreter but it is an interesting thought exercise. Which allocation strategy to use for an interpreter is really dictated by the use case. So I decided to do as the Zig standard library does and let the user specify the allocator.

Binding to C and adding dependencies

Adding a dependency was easy; I added clap and just did what the README told me to.

Zig includes a full on wrapper of Clang so adding Linenoise was trivial. With about 3 lines of build config and 2 lines of Zig code I was able to use it with no fuss.

Overall thoughts

Comparing the C++ and Zig versions, I think the Zig code comes out a lot cleaner.

We get to use an error union on return types which allows us to more easily propagate some helpful information about the error, including errors that come from Zig’s standard library. Zig has a nice try shorthand for not having to unwind the stack by hand as in Go’s if err != nil.

I was pleased to learn that Zig supports tail call optimization and using labelled blocks anywhere; while I only used tail calls once, for writing parsers and interpreters these are serious quality of life boosts.

And then there’s just things like having a built in enum-to-string converter so it doesn’t need to be added by hand for each enum. Or getting to use standard library containers like ArrayList instead of needing to self-roll or pull in library, as in C.

I found the return to manual memory management over RAII a little tedious and got tripped up in a few places because of it. But no hidden memory allocations is an explicit philosophical goal of Zig, so this is take it or leave it.

Zig also comes with a lot of built in debugging that requires external tooling in C/C++ — one of which is that the GeneralPurposeAllocator has leak detection.

I was bitten by versioning twice: first when trying to use Ziglings, which is already on 0.15. The nightly build of Zig didn’t run for me, so I downgraded Ziglings to 0.14. And again when adding the clap dependency; there was no tag for 0.14 so I had to find the right commit by hand. Various online resources were also already out of date for 0.15 or even 0.14.

I also found the pointer and slice syntax to be a bit cumbersome.

👍 Would Zig again.

#Rust

Code

From the Ziglings README I learned about Rustlings. Rustlings doesn’t include instructional material directly in the file like Ziglings, but recommends following the Rust Book. I mostly consulted a half-hour to learn Rust.

I definitely experienced a steeper learning curve here. While I was able to get through Rustlings without too much help, it was often less clear to me why something was or should be done in a particular way, particularly around lifetimes.

For example, the first chunk of code here gives an error:

eprintln!(
    "{{\"token\": \"TK_{:?}\", \"begin\": {}, \"end\": {}, \"body\": \"{}\"}}",
    tk,
    self.begin,
    self.end,
    self.token_body()
);

And the fix:

let begin = self.begin;
let end = self.end;
eprintln!(
    "{{\"token\": \"TK_{:?}\", \"begin\": {}, \"end\": {}, \"body\": \"{}\"}}",
    tk,
    begin,
    end,
    self.token_body()
);

Maybe this makes perfect sense once you’ve been working with the borrow checker and this particular family of macros for a while, but it was surprising to me.

That being said having only read about Rust for many years, I was prepared for a struggle with the borrow checker that never materialized when writing the interpreter. Maybe it’s the straightforward nature of this program but it seemed that Rustlings equipped me well enough to reason about what needed to be done. As I wanted the parser to remain allocation free, it got a lifetime annotation. Other than that and figuring out how to handle procedure data destruction there weren’t any real stumbling blocks.

Binding to C and adding dependencies

Adding a dependency was once again pretty easy and it turned out zig-clap was (I assume) inspired by clap-rs.

There’s a bit more ceremony around compiling and accessing C than in Zig. There’s a cc crate that handles compiling and linking C, and C code needs to be wrapped in unsafe blocks.

Overall thoughts

I think the Rust program ends up being pretty similar to the Zig one, aside from borrow checking instead of manual memory management. Pattern matching is exceptionally helpful for writing interpreters & compilers but here doesn’t get used in a way that’s different from Zig switch. While Rustlings did take me longer to get through than Ziglings, once I was past that it didn’t take me any longer to write this than the Zig version.

👍 Would Rust again.

#In the end

This was a fun exercise that helped me get a taste of each language without committing to a big project. The implementations could probably use a lot more polish to make them Rusty. Or Ziggy. Or Rustonic or whatever you’d term it, but I’m stopping here.

I was also surprised that both the Rust and Zig versions are a couple seconds faster than C++ on the silly fib(35) benchmark. I intentionally didn’t change much about how the code works, so I’m curious why that is.

There’s a few ways in which this falls short of really getting at the differing philosophies of each language.

Simple Tcl interpreters avoid the expression problem because everything is a string. Since strings don’t have cyclical references and can just be copied everywhere, they also don’t need garbage collection.

We also didn’t get into metaprogramming (comptime or macros) but I can definitely see cases for this shape of program where they’d really improve safety and maintenance over what I generally end up doing in C.

Something like an R4RS Scheme interpreter with an extensible type system would get you into this territory while still being manageable within ~weeks of work but was more than I wanted to bite off.

Because of this and because I enjoy not having my house TP’d and egged, I also don’t have a hot take on which of these is better nor have I decided which to use yet. I really liked:

  • Actual dependency management and build systems as first class citizens
  • Built in tests, error handling, syntax, quality of life is so much better
  • The language servers for both Just Worked (TM) so I got a nice editor experience
  • Null safety 🙏🙏🙏

Lots of progress has been made since I last experimented with this in 2018 which in retrospect feels almost like the dark ages. Thanks to the implementers and communities for pushing forward.

PREVIOUS A minimal TRMNL setup to display your own stuff
NEXT Just put the whole backend in the frontend
now / posts / contact
GitHub