std/proto: An Object Is a Proto, Interpreted
Everyone hates on OOP, and the hatred is mostly correct — but the usual indictment (“it’s super bad”) never survives the follow-up question. Why is it bad? The honest answer took us a design walk to reach: not because the ideas are wrong, but because the syntax welds four separable ideas together and forces you to reason about all of them whenever you wanted one.
So we ran the adversarial version of the question: can an OOP program be encoded perfectly in Koru — every semantic, no residual? If yes, OOP adds a spelling, not a capability, and the interesting work is deciding which spellings earn their keep.
The proto is the concept; the library is the interpreter
Here is the frame the walk converged on, and it is stronger than the one we
started with. A std/proto declares a field set — a concept, nothing more.
It carries no identity, no plurality, no layout. Interpretation is
delegated to libraries at compile time: std/store interprets a proto as
rows plus minted handles plus a plurality; std/list interprets the same
proto as element structs in a growable array. Same fields, same data —
different ontology.
This is visible in the machine, not just the model. Moving a proto from std/store to std/list changes the memory layout — SoA columns with a
slot/generation/brand table versus packed elements in a contiguous buffer.
That is why “a pointer to an element” is not a missing feature in Koru; it is
a category error. There is no canonical layout for a proto, so there is
nothing stable to point into. A list index is a name, but it is a name with
no validity story — after a removal, xs[3] silently means a different
element. A store handle is a name with a contract: minted once,
generation-checked, traps instead of rebinding. To home that contract a list
would have to grow a slot table and generations — that is, re-derive the
store interpretation. The two are not competitors; they are the same proto
read as entity versus value.
Conventional OOP, then, is a proto that one interpreter welded — layout,
identity, and dispatch all fused at new, before any library could choose
otherwise. The welds are the fixed interpretation.
What was actually bad — the four welds
OOP’s syntax is innocent. The disease is four orthogonal ideas welded into one keyword budget:
- Object = atom of allocation AND atom of code organization. Fields
hidden behind
thisforce array-of-structs layout with per-object headers; the unit you think in becomes the unit the compiler must lay out. That is the reasoning tax — you cannot discuss data shape without discussing identity. - Open-world dispatch keyed on identity.
x.f()means unknown code through a vptr in every header — a layout tax (prefix-compatible layouts forever) plus an unanalyzable call graph. - Inheritance fusing three things — field reuse, substitutability, and
implementation reuse — in a single
extends. - Encapsulation that hides from the compiler. Indirection as the enforcement mechanism, paid in cache misses.
Every “no” Koru already said — no struct, no pointers, no constructors —
had severed one of these welds without killing the idea. And the words
that do exist aren’t grammar either: if, for, cond, and const are [keyword] tors in std/control and std/declarations, auto-imported
through std/index — the language’s own control flow is already a
compile-time library interpretation, which is exactly the proto’s move.
The rejections were the design; so was where the survivors live.
And the industry’s own runtimes concede the frame. Every fast OOP implementation spends its life re-interpreting the welded object back into something denser: V8’s hidden classes are protos minted dynamically at runtime, HotSpot’s class-hierarchy analysis rents the closed-world assumption it can lose at any class-load, monomorphic inline caches pretend a call site is a single-store sweep. Making OOP fast was always “secretly un-weld it.” A language that never welds compiles to what the JITs spend their lives approximating — statically, without deopt.
What survived the encoding
Walked all the way through, the residual OOP surface in Koru is small and it
all landed in std/proto:
Field-set extension. Dog <: Animal + Pet merges the parents’ field
sets before the locals — flatten, dedup by name+concept, refuse on conflict
or cycle. This is the already-ruled direction (“compose the same concepts in
data, never in behavior”) wearing its final name — and it is the whole of
what inheritance was carrying that we wanted: declared shared shape, once.
std/proto(Animal) {
hp: i64
}
std/proto(Dog <: Animal) {
wag: f64
} Dog is { hp: i64, wag: f64 }. No vptr, no layout tax — the fields are
just there, waiting for a consumer to interpret them. Blockless works too: std/proto(Cat <: Animal) is pure inheritance, no {} scaffolding — that
took ?Source, the first optional transform-parameter type, so a missing
block means “fire the handler with null” rather than “silently skip.”
Typed references. owner: ref(Dog) — a field that means “an entity over
there.” It lowers to the i64 row handle (slot + generation + brand), so the
column is the handle and the checker keeps the target name. What it is not
is containment — next: ref(Node) is legal in a recursive proto precisely
because it doesn’t inline.
Under the interpretation frame, ref’s contract states cleanly: a ref(T) is a handle minted by an identity-interpreting plurality of T — any
library that gives T’s rows stable, generation-checked names. Stores are the
only such interpreter that exists today, which is why the contract looks store-shaped; it isn’t. A ref can never home a list element, not because
lists are second-class but because elements have no identity to reference —
and it doesn’t need to: the list carries the handle while the store is the home.
And the part that makes it a type and not a decoration: every write into
a ref(T) column — insert, stored, apply dispatch, bulk append — first
runs a generated guard: -1 is the unset sentinel; anything else must carry
the brand of a home — a plural, handle-minting plurality whose expanded
leaf set subsumes T’s fields. Subsumption, not name-matching, which is what
makes it compositional: a store holding Pup rows is a valid home for ref(Dog) when Pup <: Dog, for free, because the flattened fields are the
contract. A foreign-home handle traps at the write — at the fault, not at a
far-off dereference — and a ref(T) nothing can home refuses at create.
Substitutability needs no declaration. A Dog is an Animal when a view over shared leaf names sees it — structural, checked by the projection. “Is it in the union” is the view’s member list; “has the columns” is the query. The vtable is membership in a plurality.
The consumer contract: honor or refuse
One honest asymmetry the walk surfaced: a consumer that materializes ref(T) fields has exactly two legal postures — enforce the provenance check, or
refuse the field kind. Stores enforce; a list that lowers ref(T) to a
bare i64 and accepts any handle-shaped value is doing neither, and that is
declared debt on the board (ref-provenance-every-consumer, aspirational),
not a spec. The proto prescribes nothing; each library interprets — but an
interpreter that accepts the spelling without the contract is a type lying
about its coverage.
The rejection catalog is the load-bearing half
new does not exist. OOP’s new fuses three acts the substrate
deliberately split — allocate storage, mint identity, run hidden code. In
Koru those are insert, handle-mint, and flow — spelled separately or not at
all. More precisely: allocation is a property of whichever plurality
interprets the proto, never of the element.
Open world refused. Runtime class introduction, monkey patching, shapes
that appear after compile — all three degrade only the dispatch column and
never the data layout, and all three are refused. The refusal is the
feature: it is what lets the sweep be a fused for.
Per-instance shape refused. JS-style property bags need an EAV store; the projection is the type.
Kind mutation refused. An object changing class is take + insert.
Diamond refused, not resolved. Two parents naming the same field with different concepts refuse; same concept dedups. No MRO, no dominance — concepts compose or the declaration refuses.
Subset protos evaluated and shelved. The obvious next rung — a proto
declaring a selection of another’s fields — turned out to be redundant: a
second proto plus projection at the take already composes (verified: store
over Dog, take, push t.hp into a Slim list — compiles, runs), and
the structural home check catches field-type drift at the join, loudly. The
bar for the spelling is a real program that can’t stay honest with restated
fields; none has shown up yet.
What this settles — including a confession
The answer to “can we import OOP” turned out to be “we already did — it’s
the part we kept.” What we added is the honest spelling for the two ideas
Koru was missing: declared shared shape (<:) and declared cross-references
(ref(T), now home-checked). Sixteen pins carry it: 698_001–016 in the
STDLIB cluster, plus the ?Source machinery that makes blockless extension
possible.
One confession worth keeping in the record: ref shipped unruled — the
spelling rode in on vocabulary from an earlier design note, and nothing in
the commit path asked the question the repo’s charter protects (“syntax is
the language author’s call”). It was caught only when he asked what ref was, ratified post-hoc (“wonky in all the right ways”), and the fix that
outlives the miss is a gate row: surface-spellings-are-ruled now judges
every commit — a diff that teaches the toolchain a new user-facing form must
carry a recorded ruling or the gate flags it. The first thing it ever judged
was the commit that added it.
What remains genuinely open is one rung: ref knows a handle’s home at the write but not in the type — X.all isn’t typable yet; the plurality a
proto resolves through is still runtime knowledge. That’s the difference
between a guard that fires and a fact the checker holds, and it’s the next
place this surface can prove it earns its keep — by the first real program
that needs it.