Skip to content

objects

Gordon Heydon edited this page Sep 9, 2026 · 3 revisions

Classes and objects — naming the thing MV already builds by hand (theory, #195)

A design note on what classes would mean in Pick BASIC. A catalogued subroutine, a named COMMON block and a dynamic array are already an object: behaviour, state, and a serialised form. What they are not is instances, and nothing enforces the boundary. This asks what it would cost to say so in the grammar.

Status: theory. Nothing here is built and nothing is scheduled; #195 tracks the question, not any work. There are no measurements — the substitute credential is that every claim cites a source and each was checked before it was written. MVX claims cite a file in this tree; OpenQM claims cite its GPL source (ScarletDME, GPL-2.0, the maintained fork of the Ladybridge release), because its published documentation was unreachable and second-hand descriptions of it turned out to be wrong in ways that mattered.


1. The problem — MV has had objects since 1973, and exactly one of each

Take a program that maintains an order. The behaviour is a catalogued subroutine. The state is a named COMMON block, which the runtime keeps on the context and threads through every CALL (runtime/src/mvx_ctx.c — chunked and address-stable, precisely so later programs can extend a block without moving what earlier ones bound). The serialised form is a dynamic array. That is an object. The cmd framework in mv_cmd is one, and so is every session-scoped thing anyone has written in this language.

Three things are missing, in the order they hurt:

  1. One instance per block name. COMMON /ORDER/ is one order, for the life of the process. Two open orders means parallel arrays and a subscript threaded through every call. Recursion means giving up.

  2. No boundary. A COMMON block is global by name. Any program that spells the name correctly owns your state, and nothing says which programs were meant to.

  3. No dispatch. CALL ORDER.TOTAL names one target. A variant means writing the switch by hand, in every caller.

Note what is not on that list. Inheritance is not missing; MV's reuse mechanism is the catalog, and it works. A design that starts from what MV lacks relative to Java will produce a bad Pick dialect. This one starts from what MV programmers already assemble by hand.


2. Prior art — two answers, and one of them is silence

The question is not "should MV have classes" but "how do you get encapsulated state plus behaviour in MV". There are two answers in the field.

2.1 UniVerse and UniData: convention, not language

Verified against the reference manuals: UniVerse BASIC 11.2 contains zero occurrences of CLASS — "object" appears only as "object code" — and the UniData BASIC reference contains zero. Classic Pick and D3 have nothing either.

This is a position, not an omission, and it has real merits:

  • Nothing new to learn, and nothing new to support. The catalog, CATALOG, the debugger and the search order all keep working.
  • Portable to every MV platform, D3 included.
  • The object is a dynamic array — so it is already serialisable, writeable to a file, printable, and diffable. An object system that invents a new kind of heap value loses all of that on the first day.

And real costs: the three failures in §1, plus attribute positions become magic numbers that nothing checks.

2.2 OpenQM: the class as a module

OpenQM is the one MV platform that took the other path, and it went further than is generally reported. Read out of its GPL source — the compiler GPL.BP/BCOMP and the tokeniser gplsrc/op_str3.c — the model is:

CLASS name [MAX.ARGS n] [INHERITS class1, class2, ...]
  • A CLASS is a compilation unit, a peer of PROGRAM, SUBROUTINE and FUNCTION, and must appear before any executable statement.
  • Members are declared PUBLIC (FUNCTION, SUB or SUBROUTINE) or PRIVATE.
  • GET and SET give properties, and are the same mechanism: the compiler treats GET as a public function and SET as a public subroutine. So ORD->TOTAL can be a call rather than a slot, and the pair reads and writes it.
  • CREATE.OBJECT is the constructor, and must be a PUBLIC SUBROUTINE.
  • ME is the self-reference, compiled to its own opcode.
  • Instantiation is the function OBJECT(name, args...).
  • Member access is -> — tokenised as TKN_OBJREF from - followed by >.
  • INHERITS takes a list, so inheritance is multiple. It is implemented by giving each inherited class a private variable of its own name inside the instance — delegation, not a vtable.

Its merits are real and worth taking seriously:

  • It reuses machinery MV already has. The class is a program, so cataloguing, source control and the debugger keep working unchanged.
  • PUBLIC is the boundary COMMON never had.
  • Instantiation is a function, so making an object is an expression and needs no new statement. That is an elegant choice and worth stealing outright.
  • GET/SET is the answer to attribute numbers leaking — the second failure in §1. A caller says ORD->TOTAL and never learns which attribute that is, or whether it is stored at all.

Its costs:

  • A program-instance object is heavy next to a dynamic array.
  • It does not serialise. You cannot WRITE one, which in MV is close to disqualifying.
  • It is a second kind of value in a language whose uniformity — everything is a string — is most of its character.
  • It exists only on OpenQM, so using it is a one-way portability decision. For most shops that is the deciding constraint, not a footnote.

2.3 What MVX would take from each

Take OpenQM's class-is-a-module, instantiated by a function, with public and private members, because it costs almost no new machinery. Take UniVerse's the value is a dynamic array, because that is what makes an object something you can write to a file and read back tomorrow.

Neither platform offers both. That combination is the whole proposal.

2.4 The same order, three ways

(a) What you write today. Real, and compiles now.

      SUBROUTINE ORDER.ADD(PROD, QTY, PRICE)
      COMMON /ORDER/ LINES, TOTAL
      LINES<-1> = PROD : @VM : QTY : @VM : PRICE
      TOTAL = TOTAL + QTY * PRICE
      RETURN

One order per process, and any program may write LINES.

(b) OpenQM. Shapes taken from its GPL source; see §2.2.

      CLASS ORDER
      LINES = ''
      PUBLIC SUBROUTINE CREATE.OBJECT
         LINES = ''
      END
      PUBLIC SUBROUTINE ADD(PROD, QTY, PRICE)
         LINES<-1> = PROD : @VM : QTY : @VM : PRICE
      END
      GET TOTAL
         ...
      END

used as ORD = OBJECT('ORDER') then ORD->ADD(...) and ORD->TOTAL.

Instances, a boundary, and properties that hide their own storage. But the object is a running program, so it cannot be written to a file.

(c) What this note proposes. Detailed in §3.

      CLASS ORDER
         PRIVATE LINES
         PUBLIC TOTAL
         PUBLIC SUBROUTINE ADD(PROD, QTY, PRICE)
            LINES<-1> = PROD : @VM : QTY : @VM : PRICE
            TOTAL = TOTAL + QTY * PRICE
         END SUBROUTINE
      END CLASS

Same source shape as (b), but the instance is a dynamic array — so WRITE ORD ON F, ID works, and so does reading it back.


3. The syntax, and the bar it has to clear

DECISIONS.md admits a syntax extension only where classic Pick has no equivalent, and the C-comment precedent sets three tests: the tokens must be an impossible sequence in valid classic code, the portability cost must be stated, and the guards must be named.

3.1 The dot is already taken

The obvious spelling — ORD.TOTAL — is not available. MVX's lexer accepts . inside an identifier (compiler/src/lexer.cpp, the identifier scan admits ., _, @ and $), and ORDER.TOTAL is an entirely idiomatic MV variable name. Dot-notation is therefore ambiguous with existing legal code and fails the first test outright.

This eliminates the syntax every reader arrives expecting, and it is better to say so before proposing anything else.

3.2 -> is an impossible sequence, for the same reason /* was

- and > lex as separate tokens. After -, the classic grammar requires an operand, and > cannot begin one. So -> cannot occur in valid classic Pick — which is exactly the argument DECISIONS.md already accepted for /* */ and //:

both are impossible token sequences in valid classic code (after / the grammar requires an operand), so there is no ambiguity

-> was arrived at here by elimination, from MVX's own lexer, before OpenQM's source could be read. OpenQM tokenises the same two characters into TKN_OBJREF (gplsrc/op_str3.c). Two dialects reaching the same operator independently is weak evidence for much, but it is at least not a borrowed convention: the constraint that produced it — after - the classic grammar requires an operand — is the same in both languages.

The three tests, discharged:

  • Unambiguous — shown above.
  • Cost stated — one-way portability. MVX source using classes will not compile on UniVerse, UniData, D3 or jBASE. Legacy source never contains ->, so imports are unaffected. This is the identical trade the project already accepted for C-style comments.
  • GuardPORT-SOURCE remains the escape hatch, as it is for comments.

3.3 The sketch

      CLASS ORDER
         PRIVATE LINES
         PUBLIC  TOTAL

         PUBLIC SUBROUTINE ADD(PROD, QTY, PRICE)
            LINES<-1> = PROD : @VM : QTY : @VM : PRICE
            TOTAL = TOTAL + QTY * PRICE
         END SUBROUTINE

         PUBLIC FUNCTION COUNT()
            RETURN(DCOUNT(LINES, @AM))
         END FUNCTION
      END CLASS

and at the call site:

      ORD = NEW("ORDER")
      CALL ORD->ADD("P100", 4, 24900)
      CALL ORD->ADD("P107", 10, 1250)
      PRINT "lines: " : ORD->COUNT()
      PRINT "total: " : OCONV(ORD->TOTAL, "MD2$")
      WRITE ORD ON ORDERS, "1001"

NEW is a function, following OpenQM's good idea — instantiation is an expression, and no new statement is needed. That last line is the point of the whole design: the object is a value, so it goes to disk like any other.

One open choice, and OpenQM has the better answer. PUBLIC TOTAL above is a slot: the caller reads a stored value. OpenQM would make it a GET routine, so ORD->TOTAL is a call and the class decides whether the number is stored, derived, or read from somewhere else entirely. That directly answers the second failure in §1 — the caller never learns an attribute number, because there may not be one.

The cost is that every property access becomes a dispatch, which on this design means a mvx_call_var through the catalog (§4.2) rather than a subscript into a dynamic array. Whether that is worth it is not settled here. It is the first thing to decide if this note ever becomes a proposal.


4. The runtime shape

None of this needs a new loader, a new value layout, or a change to the frozen ABI. Showing that is why this section exists.

4.1 An object is a dynamic array whose first attribute names its class

No new tag, and no new kind of value. An ORDER instance is an ordinary MV_STR whose attribute 1 is ORDER and whose remaining attributes are the members in declared order.

Everything follows from that:

  • mv_copy and mv_clear need no change at all. Both guard on tag == MV_STR and nothing else (runtime/src/mv_value.c), so an object is already refcounted, already released, already correct.
  • The object serialises, prints, compares and version-controls for free.
  • The existing dynamic-array index makes member access an offset lookup rather than a scan.

The alternative — a new MV_OBJ tag holding a pointer, after the MV_FILE pattern — is considered and rejected in §6. The short version is that it buys identity and costs everything in the list above.

4.2 Dispatch already exists

mvx_call_var (runtime/src/mvx_call.c) resolves a subroutine whose name is held in a variable, at runtime, through the full ladder: symbols already in the process, then every library in the account's LIB/, then each linked package's LIB/, then the system account.

A method call is that, with the name computed from the object's class and the member. ORD->ADD reads ORDER out of attribute 1 and dispatches ORDER$ADD.

The consequences are worth stating plainly, because they answer the objection in §5.1 before it is raised:

  • Methods are ordinary compiled BASIC living in LIB/.
  • CATALOG and BUILD-PKG publish them unchanged.
  • The C surface grows by roughly one dispatch function — not an object system.

4.3 The ABI is not touched

The subroutine ABI is permanent: void mvx_sub_<NAME>(mvx_ctx *, int32_t argc, mv_value **argv) (compiler/src/codegen.cpp). Non-negotiable 3 forbids changing it — not adding beside it.

Methods take a distinct symbol family, mvx_mth_<CLASS>$<METHOD>. They must be distinct rather than reusing mvx_sub_, and the reason is concrete: a method ADD on class ORDER, and a plain catalogued subroutine named ORDER.ADD, are both entirely idiomatic MV. In a flat namespace mangled verbatim and resolved by dlsym, they would collide — silently, and across libraries.

The receiver rides argv. That argv[0] can be special is established convention, not invention: a FUNCTION already reserves it for the result. A method-function needs two reserved slots, receiver and result, which is worth saying out loud rather than leaving to be discovered.

4.4 No manifest needed

Two dispatch paths already exist, chosen at compile time from a name set read off disk — EXPORTS for package extension functions (compiler/src/main.cpp).

Classes need no equivalent. Because -> is unambiguous, the compiler recognises a method call from the token sequence alone, and resolution stays entirely at runtime. That is a concrete advantage of -> over the dot beyond mere legality.

4.5 The numeric lattice

Non-negotiable 4 says design for type specialisation even before implementing it. The relevant detail is that varKind returns NK::Int for a name it has not seen (compiler/src/codegen.cpp), so an object variable is not safely non-numeric by omission — it must be entered into the non-numeric set the way arrays and @-variables already are.

The reassurance: that set is absorbing, so adding members can only remove specialisation, never produce a wrong one elsewhere. No sieve variable is ever a method receiver, so the numeric fast path is untouched.


5. Hard parts

None of these is a reason not to do it. All are reasons to be honest about it.

5.1 Non-negotiable 7 says verbs are BASIC, not C. An object system written mostly in C enlarges exactly the frozen surface that rule exists to bound. This is the objection that matters most, and §4.2 is the answer: with dispatch riding mvx_call_var and methods compiled as ordinary BASIC into LIB/, the C side is a dispatch function and a name-mangling rule. If a design ever needs a class graph visible to the linker, it has failed this test and should stop.

5.2 Value semantics, not reference semantics. Because an object is a string, B = ORD copies rather than aliases. There is no object identity and no shared mutable state.

This will surprise anyone arriving from Java, and it is the design's largest single concession. Two things make it defensible: MV programmers already expect value semantics for everything, and mutation through methods still works, because a bare variable passed to CALL is passed by reference — the callee receives the caller's own slot, so self mutation propagates.

What is genuinely lost is two variables referring to one object. If that turns out to be needed, §6's rejected option is where to look, and it is a different design rather than an adjustment to this one.

5.3 PRIVATE is a compile-time fiction. The state is a dynamic array; a determined caller can read attribute 3. This is enforcement by the compiler, not by the runtime — which sits awkwardly beside non-negotiable 8, "security checks live in the runtime, not the shell". The defence is that PRIVATE is an engineering boundary, not a security one, and the note should not pretend otherwise.

5.4 DWARF. Non-negotiable 2 requires debug info from the start. Methods need line mapping like any other compiled unit, and the member names need to be visible to a debugger, or the feature ships blind.


6. Alternatives considered

  • Dot-notation member access. Rejected: . is already an identifier character, so ORD.TOTAL is ambiguous with a legal variable name and fails the extension bar (§3.1).

  • A new MV_OBJ tag holding a pointer, after the MV_FILE pattern. Rejected, and this is the closest call in the note. It would buy reference semantics and object identity. It costs: the object no longer serialises, prints, or diffs; mv_copy/mv_clear gain a per-tag branch in the two hottest functions in the runtime, which today special-case MV_STR and copy everything else bitwise; and it puts a second lifetime discipline under the same 32-byte struct as MV_FILE's aliasing one. That last is the durable cost, and it is worse than the branch. Note also how MV_FILE handles their own lifetime: they are context-owned and released together at mvx_ctx_destroy, never when a variable holding one is cleared or overwritten. That is right for the handful of files a program opens, and wrong for objects made in a loop over ten million records.

  • Refcounting a pointer-backed object with a destructor hook. Rejected as above, with one thing recorded in its favour: it is the only option that could release a record lock when the last reference dies. If that becomes the requirement, this design is the wrong one. Its precondition is that generated code never bitwise-copies an mv_value and always goes through mv_copy — the same discipline DESIGN-DYNAMIC-ARRAYS.md enforces for byte access with a grep in the suite, and it would need the same enforcement here.

  • A per-class vtable of function pointers in a class descriptor. Rejected: mvx_call_var already provides late binding through the catalog, and a vtable needs a class graph visible to the linker — the first step toward precisely the C-side object system §5.1 exists to prevent.

  • Reusing mvx_sub_ mangling for methods. Rejected: the ORDER.ADD collision in §4.3.

  • The extension-function signature for methodsvoid fn(mvx_ctx *, mv_value *ret, int32_t argc, mv_value **argv), with the result as a separate parameter. This is the cleaner signature and it already exists in the tree. Rejected because extension functions are C and methods must be BASIC. Recorded so it is clear it was passed over rather than overlooked.

  • DEFFUN ... CALLING as a declaration hook. Rejected: the CALLING clause is parsed and discarded (compiler/src/parser.cpp), so there is nothing to hang a class on without first making it mean something.


7. Deliberately not proposed

Inheritance. An earlier draft of this note rejected it on the grounds that it forces a vtable and a linker-visible class graph. OpenQM's source shows that is false, and the correction is worth recording: INHERITS takes a list, and it is implemented by giving each inherited class a private variable of its own name inside the instance. That is delegation, and it needs no vtable at all.

So the honest reason is narrower. Delegation is already available without language support — a member holding another object, and a method that forwards — and MV's reuse mechanism is the catalog. Multiple inheritance would buy the forwarding boilerplate and a name-resolution order to argue about. That is not nothing, but it is a second decision, and it should not ride along inside the first one.

Interfaces, generics, operator overloading, exceptions, static members — each buys less than it costs in a language with no type declarations.

A design admitting all of these is not a Pick BASIC extension. It is a different language wearing Pick's syntax.


8. What would make us stop

  • Any classic program in the suite whose meaning changes under the new grammar. That would say -> is not the impossible sequence it looks like, and the entire extension case rests on it.

  • A worked example that reads worse than the incumbent. If an ORDER class is not clearer than a catalogued subroutine over a dynamic array, §1 was wrong and this is answering a question nobody asked. This is the real test.

  • Any need to move an object across a CALL boundary that forces a change to mv_value's layout. Thirty-two bytes is the ABI. That ends the design rather than modifying it.

  • PRIVATE turning out to need runtime enforcement. That would put the object system on the wrong side of non-negotiable 8 and make it a security surface, which is far more than this is worth.

  • The C side growing past a dispatch function and a mangling rule. §5.1 is the boundary; crossing it means the design became the thing non-negotiable 7 forbids.

Clone this wiki locally