Script-Extensible Objects: extend
A script class can derive a bound C++ class, and extend(object, Class) gives an object the host already
made that class. The host constructs and owns its objects and never names a script class; the script decides what each
object becomes.
- One object, one identity. Once extended, the object reaches script as its script class by every
road: a bound method returning
shared_ptr<Base>, a signal argument, a container element, a loaded archive, the host's next call. A base pointer to a derived object is the derived object, as in C++. - In place. The instance is re-classed where it stands. Handles taken before the extend follow at their next member access.
- Fields initialize, no constructor runs. Field initializers run in declaration order, base first, so a class meant for extension needs no constructor.
- Script calls dispatch to overrides; C++ calls do not. A call C++ makes on the object
(
creature->update(dt), or a bound method that calls a virtual) runs the C++ body. - The host keeps ownership. It may destroy the object at any time; a layer a script still holds is then retired and reports instead of dangling.
The shape it is for
C++ spawns creatures; scripts define what each kind does.
extern self; // the host provides `self` (its callback slots) before the script runs
class Slime : Creature { // Creature is the host's bound C++ class
int size = 3;
double hopCooldown = 0.0;
void update(double dt) override {
hopCooldown = hopCooldown - dt;
if (hopCooldown <= 0.0) { hop(); hopCooldown = 1.5; } // hop() is a Creature method
}
void death() override { // a big slime splits into two smaller ones
if (size <= 1) { return; }
for (int i = 0; i < 2; ++i) {
var child = spawn_creature(position); // the host spawns a plain C++ creature
extend(child, Slime).size = size - 1; // ...and the script says it is a smaller Slime
}
}
}
// the three callbacks this host happens to expose (spawn, update, death) - your host will
// have its own. It passes the C++ creature; the script says what it becomes.
self.spawn = [=](var& creature) -> void { extend(creature, Slime).spawn(); };
self.update = [=](var& creature, double dt) -> void { creature.update(dt); }; // runs Slime.update
self.death = [=](var& creature) -> void { creature.death(); }; // runs Slime.deathextend(object, Class)
extend is a core builtin, present in every engine, sandboxes included. Class is the bare
class name or a string. It returns the extended object, so it chains (extend(kin, Slime).spawn();); keep using the
original name afterwards rather than binding the result to a second one.
extern raw; // `raw` arrives from the host, so declare it
class Goblin : Creature { // Creature is a bound C++ class
int bite = 3;
string speak() override { return "voidkin"; }
}
class GoblinChief : Goblin {
int fangs = 1;
string speak() override { return "voidspawn"; }
}
extend(raw, Goblin); // raw arrived as a plain C++ handle; now it IS a Goblin
raw.bite = 17;
print(extend(raw, Goblin).bite); // idempotent: the same class returns the live layer
print(extend(raw, "Goblin").bite); // a string names the class too
extend(raw, GoblinChief); // re-derive in place
print(raw.bite, " ", raw.fangs, " ", raw.speak());17
17
17 1 voidspawn| case | result |
|---|---|
| the class's C++ base is the object's C++ class or one of its bases (through any chain of script classes) | accepted. The object keeps its own C++ type, so objects of different C++ types under one base can share a script class. |
| the object already has that class | no-op; the current layer is returned |
| the object has another class | re-derived: fields both classes declare keep their values (bite), the new class's own fields take their initializers (fangs), the new overrides dispatch, and fields only the old class declared stay reachable |
| a script-only instance | re-derived the same way; the new class must derive the instance's class |
| anything else: an unrelated class, a non-class argument, wrong argument count | refused with extend_rejected, naming the class and its C++ base. Script catches it; engine::extend throws. |
Try it without a host
class Pt { int x = 1; string what() { return "Pt"; } }
class Pt2 : Pt { int y = 2; string what() override { return "Pt2"; } }
class Pt3 : Pt2 { int z = 3; string what() override { return "Pt3"; } }
var p = Pt2();
p.x = 17; // a field Pt3 also declares
extend(p, Pt3); // re-derive in place
print(p.what() + " x=" + to_string(p.x) + " y=" + to_string(p.y) + " z=" + to_string(p.z));
try { extend(p, Pt); } catch (e) { print("refused: " + to_string(e)); }Pt3 x=17 y=2 z=3
refused: extend: 'Pt' does not derive 'Pt3'What the checker sees
Extension is decided at run time, so the checker opens what it cannot know:
- Any name passed to
extend(...)anywhere in a unit is open for that whole unit, and a bound C++ class stays open once a script layer has been extended over it. A member the checker cannot find on such a receiver is not an error; the members it can find are still checked. - A handle the host injects must still be declared:
extern self;. The typed form,extern shared_ptr<Creature> raw;, adds member checking againstCreature.
Host side: script_extensible and engine::extend
auto creature = std::make_shared<Creature>(); // C++ constructs and owns, as always
jai::script_value layer = eng->extend(creature, "Goblin"); // what the builtin does
eng->add_global("hero", creature); // script sees the Goblin
eng->execute("hero.bite = 17;");
jai::strong_ptr<jai::class_instance> live = eng->script_layer(*creature); // the live layer, or null
std::string state = creature->script_layer_to_json(); // {"$script":{"bite":17,...},"$type":"Goblin"}
creature->script_layer_from_json(state); // hydrate in place (re-derives on another $type)
std::string fields = eng->script_layer_detach(creature); // the fields as JSON; the object carries no layer nowThe object finds its layer through a slot it carries:
| the C++ type | how it carries a layer |
|---|---|
derives property_owner | already extensible: property_owner derives jai::script_extensible, a 32-byte slot |
| has nothing to reflect | derive jai::script_extensible directly. Under multiple inheritance the first extensible base's slot is used. |
| can derive nothing | works through an engine-owned lookup. The engine owns such a layer, and keeps the object alive, until script_layer_detach. |
Handing script the layer is a pointer read on the slot, with no allocation.
Ownership and copies
| how the hybrid came to be | who owns what |
|---|---|
the script constructed it (Goblin()) | the script instance owns its C++ object |
| the host owns the object and a script extended it | the object owns its layer; the layer refers to the object without owning it. If the object dies while a script holds the layer, the layer is retired: its script fields stay readable and a C++ member access raises script_layer_retired, as it does on a detached or moved layer. |
only the script owns the object when it is extended (it made it with new, or the host let go) | the layer owns the object |
- Assigning a hybrid (
var b = a;) deep-copies it, C++ object included, giving two identities. A hybrid over a base with no copy function isnocopy. - By-value capture (
[=],[this]) shares a hybrid: the lambda holds that object, where a plain script value would be copied. - A layer belongs to one engine. Extending the object in engine
BwhileAholds its layer moves it: the fields travel by name andA's layer is retired. A type with the slot needs onlyb->extend(obj, "Goblin"); a type without it needsb->extend(obj, "Goblin", a->script_layer_detach(obj)).
Serialization
A hybrid serializes as itself through any archive path (a shared_ptr<Base> field, a container, a
save file), in JSON and binary, back-references included: $type is the script class name,
$script the script fields, $val the C++ base's own payload. Script-level
to_json/from_json do the same.
std::shared_ptr<Creature> original = eng->execute("v").as<std::shared_ptr<Creature>>();
auto loaded = jai::from_binary<std::shared_ptr<Creature>>(*eng, jai::to_binary(*eng, original)); // a fresh object...
eng->script_layer(*loaded) != nullptr; // ...carrying its Goblin layerLoading follows the language's order (Loading saved objects):
- Build the object. A script class with a zero-argument constructor is constructed, its
super(...)building the C++ object. Otherwise the C++ object is made as the archive makes any host type: the base'sload_and_construct, else its default constructor (a private one throughfriend jai::access;). Script-levelfrom_jsonalso accepts a default constructor the binder registered, and the value it returns owns its C++ object. - Load the C++ base's state through the base's own load.
- Run field initializers, then load the script fields, so a field the save never wrote keeps its declared default.
- Call
void post_load(int version)if the class defines it, once the outermost read is done;versionis the archive's version stamp.
A base that offers none of the constructions in step 1 is refused: Cannot load hybrid script class 'Goblin': its
C++ base 'Creature' is not default constructible and has no load_and_construct. The archive framework itself is
the Serialization chapter.