Files
akbasic/docs/16-structures.md
Andrew Kesterson 00daa17a47 Give each DEF call its own environment, so recursion returns
The function's environment was owned by the funcdef and re-initialised on every
call, which made a function not re-entrant and cost two silent defects:

    DEF DBL(N#) = N# * 2
    PRINT DBL(10) + DBL(1)      was 4, should be 22

The result was a pointer into the funcdef's own environment, so the second call
overwrote the first before the operator saw it -- both operands became the last
call's answer. Two *different* functions in one expression were fine, which is
most of why it was invisible.

    DEF FACT(N#)
    IF N# <= 1 THEN RETURN 1
    RETURN N# * FACT(N# - 1)
    PRINT FACT(5)               never returned

The recursive call re-initialised the environment the outer call was still
using, so the loop waiting for control to come back could not see it. No error,
no bound, no diagnostic -- the one place in this interpreter that looped forever
rather than raising.

A call takes an environment from the pool now, exactly as GOSUB does. The result
is copied into a caller-scope scratch before that environment goes back, because
handing back a pointer into the callee is what made two calls collide and would
now be a pointer into a released slot as well. RETURN parks its result on the
*parent* rather than on the environment it is about to release, so nothing reads
a freed slot to find it.

Recursion depth answers to AKBASIC_MAX_ENVIRONMENTS like every other nesting, so
too deep is "Environment pool exhausted" -- a diagnosis where there was none.

akbasic_FunctionDef.environment goes with it, as dead state.

One thing this exposed but did not cause, measured against a stashed build and
recorded rather than fixed: a statement containing a failed multi-line DEF call
still completes and prints a junk value. It is visible more often now only
because runaway recursion reaches it where it used to hang.
tests/language/functions/recursion.bas deliberately does not pin that answer.

Chapter 16 loses its "walk a list with a loop, not a recursive DEF" caveat and
gains the one that is still true: a function cannot take a structure parameter
yet, so it reaches a record by name.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-01 12:29:35 -04:00

8.0 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 recursive DEF works too, and is bounded by the scope pool at 32 deep — the same bound GOSUB has. What a function cannot yet do is take a structure parameter: DEF F(B@ AS NODE) is not implemented, and a bare DEF F(B@) is refused because @ alone does not say which type. Until it is, a function reaches a record by name, which the scoping already allows — a call can see the caller's variables.

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

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.