Document structures: a chapter, the architecture, and the differences

docs/16-structures.md is the feature: records, nesting, copy-on-assign, strict
pointers, lists, what is checked and what is not, and how a host shares its own
C structs. Every example in it is executed by docs_examples and byte-compared,
including the refusals -- so a message that changes fails the suite rather than
quietly making the chapter wrong.

The chapter makes one contrast explicitly, because it is the question a reader
will actually have: a misspelled *field* is refused and a misspelled *variable*
still prints zero. The rule underneath is that what the program declared gets
checked and what it did not gets shrugged at -- a variable's name is never
declared, a TYPE's field list is. Structures end up the strictest thing in the
language, not from a higher standard but because they are the only named thing
whose valid spellings are written down.

Chapter 14 gains the layout: an instance is a contiguous run of value slots with
a diagram of where the fields sit, the three-pass prescan and why each pass
exists, why the copy cannot live in akbasic_value_clone(), and why the render
depth bound is four rather than eight. Chapter 3 gains the @ suffix, chapter 13
records that all of this is an addition BASIC 7.0 has nothing like, and the verb
reference gains TYPE, POINT and DIM ... AS.

MAINTENANCE.md gains the two rules that are on a maintainer rather than on a
test: a structure copy must not go through clone, and a field chain gets its own
leaf field. TODO.md section 5 records what was invented and the three limits
that are ours, and section 8 records the two defects the work exposed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-01 12:03:02 -04:00
parent 631c70ce7d
commit 6bac929901
8 changed files with 484 additions and 6 deletions

View File

@@ -421,6 +421,83 @@ normally true, and evaluating `A#` then yields a clone drawn from the per-line v
own evaluation. If you are debugging an assignment that wrote to the wrong place, that
flag is the first thing to check.
## Structures are laid out exactly as arrays are
The whole storage model falls out of one decision: **a `TYPE` is declared**, so an
instance has a known slot count before the program runs.
That means a structure needs no pool of its own. `DIM R@ AS RECT` calls the same
`akbasic_valuepool_take()` that `DIM A#(10)` calls, and the variable's `values` run *is*
the instance. A field access is offset arithmetic against an offset the type descriptor
already knows. A nested value field **flattens into its container's run** — a `SEGMENT`
holding two `COORD`s and a string is five slots, not three — which is why the nesting is
free rather than a second indirection.
```text
DIM S@ AS SHAPE SHAPE: NAME$, ORIGIN@ AS COORD, AREA%
COORD: X#, Y#
variable S@
structtype ──► type table entry SHAPE (slotcount 4)
values ──────► ┌────────┬────────┬────────┬────────┐
│ NAME$ │ X# │ Y# │ AREA% │
└────────┴────────┴────────┴────────┘
off 0 off 1 off 2 off 3
└── ORIGIN@ is offset 1, two slots ──┘
```
`src/structtype.c` fills the table, and it does so **in three passes**, each for a case
the pass before cannot handle. Names first, so a field can refer to a type declared
further down. Then field lists, now able to resolve every reference. Then sizes, by
repeated resolution — a type whose fields are all sized can be sized, and repeating that
settles any legal ordering. **Whatever never resolves is a cycle of by-value containment**,
which is how "a `TYPE` cannot contain itself by value" is a diagnosis rather than an
assumption.
It is a *prescan*, run from `akbasic_runtime_set_mode()` beside the label and `DATA`
scans, for the reason all three are: a declaration has to be in effect wherever control
goes, including when a branch skips the lines that made it. The `TYPE` verb's whole job at
run time is to **jump past its own `END TYPE`**, because the field lines are declarations
and executing `W#` would evaluate a bare identifier and quietly create a global.
### Copy, and where it has to happen
A `STRUCT` value and a `POINTER` value carry the same thing: a type index and a base. What
differs is what *assignment* does with it.
**The copy cannot live in `akbasic_value_clone()`**, and this is the trap to know about.
Clone copies one slot, and one slot holds a *reference* to an instance rather than the
instance — so a structure going through clone would alias, which is precisely the
semantics the language does not have. `akbasic_environment_assign()` intercepts a
structure before that path and calls `akbasic_struct_copy()`, which walks the descriptor
and copies slot by slot. Deliberately not one `memcpy` of the run: a pointer field must
copy its reference where a value field must copy its slots, and only the descriptor knows
which is which.
Copy is therefore **deep through values and stops at pointers**, as it is for a C struct
holding a pointer. Since a `TYPE` cannot contain itself by value, copy depth is fixed by
the type graph before the program starts and no copy can recurse away. Only *rendering*
needs a runtime bound, because a pointer can make the graph cyclic — that is
`AKBASIC_MAX_STRUCT_DEPTH`, four, chosen so the bound bites before the 256-byte render
buffer does.
### A host structure is the same thing with its bytes somewhere else
`akbasic_host_register_type()` puts a host's C struct in the *same* table, so `PTR TO`,
copy-on-assign, `.` and `->` all work across the boundary with no second set of rules. The
only difference is where a field's bytes live, and that is carried by three members on the
field descriptor — `hostkind`, `hostoffset`, `hostwidth`.
A binding takes a **shadow run** of slots from the ordinary pool. A field read refreshes
its slot from host memory first; a field write converts back and stores. So the script
always sees current values and its writes always land, while everything else in the
interpreter goes on seeing one storage model. If you are debugging a host binding that
reads stale data, `akbasic_host_refresh()` is where to look.
**This is the one pointer the interpreter holds that it did not allocate.** Everything
else is pool-bounded; a binding whose instance has been freed is the only way to get a
wild pointer in here, which is what `akbasic_host_unbind()` is for.
## Errors come in two kinds, and the distinction is the point
```text