Solveig

The class side and the instance side

What roadmap 2.5 was about, why it was only a problem for the built-in classes, why the answer was not metaclasses, and what the class side should contain at all. Includes the single root, which this was long believed to gate and did not. Every snippet here has been run; the outputs are what the VM actually prints.

This question is closed — see COMPLETED for the verdict, and below for what settled it. The document is kept because the reasoning is what makes the answer make sense, and because the two sides still share one object: what changed is that the line between them is now drawn and checked.


A class is an object, and its slots are messages

There is no class keyword. integer is an ordinary object, and the messages an integer understands are its slots. That is the whole model, and it is why the language needed no separate notion of a class.

But look at what is actually in those slots:

integer:slots:size:print.        ; #37

Those 37 slots serve two different audiences:

slot sent as who it is for
add, mul, print, asString #45:add(#1) the instance side — what an integer understands
new integer:new the class side — what the class itself understands (it refuses, but it is still a slot on this side)

One bag of slots, two roles, and nothing in the object distinguishes them.

What that costs

An instance still finds a class-side message

#45:new(#1).                     ; solvm: 'new' expects an object, got integer
[#1]:of(#1, #2).                 ; solvm: 'of' expects an object, got array

#45:new(#1) is nonsense, and lookup finds it anyway. Lookup for #45 starts at integer, and new is sitting there beside add, so the send resolves; what stops it is the primitive on the other end checking what it was handed. The slot is on one side and the refusal is on the other, which is the shape of the whole answer: nothing in the object separates the two roles, so the separation has to be drawn where the work happens.

Both of those used to answer #1 and [#1, #2], and this document said so for a long time after they stopped. Nothing ran them: they sat in the pile of blocks the checker could not compile, which is 3.16.

It used to be a crash going the other way

array:add(#1).      ; was: abort
array:size.         ; was: #0, read from whatever `array` is not

array is an object whose slots are the messages an array understands, so it answers them itself — and prim_array_add then read the class object as an array. That was roadmap 1.6, and it fell out of exactly this: one object, two roles, and no way for a primitive to tell which one a send meant.

Reflection has to say two different things

slots reports what is there; respondsTo asks the dispatch question. Since 1.6 those have different answers, and both are correct:

integer:respondsTo('add):print.  ; false  -- sending it would be refused
integer:respondsTo('new):print.  ; true

integer:slots still lists add, because the slot is genuinely there.

The class side is populated wherever someone needed it

  new of
integer, float yes — refuses, and says what to write
array yes — allocates yes
object yes — allocates and delegates
string, symbol, block, boolean yes — refuses, and says what to write

This document used to say there was no rule saying which classes should have a class side, because there is nowhere for a rule to live — the class side not being a place, only some slots that happen to sit beside the instance ones, which is how one name came to mean four different things.

The second half of that is still true and the first half is not. A rule was available the whole time: new belongs where something is constructed, which is where the instances are mutable. An array and an object are references, so new hands back a fresh, distinct one — array:new:equals(array:new) is false. A string, a symbol, a boolean and a number are values, so there is no fresh distinct one to hand back; "":equals("") is true, and a string:new could only answer a literal spelled longer.

That rule sorts all nine classes correctly, and it agrees with the four refusals. It also puts integer:new and float:new on the wrong side of its own line, which the question underneath reaches by a different route.

What it does not do is give the rule somewhere to live. It is a rule about this document rather than one the language enforces, and only the split would change that.

The last row is recent, and it is the single root’s doing rather than anyone deciding those four wanted a new: once every built-in delegates to object they would otherwise have inherited one, so they shadow it to refuse. See below.


It is only a problem for the built-ins

This is the reframing that decides what the answer should be.

A user-defined object has no two sides at all:

point := object:new.
point:make := { ... }.           ; would be "class-side"
point:sum  := { ... }.           ; would be "instance-side"
p := point:new.                  ; p delegates to point, and sees both

That is coherent. It is prototypes doing what prototypes do, and p:make working is the model being consistent rather than a bug.

The built-ins are the anomaly, and one line in sol_vm_class_of says why:

case SOL_INT:   return vm->integer_class;      /* forced: one object */
case SOL_OBJ:   return SOL_AS_OBJ(value);      /* the object answers for itself */

An unboxed #45 has no object of its own, so dispatch has to be given one — and the only candidate is the same object the global integer names. The two roles are welded together by that line, not by the object model.

And a built-in cannot be subclassed, even deliberately

The natural next thought is that this is all a missing constructor: if integer:new answered an object delegating to integer the way object:new answers one delegating to its receiver, you would have a subclass of integer and could add to it. For user-defined objects that is exactly how it works — point:new and tip := point:new are the same operation, and whether one is an instance or a subclass is how you use it.

It does not carry over, and the reason is worth seeing rather than arguing about. Built as an experiment — integer:new with no argument answering sol_object_new(vm, vm->integer_class), on a throwaway copy of the tree — it looks right at first:

a := integer:new.
a:isKindOf(integer):print.       ; true
a:tag := #7.
a:tag:print.                     ; #7     -- a real object, slots and all

and then:

a:add(#1).       solvm: 'add' expects an integer, got object
a:print.         solvm: 'print' expects an integer, got object

a:double := { self:mul(#2) }.
a:double.        solvm: 'mul' expects an integer, got object

It inherits every method name and can run none of them. The last line is the one that settles it: a method you wrote fails too, the moment it touches anything inherited.

integer’s methods are not Solum code. They are C primitives that do SOL_AS_INT(self) and read an 8-byte payload, and an object does not have one. So a built-in class hands down an interface and no implementation — which is the same inert object string:new would have produced, and the reason those four refuse rather than inherit.

Two independent things stop it, and either would be enough: an unboxed value carries no class pointer, so there is nowhere to record a different class; and the inherited methods require the exact value representation. Neither is fixed by anything in this document — a behaviour object per built-in would not help, because #45 would still dispatch by type tag.

So there are two ways to give integers behaviour, and no third:

Probably not metaclasses

The roadmap says this “needs a metaclass level”. That is the Smalltalk answer, and it is worth being sceptical of here.

Metaclasses answer where do class-side methods live for languages that have a class/instance distinction built in. Solum does not: design.md says plainly that whether an object is a class or an instance is how it is used, not what it is. A metaclass tower would import a class-based concept to fix a problem only the built-ins have. Smalltalk’s loop-closing — Metaclass class class = Metaclass — buys uniformity for a reflective browser this language does not have and may never want.

A smaller answer: a behaviour object

One extra plain object per built-in. No new concept, no tower.

integer             (the global)     slots: new
integer-behaviour   (internal)       slots: add, mul, print, asString, ...

and one changed line:

case SOL_INT:   return vm->integer_behaviour;

What falls out:

  today after
#45:new(#1) answers #1 integer does not understand 'new' — it was never in the object #45 dispatches to
array:add(#1) needs 1.6’s receiver check to refuse it refuses structurally; add is not there to find
integer:slots 37 slots, both sides mixed the class side; the instance side is a separate object to ask
slots vs respondsTo disagree agree again

1.6’s per-message receiver requirement becomes mostly redundant. It exists because a class object can reach an instance primitive, and afterwards it cannot. Worth keeping as a cheap floor — the same argument as the send’s stack check, which the verifier also made redundant and which stayed — but it would stop being load-bearing.

The single root — done, and it was not gated by this

This section used to say the built-in classes deliberately do not delegate to object, because float inheriting object’s new would answer a plain object rather than a float, and that a single root therefore waited on the split.

That stopped being true and nobody noticed. Two earlier commits removed it:

So what integer would inherit was exactly two messages, and both were already refused. It was tried on a throwaway copy, the whole suite passed untouched, and it is now committed:

#45:isKindOf(object):print.            ; true
"s":isKindOf(object):print.            ; true
nil:isKindOf(object):print.            ; true
integer:parent:equals(object):print.   ; true
object:parent:print.                   ; nil   -- the chain ends here

Nothing leaked onto the values. #45:parent and #45:via(...) are refused by the receiver check, three commits before anyone thought about a root.

What the root means for writing programs — one definition on object answered by every value, and the line between inheriting behaviour and being an object — is one-hierarchy.md. This section is the decision; that page is the consequence.

The cost is on the miss path only, and only because a miss now walks object’s thirteen slots before failing: a send that hits is unchanged, and 200,000 failed lookups cost about 10% more. That is the path that ends in does not understand.

The one thing that was in the way

string, symbol, block and boolean have no new of their own, so they would have inherited object’s — which answers a fresh object delegating to the receiver. For string that is an object delegating to the string class object, refusing every message a string understands. Inert rather than wrong, and no use to anybody.

They shadow it now, and refuse:

string:new.
solvm: a string is written as a literal, not made with 'new' -- "" is the empty one

symbol:new.
solvm: a symbol is written 'name, or made from a string with asSymbol -- not with 'new'

block:new.
solvm: a block is written { ... } and compiled -- there is nothing for 'new' to make

boolean:new.
solvm: there are only two booleans, true and false -- 'new' makes neither

The rule underneath is worth stating once: new means “make an object delegating to me”, and these four have instances that are not objects delegating to them. That asymmetry is inherent to unboxing rather than a wart, so the right answer was to say so where each class is defined, and leave object:new general.

The error is the only thing such a class has to offer here, so it teaches.

The wrinkle to design carefully

#45:isKindOf(integer) must stay true, and after the split #45’s lookup object is no longer the one integer names. The class object needs a link to its behaviour object, with isKindOf and reflection following it. That is the whole cost, and it is where the thinking time would go — not on the dispatch change, which is one line.

A second question falls out of it: should the behaviour object be reachable from the language? integer:behaviour would make reflection honest about the split. Hiding it would keep the surface smaller and make integer:slots quietly incomplete.

How it was settled

Not by splitting the objects. The line between the two sides is drawn by the receiver each slot requires, which 1.6 had already built for a different reason — to stop array:add(#1) running against a class object — and which turned out to be the whole mechanism the split was wanted for.

Three messages were on the wrong side of it. new, slots and slotAt were registered for any receiver and then refused a value from inside the primitive, so respondsTo said one thing and sending did another:

#45:respondsTo('new).       ; true
#45:new.                    ; an integer is written #45, and there is nothing for 'new' to make

The same gap let an instance answer a class-side message — [#1]:new handed back a fresh empty array, and [#1]:of(#2, #3) a fresh one holding them.

Every class-side message requires an object receiver now: new, of, fromSeconds, slots, slotAt. Ten registrations. respondsTo agrees with sending everywhere, an instance cannot answer for its class, and the teaching errors survive because they were always for the class:

integer:new.
solvm: an integer is written #45, and there is nothing for 'new' to make -- #0 is the empty one

The rule this document said had nowhere to live now lives in the registration table, where it is checked on every send: a slot that takes SOL_OBJ is class side, a slot that takes a value type is instance side. And the two are separable from inside the language, with nothing on neither side:

integer:slots:size.                                            ; #30
integer:slots:select({ s | integer:respondsTo(s) }):size.      ; #8
integer:slots:select({ s | #45:respondsTo(s) }):size.          ; #27

The five that overlap are isKindOf, isNil, notNil, perform and respondsTo — reflection that serves both audiences, which is right rather than sloppy.

What the split would still buy

One thing, and it is not worth a second object per built-in. integer:slots answers 30, listing both audiences. That is honest — they are its own slots — and it is now explicable in one sentence, with a filter to show it. The split would make it 8 by moving 22 somewhere else, at the cost of a class-to-behaviour link that isKindOf and all four reflection messages would have to keep honest.

The other two things it was for are gone: #45:new(#1) is refused, and the rule about the class side has somewhere to live.

The trigger to reopen this: when slots on a built-in class is read by a program rather than printed by an example. Across four programs written to do a job and four libraries, reflection on a built-in class appears exactly once — integer:slots:size:print in examples/reflect.sol, printing a count. Until something reads that list and has to care which audience it is looking at, the filter is enough.

The single root was a separate decision, and it is already made — see above. This paragraph used to say the work was “eight lines and the answer to what new should do on the four classes”; both were done, and the sentence outlived them. Nothing here is waiting on that any more.


What should the class side contain?

The same question, one level down, and separable from everything above: given that there is a class side, what belongs on it?

string, symbol, block and boolean have none at all. Should they be given one — and what would new even mean for a string, a symbol, a block, or a boolean?

The answer is no for all four, and the reason is that new is already three different operations sharing a spelling.

new used to mean three things

integer:new(#45)  ->  return args[0];                  /* identity, type-checked */
array:new         ->  sol_array_new(vm, 0)             /* allocates              */
object:new        ->  a fresh object delegating to self /* allocates and delegates */

The comment in builtins.c said the first one outright: “Integers are immutable values, so there is nothing to allocate — integer:new(#45) is the long form of the literal #45.”

It was not even a uniform protocol, because the arities disagreed:

integer:new.        ; solvm: 'new' takes 1 argument, got 0
array:new(#1).      ; solvm: 'new' takes 0 arguments, got 1

So no generic code could send new without already knowing which class it was talking to — which is the only thing a shared name would have bought.

The first line is gone as of d58918c, and the section below is what argued for that. new now means one thing, make me a new one, and lives on the two classes where something is made.

The arities agree now too, which was the other half of the complaint: every class that constructs takes no argument, object:new and array:new and every user-defined point:new alike. So generic code handed a constructing class can send new without knowing which one it has. It still cannot send it blind to any class, since six refuse — but a refusal that says what to write is a better answer than one that hands back its own argument.

Why none of the four wants a real one

They have a new now, but it refuses — and this is why nothing was built to succeed instead:

Which is the argument for the refusal being the right implementation rather than a placeholder: there is nothing better for these to do, and saying so is worth more than answering something that is technically a value.

There is a second motivation for wanting these, which the above does not touch. a := string:new reads as a declarationa is a string variable, not filled in yet — rather than as a construction, and that is a nicer thing to want than any of the four flavours above. It is answered in absence.md: the language already has that declaration in | a | and a := nil, both of which leave isNil able to answer, where starting a name at "" trades “not filled in” for “filled in with nothing”.

That section also says why array:new is not the precedent it appears to be. It answers a fresh array rather than the empty one — array:new:equals(array:new) is false where "":equals("") is true — so the line between the classes that construct and the classes that do not is mutability, not emptiness. That is the rule this document says has nowhere to live, and it sorts all nine classes correctly.

The question underneath — answered, and acted on

The interesting question was not whether the four should gain new. It was whether the two identity ones should have it. They should not, and as of d58918c they do not.

integer:new and float:new allocate nothing and construct nothing. They are the literal, spelled longer — and float:new was added in 7ac6be6 for symmetry with integer:new, which is symmetry with something that does not do anything.

They are also a vestige of a design that was abandoned. The original sketch in design.md reads:

        integer:new(a)  ; sends message "integer" to top Object to create an integer object
        a:set(#45)      ; assigns integer value #45 to integer object

That is a mutable integer object you construct and then fill. Numbers became immutable unboxed values instead, set never existed, and new survived the change with nothing left to do. It has been the identity function ever since, because that is all that was left once the thing it constructed stopped being a thing you construct.

And it is what makes one of the symptoms above possible: #45:new(#1) answers because there is a new on integer at all.

So the tidier direction was the opposite of the question: leave the four alone, and take new off integer and float — leaving it meaning one thing, make me a new one, on the classes where something is actually made – which was object and array, and is object, array and dictionary now that there is one more mutable thing to construct.

They could not simply lose it. Deleting the registration was tried, and integer:new then inherited object’s and answered an object delegating to integer, which fails print. Worse than the identity function it replaced, and the same trap that made the other four shadow rather than inherit. So the two joined the refusers:

integer:new(#45).
solvm: an integer is written #45, and there is nothing for 'new' to make -- #0 is the empty one

#45:new(#1) refuses along with it, which removes the first of the three symptoms at the top of this document. The other two are untouched: integer:slots still lists new beside add, because the slot is still there — it holds a refusal rather than a constructor, and slots reports what is there.

Two things weighed against doing it. It is a breaking change to a documented message, and examples/hello.sol used it deliberately — “integer:new is the explicit long form of the literal” — as the closing of the loop from the original notes. Both were paid: the example now closes that loop better, by showing that the notes’ integer:new(a) and a:set(#45) both went, and why. Four tests changed.

The alternative, had they stayed, was to admit that new on a value class is a checked assertion rather than a constructor and name it for that. Refusing says the same thing in less space.


Summary