I write Ruby for a living, and about two years ago it started bothering me that I had no real idea what happens between typing x = 5 and the machine doing something about it.
So I wrote a toy interpreter over a weekend. Got it evaluating arithmetic. Felt clever. Then instead of moving on I kept adding things to it, and eighteen months later it's AlexScript: an interpreted, object-oriented scripting language with a standard library, async/await, a debugger, and a web framework written in itself.
Every keyword is in Polish, which is my first language.
klasa Kwadrat {
funkcja konstruktor(bok) {
niech @bok = bok
}
funkcja pole() {
zwroc @bok * @bok
}
funkcja opis() {
zwroc "Kwadrat o polu #{sam.pole()}"
}
}
niech k = Kwadrat.nowy(5)
pokazl k.opis() # Kwadrat o polu 25
klasa is class, funkcja is function, niech is let, zwroc is return.
One thing about that last one. Polish has diacritics, and correct spelling is zwróć, not zwroc. But typing ó and ć means AltGr combinations on every keyboard, which gets old fast when you're writing a loop. So most keywords have two forms: an ASCII one you actually type, and an accented alias for people who want their code spelled properly. jesli/jeśli, dopoki/dopóki, falsz/fałsz. The lexer accepts both. I went back and forth on this for an embarrassingly long time.
Anyway, a Polish part is what some people might be curious about, but it's not what I learned the most from. Building an interpreter in Ruby taught me more about Ruby than many years of Rails apps did.
1. Using exceptions for control flow is expensive
The obvious way to implement return in a tree-walking interpreter is to raise something and catch it at the call site. It works immediately, which is why everyone does it first.
It's also a performance problem, because Ruby builds an exception object and captures a backtrace every single time. On recursive code, where you're returning constantly, this dominates everything else.
Fix is throw/catch, which most Rubyists know exists and almost nobody uses:
# in the return statement
throw :alex_return, interpreter.interpret!(@value, env)
# at the call site
result = catch(:alex_return) do
# evaluate the function body
end
No exception object, no backtrace, no stack unwinding machinery. Worth knowing that throw/catch only works for non-local exits you control, so it's not a general replacement for raise. But for something like function return, where you know exactly where the jump lands, it's the right tool.
2. Indexing into a UTF-8 string is O(n)
My lexer originally scanned character by character with source[position]. Fine for ASCII. But Ruby strings are UTF-8, and to find the nth character, Ruby has to walk the string from the beginning counting codepoints. On a file full of Polish diacritics, tokenisation was accidentally quadratic.
Switching to getbyte and byteslice fixed it:
byte = @source.getbyte(@current) # O(1), no allocation
lexeme = @source.byteslice(@start, @current - @start).force_encoding(Encoding::UTF_8)
If you're writing anything that scans text character by character and your input might not be ASCII, check this.
3. MRI keeps C methods and Ruby methods in the same table, and you should too
My standard library is written in Ruby. User code is written in AlexScript. The naive approach is two registries: one for native methods, one for user-defined ones, and dispatch checks both.
Don't do that. I injected native methods into the exact same method table that user classes populate, tagged with a flag:
methods[name] = { declaration: nil, env: nil, private: false, native_lambda: lambda_fn }
Dispatch is a single lookup, and the branch happens at the very last step:
if method_info[:native_lambda]
NativeClassRegistry.dispatch_native_lambda(...)
else
# evaluate the AST body
end
The payoff is that native classes become genuinely first class. You can subclass them, super reaches into them, and reflection can't tell them apart from user classes.
I got to this the slow way. The first version had a separate native registry, and it was fine until I wanted a user class to inherit from a stdlib one, and then every layer above dispatch needed a "but builtins are special" branch. Method lookup, super resolution, reflection, the debugger. I rewrote it twice before giving up and merging the tables, and the diff that did it deleted more code than it added.
I didn't invent this. MRI does the same thing with cfunc and Ruby methods sharing a method table. Reading how CRuby handles method dispatch was probably the single most useful thing I did for this project.
4. Fibers are a real concurrency primitive, not a curiosity
Most Ruby developers know Fibers exist and have never used one. I ended up building an entire async runtime on them.
czekaj (await) parks the current fiber with Fiber.yield and hands control back to a custom reactor: a ready queue, a sorted timer list, and an IO.select loop. When a promise settles, it pushes its waiters back onto the queue. The reactor installs itself as Fiber.set_scheduler while it runs, so stdlib operations inside fibers go through the scheduler interface.
asynchroniczna funkcja main() {
niech wolny = powolne_zapytanie() # starts a fiber, returns a promise
niech szybki = szybkie_zapytanie()
niech wyniki = czekaj Obietnica.wszystkie([wolny, szybki])
}
uruchom(main)
Fibers give you suspend and resume. Everything else is bookkeeping, and there is less of it than I expected going in.
5. WeakRef will ruin your week
I used WeakRef for closure environments, reasoning that it would help the GC clean up scopes nobody was using any more.
What actually happened was Invalid Reference: probably recycled appearing at random, in code that had worked ten minutes earlier, with no reliable reproduction. The GC was collecting environments that were still logically reachable, just not through references it could see.
Strong references everywhere now. Memory is fine.
6. Ruby's exception system can host another language's exception system
AlexScript exceptions are ordinary classes that inherit from a base exception class, Ruby style.
Every one of them is backed by a real Ruby exception class, resolved by walking the user's class hierarchy up to the nearest builtin. Which means proba/zlap/wkoncu is literally begin/rescue/ensure underneath. Real stack unwinding, real backtraces, no homegrown stack walking, no manual cleanup handling.
The part I'd underestimated is how much of an exception system is actually cleanup semantics rather than the throwing itself. Ensure blocks running in the right order when an exception passes through three nested scopes, that sort of thing. All of that came along for free.
7. Arbitrary-precision integers are free and you forget they exist
At some point I implemented exact rational arithmetic on top of integer pairs, and computed Bernoulli numbers with it. B(60) has a 43-digit numerator. It came out exactly right, with no overflow and no precision loss, because Ruby integers just grow.
If you're used to languages where int means 64 bits, that's worth remembering.
What went badly
Web framework was supposed to use fiber-based concurrency end to end. Then I hit a Ruby bug where the fiber scheduler can't be interrupted by IO#close, so a fiber blocked on a socket read hangs when the client disconnects. Still open. Server runs thread-per-connection instead, which is less elegant but doesn't lock up.
I also have no eval, no method_missing, no define_method, and no macros. It's a tree-walking interpreter, so performance has a ceiling. And the practical case for a language with Polish keywords is, let's be honest, limited.
Try it without installing anything
There's a browser REPL compiled to WASM with ruby.wasm, if you want to poke at it for two minutes: link
Top comments (0)