Doll 1 — PolyNode + MayItem — Deep Dive
See Quick Reference for API signatures and contracts.
Intrusive vs non-intrusive
A non-intrusive queue allocates a wrapper node around your data:
[ queue node ] → [ your struct ] ← two allocations, two indirections
.next
.data ──────────────────────►
An intrusive queue puts the link inside your struct:
[ your struct ] ← one allocation
PolyNode.next ──────────────► next item in queue
PolyNode.id
your fields...
With using poly: PolyNode at offset 0, your struct is the node:
- No wrapper.
- No extra allocation.
- No extra indirection compared to non-intrusive.
Services don't know your types
Matryoshka services
- receive
^PolyNode - store
^PolyNode - return
^PolyNode.
They don't know what is inside.
All concrete type knowledge lives in user code.
PolyNode.id tells you the type. It makes the cast safe:
- Zero is always invalid.
- Unknown id is a programming error.
- Known id → you can cast. Correctness is on you.
One place at a time
list.Node has exactly one prev and one next.
Linking an item into two lists at the same time corrupts both.
An item lives in exactly one place at a time.
The link structure makes correct use natural — one prev, one next, one place.
But nothing stops you from inserting the same node twice.
That would corrupt both lists.
This is discipline, not enforcement.
Maybe — transfer and receive
Transfer
Before transfer:
After transfer:
Receive
Before receive:
After receive:
Two levels
list.Node— the link. Oneprev, onenext. If you put a node in two lists, both break.Maybe— the ownership flag.nil= not yours. Non-nil = yours.
Why ownership matters
- Services receive
^PolyNodeand store it — they don't know the concrete type. - Only the code that created the item can safely cast
^PolyNodeback. - One item, one holder at any moment.
^MayItemmakes this visible at every call site.
Builder example: Event + Sensor
Builder :: struct {
alloc: mem.Allocator,
}
make_builder :: proc(alloc: mem.Allocator) -> Builder {
return Builder{alloc = alloc}
}
ctor :: proc(b: ^Builder, id: int) -> MayItem {
switch ItemId(id) {
case .Event:
ev := new(Event, b.alloc)
if ev == nil {
return nil
}
ev^.id = id
return MayItem(&ev.poly)
case .Sensor:
s := new(Sensor, b.alloc)
if s == nil {
return nil
}
s^.id = id
return MayItem(&s.poly)
case:
return nil
}
}
dtor :: proc(b: ^Builder, m: ^MayItem) {
if m == nil {
return
}
ptr, ok := m^.?
if !ok {
return
}
switch ItemId(ptr.id) {
case .Event:
free((^Event)(ptr), b.alloc)
case .Sensor:
free((^Sensor)(ptr), b.alloc)
case:
if ptr.id == 999 { // EXIT_ID
free(ptr, b.alloc)
} else {
panic("unknown id")
}
}
m^ = nil
}
What ctor does inside (so you don't have to)
This is the manual way — without Builder:
ev := new(Event, alloc)
// ...
ev^.id = int(ItemId.Event)
ev.code = 99
ev.message = "owned"
// ...
m: MayItem = &ev.poly
With Builder:
Builder prevents the mistakes:
- You don't think about wrapping.
- You don't forget to set id.
- You don't accidentally
defer freethe original pointer.
Standalone use
- Builder does not need a pool.
- Builder does not need a mailbox.
- Any code that creates and destroys polymorphic items can use Builder directly.
Matryoshka does not need Builder either. Builder — everything described from here on — is your code.
Not forced. Not required.
Use it, change it, or write your own.
Working with lists — produce and consume
Produce
Allocate items. Push to intrusive list:
// Drain on any exit path — no-op if list is already empty.
defer drain_list(&l, alloc)
// --- Produce: N pairs of (Event, Sensor) ---
N :: 3
for i in 0 ..< N {
ev := new(Event, alloc)
if ev == nil {
return false
}
ev^.id = int(ItemId.Event)
ev.code = i
ev.message = "event"
list.push_back(&l, &ev.poly.node)
s := new(Sensor, alloc)
if s == nil {
return false
}
s^.id = int(ItemId.Sensor)
s.name = "sensor"
s.value = f64(i) * 1.5
list.push_back(&l, &s.poly.node)
}
Consume
- Pop from list.
- Dispatch on id.
- Process.
- Free:
for {
raw := list.pop_front(&l)
if raw == nil {
break
}
poly := (^PolyNode)(raw)
switch ItemId(poly.id) {
case .Event:
ev := (^Event)(poly)
fmt.printfln("Event: code=%d message=%s", ev.code, ev.message)
free(ev, alloc)
case .Sensor:
s := (^Sensor)(poly)
fmt.printfln("Sensor: name=%s value=%f", s.name, s.value)
free(s, alloc)
case:
fmt.printfln("unknown id: %d", poly.id)
panic("unknown id")
}
processed += 1
}
What you can build with Doll 1
- Intrusive lists in one thread — no extra allocations.
- Simple game entity systems — entities live in one list at a time.
- Single-threaded pipelines — read → process → write.
- Any system where ownership changes hands instead of data being shared.
No locks. No threads yet. Just clean ownership.