O²L: everything is an object, nothing is null
Design notes on a small object-oriented language, covering its single-variant value model, immutable objects, and the places where purity gave way to practicality.
O²L starts from three rules:
- Everything is an object. Numbers, text, collections and errors all follow the same model.
- There is no null. Absence is
Optional<T>, and failure isResult<T, E>or an exception. - Objects are immutable. Properties are fixed at construction, which removes the bugs that come from shared mutable state.
Object Greeter {
property message: Text
constructor(greeting: Text) { this.message = greeting }
@external method greet(name: Text): Text {
io.print("%s, %s!", this.message, name)
return this.message
}
}
Methods are private unless marked @external, so an object’s public surface is what you explicitly chose to expose.
One variant for the whole language
The interpreter is a classic tree-walker in C++23: lexer, parser, AST, and about 40 node types that each know how to evaluate themselves. The interesting part is the value model. Every runtime value is a single std::variant. It holds:
- primitive numbers and text;
- object instances, enums, records and protocols;
- lists, maps, sets and their iterators;
- results and errors;
- coroutine handles and channels;
- FFI types: pointers, buffers, C structs, arrays and callbacks.
The variant makes the interpreter honest. Every operation has to handle every kind of value, and the compiler checks that it does. One detail is Long. It is a real 128-bit integer where the platform supports one. On MSVC it falls back to 64 bits, with a tag that keeps Int and Long distinct.
Where purity gave way
A language this strict on paper can be tiring to write. O²L keeps a few practical constructs on purpose:
- Mutable locals. Properties stay immutable, but local variables inside a method can be reassigned. Accumulating a result in a loop shouldn’t require a fold.
- Ordinary control flow. There’s
if/else,whileandbreak. Protocol dispatch is the idiomatic way to branch on type, but it isn’t the only way to branch. - Two error styles.
throw/try/catch/finallyfor exceptional paths, andResult<T, E>where failure is an expected outcome.
Concurrency and FFI
Newer additions, contributed in part from outside the project:
- Green threads. A
spawn { … }block runs as a coroutine on a scheduler, and channels (sendandreceive) connect coroutines. - Async I/O. A pool of I/O threads keeps blocking calls off the scheduler.
- FFI. A libffi-based layer calls C libraries directly. The SQLite bindings are the showcase.
Tooling
A language needs more than an interpreter to be usable, so O²L also has:
o2l-pkgfor project scaffolding, with ano2l.tomlmanifest;o2l-fmtfor formatting;- a language server with a Neovim plugin;
- 478 tests, with CI on Linux, macOS and Windows.
Next on the roadmap is a bytecode compiler. A tree-walker is the right first implementation, because it makes the semantics easy to change, but not the last one.