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.

jaiscript · needs the host's classes
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.death

extend(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.

jaiscript · needs the host's classes
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());
output
17
17
17 1 voidspawn
caseresult
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 classno-op; the current layer is returned
the object has another classre-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 instancere-derived the same way; the new class must derive the instance's class
anything else: an unrelated class, a non-class argument, wrong argument countrefused with extend_rejected, naming the class and its C++ base. Script catches it; engine::extend throws.

Try it without a host​

jaiscript
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)); }
output
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 against Creature.

Host side: script_extensible and engine::extend​

C++
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 now

The object finds its layer through a slot it carries:

the C++ typehow it carries a layer
derives property_owneralready extensible: property_owner derives jai::script_extensible, a 32-byte slot
has nothing to reflectderive jai::script_extensible directly. Under multiple inheritance the first extensible base's slot is used.
can derive nothingworks 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 bewho owns what
the script constructed it (Goblin())the script instance owns its C++ object
the host owns the object and a script extended itthe 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 is nocopy.
  • 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 B while A holds its layer moves it: the fields travel by name and A's layer is retired. A type with the slot needs only b->extend(obj, "Goblin"); a type without it needs b->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.

C++
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 layer

Loading follows the language's order (Loading saved objects):

  1. 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's load_and_construct, else its default constructor (a private one through friend jai::access;). Script-level from_json also accepts a default constructor the binder registered, and the value it returns owns its C++ object.
  2. Load the C++ base's state through the base's own load.
  3. Run field initializers, then load the script fields, so a field the save never wrote keeps its declared default.
  4. Call void post_load(int version) if the class defines it, once the outermost read is done; version is 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.