2 min reado2l

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:

  1. Everything is an object. Numbers, text, collections and errors all follow the same model.
  2. There is no null. Absence is Optional<T>, and failure is Result<T, E> or an exception.
  3. 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, while and break. 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/finally for exceptional paths, and Result<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 (send and receive) 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-pkg for project scaffolding, with an o2l.toml manifest;
  • o2l-fmt for 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.

← all writing