Small Steps and Big Leaps for Self-Taught Engineers
How I tried to get good fast by building "Building an Interpreter in Go" in Rust
Why “Build Projects” is too simple
“Build projects” — that’s what those who teach themselves software or ML engineering are told.
I think this advice falls short.
Of course, you need to put in the hours. Solving the many little problems you encounter when building a project is how you build muscle memory and craft.
But by solving only the small issues you encounter, you only get incremental gains. You add polish.
If you’re unlucky, you may even learn a solution that doesn’t make sense in the bigger picture and end up parallelizing calculations on lists in Python instead of using vectorized NumPy operations.
To make large leaps in abilities, one must do both, build muscle memory with tools like programming languages, libraries, and frameworks while also building foundations in theory. With a proper theoretical grasp, you’d probably know why Python lists are slow and would come up with easier solutions than parallelization.
The catch? If the only thing you do is learning big conceptual stuff you may end up knowing a lot but are only capable of doing very little.
So, how to navigate this tension and are there ways you can accomplish both simultaneously?
That’s one question I asked myself at the beginning of this year when I quit my job and attended Recurse Center, a self-directed programming retreat, to focus on learning new skills full-time for a three-month batch. (Read here, if you are interested in how it went).
One obvious suggestion is building something complex, something beyond the current edge of your abilities with just enough handholding so you can cross it yourself.
Books are great at this; usually much more thought-through than blog posts, more extensive at the very least. They guide you through building something substantial from scratch and cover the necessary theory in passing by.
The good ones at least.
Some books are too textbook-y and provide you with loads of theory interspersed with only small self-contained exercises. Not too shabby, but I also wanted to build something I am proud of, something at least somewhat impressive, something I could talk about in an interview. Solving problem 4.23 is just not that.
On the other end are books that guide you through one comprehensive implementation. These are great for my goals.
But they are dangerous!
It’s all too easy to just zone out copy the code you see on the page, and mindlessly churn through pages to give you the satisfying feeling of progress while fooling yourself into thinking you understand what you type.
But there is an easy fix. Force yourself to think through every line by implementing what you read in a different language than what you are presented.
That’s why I picked up “Building an Interpreter in Go” with the intention to port it to Rust — a language I had started to learn a couple of months earlier and wanted to get better at.
But first, lets discuss:
Why build an interpreter?
I wanted to learn about programming languages for a long time. teachyourselfcs.com — a great collection of textbooks that I’m using as a guide through my self-learning of computer science — recommends learning about interpreters and compilers because
If you understand how languages and compilers actually work, you’ll write better code and learn new languages more easily.
Now that I’ve completed the project, I can attest to the truth of this statement. Building something from scratch yourself demystifies how it works and, at the same time, gives you a greater appreciation for the tools you use every day.
While teachyourselfcs recommends Crafting Interpreters, I went with “Building an Interpreter in Go”, mostly because I follow the author, Thorsten Ball, on Twitter. He seems like a nice and knowledgeable person. The Crafting Interpreter author is probably too. But you can only maintain so many asymmetric online relationships with engineering role models at a time…
I definitely did not regret my choice. “Building an Interpreter in Go” strikes a nice balance between discussing some theory and then diving into the implementation fast.
Nonetheless, I covered a lot of conceptual ground. How to lex and parse. How and why to build an abstract syntax tree and an internal object representation, and lastly evaluate all that to compute a result.
I implemented expressions, let and return statements, closures with their respective environments, and many other things. That it works, even though building it demystified the process, still feels like magic.
What did this project do to my Rust?
At the same time, writing along in Rust forced me to think through every line and I could build a lot of muscle memory. Repetition is the mother of didactics, after all.
Whenever I tripped over the philosophical differences between Go and Rust, I was in for a treat. Or a painful wrestle with the borrowchecker, depending on your framing.
The borrow checker is a part of the Rust compilation process that enforces Rusts rules of ownership and borrowing. This means you have to be intentional about which part of the code owns a value, whereas Go is more flexible and allows a value to be edited in different places.
To allow multiple places of my Rust code to access the same parts of the interpreted source code, I needed to wrap values into RC (reference counters) and RefCell (reference cell allowing interior mutability); types I knew from reading the Rust Book but which I hadn’t used before.
Option and Result enums are another feature of Rust that I grew to like. Rusts static types forced me to think a lot more rigorously about what a function was supposed to do and what possible failure modes and return values are. Option and Result are great for something that may be there or something that may succeed or fail.
What can you take away and what’s next for me?
There is a sequel, “Building a Compiler in Go” which I’ve started with already. It implements a small virtual machine to compile to. It’s great fun. So you can expect a sequel to this blog post, too. The learning curve feels steeper, which is nice because it means more learning per unit-time.
If you’d ask me for a recommendation, I think that implementing something big and complex, way out of your comfort zone, with some handholding but written in another language than it’s presented to you, is a great way to do both at the same time — learn new concepts and gain muscle memory with a new tool.
If you’re rock solid in the concepts building something you’re familiar with using some new tool to learn it may be the fastest way. Vice versa, if you know your tool and are fast and creative in applying it, learning new concepts by just trying and wiggling until it works is building a great intuition and deep understanding. But if you want to do both at the same time, a new tool and new concepts, following a reference that is close but substantially different so that it forces you to think through every step is a great way to maximize the bang for your buck.
I’m still in between jobs.
If you know a great series A+ company that hires at the intersection of MLE and SWE, preferably with (the potential to grow into) systems-level stuff, reach out.
Cool stuff I did recently:
20+ PRs into established Python tooling written in Rust (Ruff, UV, Zed, …)
Research I’ve submitted for publication with the AI Safety Institute at the German Aerospace Center. Including generating synthetic data using custom performance-optimized C++ backends. See here: Code.
The interpreter I’m yapping about here
You find my CV and contact on maxmynter.com
Great article and relatable btw !🙌🙌I started learning rust and i can’t even count how many times I’ve had to go back to the beginning of docs,and that has helped me to get some things stuck on my head 😂else is run into bugs and errors .not sure if in the only one that finds rust great but complex 😂(my first language was python )
Btw).Rust complexity teaches you to think the hard way and do lots of research ;making you to grab lots of info too 👌