-
Notifications
You must be signed in to change notification settings - Fork 0
objects
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.
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:
-
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. -
No boundary. A
COMMONblock is global by name. Any program that spells the name correctly owns your state, and nothing says which programs were meant to. -
No dispatch.
CALL ORDER.TOTALnames 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.
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.
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.
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
CLASSis a compilation unit, a peer ofPROGRAM,SUBROUTINEandFUNCTION, and must appear before any executable statement. - Members are declared
PUBLIC(FUNCTION,SUBorSUBROUTINE) orPRIVATE. -
GETandSETgive properties, and are the same mechanism: the compiler treatsGETas a public function andSETas a public subroutine. SoORD->TOTALcan be a call rather than a slot, and the pair reads and writes it. -
CREATE.OBJECTis the constructor, and must be aPUBLIC SUBROUTINE. -
MEis the self-reference, compiled to its own opcode. - Instantiation is the function
OBJECT(name, args...). - Member access is
->— tokenised asTKN_OBJREFfrom-followed by>. -
INHERITStakes 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.
PUBLICis the boundaryCOMMONnever 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/SETis the answer to attribute numbers leaking — the second failure in §1. A caller saysORD->TOTALand 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
WRITEone, 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.
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.
(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.
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.
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.
- 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. -
Guard —
PORT-SOURCEremains the escape hatch, as it is for comments.
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.
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.
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_copyandmv_clearneed no change at all. Both guard ontag == MV_STRand 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.
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/. -
CATALOGandBUILD-PKGpublish them unchanged. - The C surface grows by roughly one dispatch function — not an object system.
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.
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.
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.
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.
-
Dot-notation member access. Rejected:
.is already an identifier character, soORD.TOTALis ambiguous with a legal variable name and fails the extension bar (§3.1). -
A new
MV_OBJtag holding a pointer, after theMV_FILEpattern. 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_cleargain a per-tag branch in the two hottest functions in the runtime, which today special-caseMV_STRand copy everything else bitwise; and it puts a second lifetime discipline under the same 32-byte struct asMV_FILE's aliasing one. That last is the durable cost, and it is worse than the branch. Note also howMV_FILEhandles their own lifetime: they are context-owned and released together atmvx_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_valueand always goes throughmv_copy— the same disciplineDESIGN-DYNAMIC-ARRAYS.mdenforces 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_varalready 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: theORDER.ADDcollision in §4.3. -
The extension-function signature for methods —
void 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 ... CALLINGas a declaration hook. Rejected: theCALLINGclause is parsed and discarded (compiler/src/parser.cpp), so there is nothing to hang a class on without first making it mean something.
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.
-
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
ORDERclass 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
CALLboundary that forces a change tomv_value's layout. Thirty-two bytes is the ABI. That ends the design rather than modifying it. -
PRIVATEturning 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.