Two worked examples
Everything in 30-teaching.md and 40-backends.md exists because
of two programs in this repository. Neither of them changes a single line in src/ or stage0/.
examples/api | examples/lang | |
|---|---|---|
| what it is | an HTTP + SQLite todo API | a language, lx, with classes, generics and reference counting |
| what it teaches | class, interface, bool, str | fn, single inheritance, virtual/override, interfaces, generics with where, ref, namespaces, automatic memory |
| size of the module | oop.mc, 482 lines | 9 modules, 2,831 lines |
| built by | mc build examples/api | mc build examples/lang |
| checked by | make check-examples | make check-lang |
examples/api — a todos API in a language it teaches itself #
$ mc build examples/api
compiler build/mc-api.mc -> build/mc-api
compile main.mc -> build/api
Two steps: a compiler that understands class and interface, and then the server compiled by
it. No make, no ld.
examples/api/
mc-api.mc this directory's compiler: <mc/core> + oop.mc + user_init()
oop.mc teaches `class` and `interface` through the parser's public API
lib/rt.mc a fixed arena, strings, strbuf, itoa/atoi
lib/http.mc sockets, HTTP/1.1 request and response
lib/sqlite.mc #dylib "/usr/lib/libsqlite3.dylib" + externs + wrappers
main.mc the API: routes, handlers, the database
mc.toml the whole build as data
test.sh brings the server up on a free port and hits every route
The compiler is twenty lines #
// mc-api.mc
#include <mc/core>
#include "oop.mc"
void user_init() {
syntax("class", &oop_class); // top-level declaration
syntax("interface", &oop_interface); // top-level declaration
type_alias("bool", TY_U8); // a new type, no new syntax
type_alias("str", TY_UPTR);
}
That is the entire interface between the example and the compiler. oop.mc then consumes tokens
with p_id, p_next, p_type, p_ident, parse_function, parse_block … and hands ordinary
declarations back through top_add.
What a class turns into #
written in main.mc | generated by oop.mc |
|---|---|
interface Handler { | type_alias("Handler", TY_UPTR) |
i64 handle(self, Request req, Response res); | #define HANDLER_HANDLE 0 and a dispatcher i64 handler_handle(uptr self, …) { return callp(ld64(ld64(self) + 0), self, …); } |
class Todo { | type_alias("Todo", TY_UPTR) |
i64 id; | #define TODO_ID 0 · todo_id(self) · set_todo_id(self, v) |
bool done; | #define TODO_DONE 16 + accessors using ld8/st8 |
str json(self) { … } | uptr todo_json(uptr self) { … } — self prepended to the parameters |
} | #define TODO_SIZE 24 and uptr todo_new(), calling rt_alloc(TODO_SIZE) |
class TodoHandler : Handler { | word 0 of the object reserved for the vtable; fields start at 8 |
} with an interface | u8 todohandler_vt[8], todohandler_vt_init() filling it with &todohandler_handle, and todohandler_new() |
main.mc's seven class/interface declarations expand into dozens of ordinary ones —
accessors, dispatchers, vtables and constructors — and you can watch it happen:
$ examples/api/build/mc-api --dump-ast examples/api/main.mc | grep -E '^(FUNC|GLOBAL)'
...
FUNC type=i64 name=handler_handle
FUNC type=uptr name=todohandler_db
FUNC type=i64 name=todohandler_handle
GLOBAL val=8 type=u8 name=todohandler_vt
FUNC name=todohandler_vt_init
FUNC type=uptr name=todohandler_new
Here is the whole mechanism in miniature — an interface, a class that implements it, and a
dispatch through the vtable, compiled by mc-api:
// expect-exit: 42
#include "../lib/rt.mc"
interface Greeter {
i64 greet(self, i64 n);
}
class Doubler : Greeter {
i64 base;
i64 greet(self, i64 n) { return doubler_base(self) + n * 2; }
}
i64 main() {
Doubler d = doubler_new();
set_doubler_base(d, 10);
Greeter g = d; // the concrete type is forgotten here
return greeter_greet(g, 16); // 10 + 32 = 42, through the vtable
}
Greeter g = d; is an ordinary uptr assignment; greeter_greet(g, 16) is
callp(ld64(ld64(g) + 0), g, 16). There is no dynamic dispatch in the language — there is
callp, and a module that generates the right table.
Diagnostics belong to the module #
An interface method the class does not implement is a compile error at the class line; an error
about a specific member points at the member's line:
$ build/mc-api missing.mc -o /tmp/x.o # a class that skips one interface method
missing.mc:8: interface method not implemented: name
$ build/mc-api dupfield.mc -o /tmp/x.o # a class with two fields of the same name
dupfield.mc:6: duplicate #define
The second one is the core's own message, reached through def_add — a module that generates
#defines gets the core's duplicate check for free.
The default compiler rejects the same source, which is the point: the syntax belongs to this directory, not to the language.
$ mc examples/api/main.mc -o /tmp/x.o
examples/api/main.mc:27: type expected in parameter # `str s` — no such type in the core
The rest of it #
Routing is a linear (prefix, Handler) table walked in registration order, and the main loop
never knows which handler it is calling. #dylib "/usr/lib/libsqlite3.dylib" plus thirteen
externs give the SQLite bindings; mc.toml's [libs]/[externs] say the same thing from
outside the source, and both are present on purpose — the #dylib wins for its own externs.
$ examples/api/build/api 8080 /tmp/todos.db &
api: port 8080, db /tmp/todos.db
$ curl -s -X POST --data-binary 'buy bread' localhost:8080/todos
{"id":1,"title":"buy bread","done":false}
$ curl -s localhost:8080/todos
[{"id":1,"title":"buy bread","done":false}]
$ curl -s -X DELETE localhost:8080/todos/1
{"deleted":1}
Known limits, all documented in examples/api/README.md: the runtime arena is fixed at 4 MiB and
never returns memory, the server handles one connection at a time, and #dylib only works along
the --exe path.
examples/lang — a whole language from a prelude #
$ mc build examples/lang
compiler build/mc-lang.mc -> build/mc-lang
compile main.lx -> build/lang-demo
lx has classes with single inheritance and virtual/override, interfaces, generics with
where constraints and const parameters, ref parameters, namespaces with import/using, and
automatic memory management by reference counting. None of that is in mc. It all lives in
lang.mc, a module that runs inside the compiler during the parse and hands the core nothing but
ordinary declarations.
// expect-exit: 0
// expect-stdout: 13
// expect-stdout: circle
#include "../lib/prelude.lx"
class Shape {
virtual i64 area(self) { return 0; }
virtual str name(self) { return "shape"; }
}
class Circle : Shape {
i64 r;
override i64 area(self) { return 3 * self.r * self.r; }
override str name(self) { return "circle"; }
}
interface Printable { str show(self); }
class Box<T, const N: i64> : Printable where T : Shape {
T items[N];
i64 count;
fn push(self, T s) { self.items[self.count] = s; self.count += 1; }
fn total(self) -> i64 {
i64 t = 0;
for (i64 i = 0; i < self.count; i += 1) { t += self.items[i].area(); }
return t;
}
str show(self) { return "box"; }
}
fn bump(ref i64 x) { x += 1; }
fn main() -> i64 {
Box<Circle, 4> b = new Box<Circle, 4>();
Circle c = new Circle();
c.r = 2;
b.push(c);
i64 n = 0;
bump(ref n);
print(b.total() + n); // 3*2*2 = 12, plus the bumped 1
prints(c.name()); // virtual dispatch through Shape
return 0;
}
What mc actually sees for that Box<Circle, 4>:
uptr Box__Circle__4_new();
void Box__Circle__4_push(uptr self, uptr s);
i64 Box__Circle__4_total(uptr self);
u8 Box__Circle__4_vt[24];
void Box__Circle__4_vt_init();
void Box__Circle__4_release(uptr self);
How each feature is built #
Nothing below is a special case in src/:
| feature | mechanism |
|---|---|
class, interface, namespace, import, using, fn | syntax(word, &f) — top-level position |
{, while, for, and every class or interface name | syntax_stmt(word, &f) — statement position |
new, ref, generic functions, namespaced calls | syntax_expr(word, &f) — expression position |
a.b, a.b = c, a.m(x), a[i], a[i] = v | syntax_infix(word, prec, &f) |
str, bool, and every class name used as a type | type_alias(name, TY_*) |
| the body of a generic | p_skip_balanced + p_start |
| one instantiation per argument tuple | p_push_source + p_subst_name/p_subst_int + p_depth |
Holder<Bag<Num, 2>> closing on >> | p_resplit_punct(1) |
Three of those are worth a sentence each.
syntax_stmt("{") is what makes a release happen per scope. K_LBRACE is not a core
keyword, so the module may own every statement-position block, nested ones included — and without
recursing, because the core's parse_block consumes its own brace. That is also why while and
for are handlers here rather than #rules: a rule's block hole is parsed by parse_block,
which would walk straight past the module's block handler and with it every scope-exit release.
syntax_infix(".") is what makes member access typed. The handler receives the left operand
already parsed and reads the member name itself, so what it emits depends on the static type the
module recorded for that expression: a field becomes ldW/stW at an offset, a virtual method
becomes callp(ld64(ld64(obj) + slot), obj, …), a plain method becomes a direct call. Member
assignment works because = is deliberately not in the core's infix table: the Pratt loop
has already stopped, the handler reads the =, and the core sees a plain expression statement.
Memory is reference counting with free lists, in lib/rt.mc — written in nothing but the
core language, so the default mc compiles it unchanged. The acceptance test churns 100 000
objects inside a 4 MiB arena, which is what proves the deallocation is real.
The core is still there underneath #
i64/u8/uptr, if, loop, break N, ld64/st64, #include, #define, #rule all
still work, and an .lx file may drop into the core whenever it wants to. Replace lang.mc with
a different file and the same mc compiles a different language.
Reading the sources #
Both examples are meant to be read. Start at examples/api/oop.mc — it is the smaller one and it
uses only syntax and type_alias. Then examples/lang/lang_expr.mc, which is where
syntax_infix(".") and the generic instantiation live.
The one deviation on record: examples/lang/mc.toml sets [compiler].core = "lang_core.mc"
instead of using the bundled <mc/core>, because the bundle's data array plus a module that size
exhausted the arena at the time it was written. The mechanism that removes that cause is in
place; the example was left exactly as it was verified.
Next #
How the compiler compiles itself, and the rules that keep it reproducible: 70-bootstrap.md.