Files
akbasic/docs/16-structures.md
Andrew Kesterson ad76889292 State where a scalar lives in the three chapters that describe the pool
Chapter 13's limits table said 4096 array elements "in total", Chapter 16 said
an instance's fields come out of the same pool and nothing is reclaimed, and
Chapter 14's pool table listed `AKBASIC_MAX_ARRAY_VALUES` with no note about
what does and does not draw from it. All three were written when a scalar drew
from that pool, and all three now understate what a program may do.

Each gains the same two facts in the register it is written in. Chapter 13: a
scalar does not come out of the 4096, so creating one inside a `GOSUB` or a
`FOR` -- the loop counter included -- costs nothing, while a `DIM` inside a
scope does and is not given back. Chapter 16: scalars are the exception, and
"nothing is reclaimed" is what lets a pointer into a record stay sound after its
scope has gone -- which is the reason arrays and structures still spend.
Chapter 14: a row for the variable's own storage, and a paragraph on why the
value pool is the one budget that needs a second sentence.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-02 00:32:20 -04:00

9.7 KiB

16. Structures

A structure groups values that belong together. Commodore BASIC 7.0 has nothing like it — this is entirely an addition, and Chapter 13 lists it with the other differences.

A structure variable's name ends in @, the fourth type suffix:

Suffix Type
# integer
% floating point
$ string
@ structure

Declaring a type

TYPEEND TYPE names a record and lists its fields, one per line. Each field takes its type from its own suffix, the same rule every other name in this language follows, so a field list needs no type column:

10 TYPE RECT
20   W#
30   H#
40 END TYPE
50 DIM R@ AS RECT
60 R@.W# = 3
70 R@.H# = 4
80 PRINT R@.W# * R@.H#
90 PRINT R@
12
RECT(W#=3, H#=4)

DIM name@ AS TYPE is how a variable gets storage, and it is required — unlike an ordinary variable, a structure cannot spring into existence on first use, because nothing would say which type it is.

A type name is a bare word, and so is every verb, so the two share a namespace. TYPE POINT is refused, because POINT is a verb:

10 TYPE POINT
20   X#
30 END TYPE
40 PRINT 1
? 40 : PARSE ERROR TYPE POINT: POINT is a reserved word and cannot name a type

The same applies to field names, for the same reason a variable cannot be called TO#.

Nesting

A field may be another structure, named with AS:

10 TYPE COORD
20   X#
30   Y#
40 END TYPE
50 TYPE SHAPE
60   NAME$
70   ORIGIN@ AS COORD
80 END TYPE
90 DIM S@ AS SHAPE
100 S@.NAME$ = "BOX"
110 S@.ORIGIN@.X# = 10
120 PRINT S@
SHAPE(NAME$=BOX, ORIGIN@=COORD(X#=10, Y#=0))

@ on its own says "a structure" but not which, which is why a structure field has to name its type where W# does not. Three primitive types fit in three suffix characters; ten declared types do not fit in one.

Assignment copies

This is the rule to remember. A structure behaves like every other value here:

10 TYPE RECT
20   W#
30 END TYPE
40 DIM A@ AS RECT
50 DIM B@ AS RECT
60 A@.W# = 1
70 B@ = A@
80 A@.W# = 99
90 PRINT B@.W#
1

B@ is its own record from line 70 onward. Nesting copies too — a whole record, however deep, moves as a unit.

Pointers

When you want two names for one record, say so. A pointer is a distinct declared kind:

10 TYPE RECT
20   W#
30 END TYPE
40 DIM A@ AS RECT
50 DIM P@ AS PTR TO RECT
60 A@.W# = 1
70 POINT P@ AT A@
80 P@->W# = 99
90 PRINT A@.W#
99

Three things are deliberate:

  • POINT is the only way to share. A program that never writes it can never be surprised by aliasing.
  • . reaches a field of a structure and -> reaches one through a pointer. They do not stand in for one another, and using the wrong one is an error that names the other. So a reader always knows from the spelling whether the thing on the left is their own copy or somebody else's data.
  • A pointer with nothing behind it is NOTHING, and dereferencing it is refused rather than being a crash.
10 TYPE RECT
20   W#
30 END TYPE
40 DIM P@ AS PTR TO RECT
50 PRINT P@
60 PRINT P@->W#
NOTHING
? 60 : RUNTIME ERROR This pointer is not pointing at anything yet; POINT it AT a structure first

Lists and trees

A TYPE may refer to itself only through a pointer — by value it would have no finite size, and that is refused at declaration. Which is exactly what makes a list possible:

10 TYPE NODE
20   COUNT#
30   TAIL@ AS PTR TO NODE
40 END TYPE
50 DIM N1@ AS NODE
60 DIM N2@ AS NODE
70 N1@.COUNT# = 10
80 N2@.COUNT# = 20
90 POINT N1@.TAIL@ AT N2@
100 DIM WALK@ AS PTR TO NODE
110 POINT WALK@ AT N1@
120 PRINT WALK@->COUNT#
130 WALK@ = WALK@->TAIL@
140 PRINT WALK@->COUNT#
150 PRINT N1@
10
20
NODE(COUNT#=10, TAIL@=NODE(COUNT#=20, TAIL@=NOTHING))

Note line 130: assigning one pointer to another copies the reference, not the record. That is the one place assignment does not deep-copy, and it is why pointers are declared separately rather than being a mode a structure can be in.

A pointer is true when it points at something, which is how a walk knows where the list ends — and a recursive DEF can do the walking:

10 TYPE NODE
20   COUNT#
30   TAIL@ AS PTR TO NODE
40 END TYPE
50 DIM N1@ AS NODE
60 DIM N2@ AS NODE
70 N1@.COUNT# = 10
80 N2@.COUNT# = 20
90 POINT N1@.TAIL@ AT N2@
100 DEF TOTAL(P@ AS PTR TO NODE)
110 IF P@->TAIL@ THEN RETURN P@->COUNT# + TOTAL(P@->TAIL@)
120 RETURN P@->COUNT#
130 DIM W@ AS PTR TO NODE
140 POINT W@ AT N1@
150 PRINT TOTAL(W@)
30

NOT is the bitwise operator here, so write the test the positive way round as line 110 does. Recursion is bounded by the scope pool at 32 deep, the same bound GOSUB has.

PRINT follows pointers, so a cycle would not come back — it stops after four levels and prints (...).

Passing a structure to a function

A parameter names its type, exactly as DIM does:

10 TYPE CRATE
20   W#
30   H#
40 END TYPE
50 DIM A@ AS CRATE
60 A@.W# = 5
70 A@.H# = 3
80 DEF AREA(B@ AS CRATE) = B@.W# * B@.H#
90 PRINT AREA(A@)
15

A bare B@ is refused. @ says "a structure" without saying which, so it does not state a contract the way B$ does — and accepting it would mean checking fields at the call instead of at the declaration, which is the hole naming the type closes. The cost is that there are no generic functions: one AREA cannot serve CRATE and BOXY.

Passing is by value, because a parameter is bound by assignment and assignment copies:

10 TYPE CRATE
20   W#
30 END TYPE
40 DIM A@ AS CRATE
50 A@.W# = 5
60 DEF WIDEN(B@ AS CRATE)
70 B@.W# = 99
80 RETURN B@.W#
90 PRINT WIDEN(A@)
100 PRINT A@.W#
99
5

The function saw 99; the caller still has 5. To change a caller's record on purpose, pass a pointer — DEF POKEIT(P@ AS PTR TO CRATE) — and reach through it with ->. Neither is a special rule: both fall out of the parameter being assigned like any other variable.

What is checked, and what is not

A field name is checked against the set the type declared, and the refusal lists the fields that do exist:

10 TYPE RECT
20   W#
30   H#
40 END TYPE
50 DIM R@ AS RECT
60 PRINT TOTLA#
70 PRINT R@.NOPE#
0
? 70 : RUNTIME ERROR RECT has no field NOPE# (W#, H#)

Line 60 is the contrast worth understanding. A misspelled variable is still silentTOTLA# prints zero, as it does in every BASIC ever written. A misspelled field is not, because the set of fields is closed and the program wrote it down.

That is the rule underneath both: what the program declared gets checked, and what it did not gets shrugged at. A variable's name is never declared, so it cannot be checked. A TYPE's field list is, so it can be. Structures end up the strictest thing in the language, not because they are held to a higher standard but because they are the only named thing whose valid spellings are written down.

Sharing a structure with a host

If you are embedding the interpreter in a game, a script can read and write the game's own C structs — not a copy of them. The host describes its struct once:

typedef struct
{
    char     name[32];
    int32_t  hp;
    float    x;
    bool     hostile;
} game_Enemy;

static const akbasic_HostField ENEMY_FIELDS[] = {
    /*                  struct       member    BASIC name   C representation           */
    AKBASIC_HOST_FIELD( game_Enemy,  name,     "NAME$",     AKBASIC_HOSTFIELD_CSTRING ),
    AKBASIC_HOST_FIELD( game_Enemy,  hp,       "HP#",       AKBASIC_HOSTFIELD_INT32   ),
    AKBASIC_HOST_FIELD( game_Enemy,  x,        "X%",        AKBASIC_HOSTFIELD_FLOAT   ),
    AKBASIC_HOST_FIELD( game_Enemy,  hostile,  "HOSTILE#",  AKBASIC_HOSTFIELD_BOOL    )
};

static const akbasic_HostType ENEMY_TYPE = {
    "ENEMY", sizeof(game_Enemy), ENEMY_FIELDS, 4
};

akbasic_host_register_type(&SCRIPT, &ENEMY_TYPE);
akbasic_host_bind(&SCRIPT, "FOE@", "ENEMY", &GOBLIN);

and the script then works on FOE@ like any other structure:

10 PRINT FOE@.NAME$ + " HAS " + FOE@.HP#
20 FOE@.HP# = FOE@.HP# - 10

Line 20 decrements GOBLIN.hp in place. There is no marshalling step.

examples/hoststruct.c is a complete working host, built and run by every build. Chapter 10 covers the rest of the embedding API; the parts specific to structures:

Call Does
akbasic_host_register_type Makes a C struct available as a BASIC type
akbasic_host_bind Binds one instance to a script variable
akbasic_host_rebind Points that name at a different instance — the per-frame call
akbasic_host_unbind Breaks the binding before the storage goes away

Three things a host should know:

  • Conversion refuses rather than truncates. Assigning 70000 to an int16_t field, or forty characters to a char[32], is an error naming the field.
  • A host float round-trips lossily, because BASIC floats are doubles. A char[] has a width that BASIC strings do not.
  • A bound instance must outlive the binding. It is the only pointer this interpreter holds that it did not allocate; akbasic_host_unbind() exists for exactly that.

Limits

Types 16
Fields per type 16
Nesting shown by PRINT 4 levels

An instance's fields come out of the same value pool arrays use, so the 4096-element budget in Chapter 13 covers both. Nothing is reclaimed — a structure lasts until CLR or NEW, exactly as an array does, and that is what lets a pointer into a record stay sound after the scope that declared it has gone. Scalars are the exception and are not in the pool at all; they live in the variable, so creating one inside a scope costs nothing.