dynamic_binder: Binding C++ Classes
jai::dynamic_binder<T> makes a C++ class usable from script. The rules that govern this page:
- Scripts reach only what you bind. A binder chain names the constructors, members and methods of one class for one engine.
- Objects cross the boundary by identity, never by copy. Once in script, a bound object follows the language's value rules: a value copies through the C++ copy constructor, a handle (
shared_ptr) or a reference shares. - One declaration can do three jobs. A field declared with
JAI_PROPERTYon ajai::property_owneris reflected (C++ enumerates it by name), script-visible, and serialized to JSON and binary, so the three never drift apart. - A
jai::registrarmakes a class known to every engine that callsbind_registrar. Registration order never matters. - An engine and its objects belong to one thread at a time.
Every C++ sample here is compiled against the library and run on both backends.
Bind a plain C++ class
A class you already have needs no macros:
#include <jaiscript/core/engine.hpp>
#include <jaiscript/core/dynamic_binder.hpp>
#include <jaiscript/stdlib/stdlib.hpp>
#include <iostream>
struct Turret { // 1. an ordinary class
std::string name = "turret";
int64_t ammo = 10;
Turret() = default;
Turret(std::string a_name, int64_t a_ammo) : name(std::move(a_name)), ammo(a_ammo) {}
bool fire() { if (ammo == 0) { return false; } --ammo; return true; }
double range() const { return 12.5; }
};
int64_t total_ammo(const Turret& a, const Turret& b) { return a.ammo + b.ammo; }
int main() {
auto eng = jai::engine::make();
jai::stdlib::register_all(eng);
jai::dynamic_binder<Turret>(*eng, "Turret") // 2. bind it under a script name
.constructor<>()
.constructor<std::string, int64_t>()
.property("name", &Turret::name)
.property("ammo", &Turret::ammo)
.method("fire", &Turret::fire)
.method("range", &Turret::range);
eng->add_function("total_ammo", &total_ammo); // 3. functions that take it
eng->execute(R"( // 4. scripts construct and call it
Turret t = Turret("north", 2);
t.fire();
t.fire();
print(t.name, " ammo=", t.ammo, " fired=", t.fire(), " range=", t.range());
)");
auto east = std::make_shared<Turret>("east", 5); // 5. hand a live C++ object to script
eng->add_global("east", eng->make_object(east));
eng->execute("east.fire(); east.ammo += 10;");
std::cout << "C++ sees east.ammo = " << east->ammo << "\n";
eng->execute(R"(var west = new Turret("west", 3); print(total_ammo(east, west));)");
auto west = eng->get_variable("west").as<std::shared_ptr<Turret>>(); // 6. and take one back
west->ammo = 99;
eng->execute("print(west.ammo);");
return 0;
}north ammo=0 fired=false range=12.5
C++ sees east.ammo = 14
17
99- Constructors are listed by parameter types; a script call picks one by argument count and types.
Turret(...)makes a value andnew Turret(...)ashared_ptrhandle. - Properties read and write the C++ member directly.
- Identity.
make_object(shared_ptr)andadd_global(name, shared_ptr)wrap your pointer without a copy, so script writes land in C++.as<std::shared_ptr<T>>()hands the script's object back, shared. - Lifetime follows the
shared_ptr: the object lives while either side holds it.add_global_refbinds an object you keep alive yourself, andeng->make_object<T>(args...)constructs a value in place. - Null and weak handles. A
std::shared_ptr<T>orstd::weak_ptr<T>parameter (by value orconst&) takes a scriptnullas an empty handle, sopick(null)resolves to the handle overload. Aweak_ptr<T>parameter also takes a handle or a scriptweak_ptr<T>; an expired one arrives empty. Returning astd::weak_ptr<T>to script is not supported: return ashared_ptr<T>and let script take its ownweak_ptr. - Exceptions. A C++ exception from a bound method reaches script as a catchable error carrying its
what()text (Error surfaces).
One declaration: JAI_PROPERTY
An engine usually grows reflection for its editor, script binding for its designers and serialization for its saves as three separate registrations of the same fields, and they drift: a field saves but does not show in the editor, or renames in one system only. Here the declaration is the registration. A spawner for a tower-defense game:
#include <jaiscript/core/registrar.hpp>
#include <jaiscript/properties/macros.hpp>
#include <jaiscript/properties/property_serialization.hpp>
#include <jaiscript/signals/signal_property.hpp>
#include <jaiscript/serialization/convenience.hpp> // to_json / from_json / to_binary below
class Spawner : public jai::property_owner<Spawner> {
public:
JAI_PROPERTY((std::string), id, "north-gate");
JAI_PROPERTY((int64_t), level, 1);
JAI_PROPERTY((double), interval, 2.5);
JAI_OBSERVABLE_PROPERTY((int64_t), threat, 0); // emits on_change(old, new)
JAI_SIGNAL_PROPERTY(void(int64_t), onWave); // script-connectable, serializable
void spawn_wave() { total_waves_ += 1; onWaveSignal(total_waves_); }
int64_t total_waves() const { return total_waves_; }
private:
int64_t total_waves_ = 0; // not a property: private, unreflected, unsaved
};
static jai::registrar<Spawner, void> _regSpawner("Spawner",
[](jai::dynamic_binder<Spawner>& b) {
b.method("spawn_wave", &Spawner::spawn_wave);
b.method("total_waves", &Spawner::total_waves);
});property_owner<Spawner>gives the class aproperty_mgrand registers its serialization identity (the$typename and the polymorphic load factory) at program start.JAI_PROPERTY((type), name, default)declares ajai::property<T>member that behaves like aTin C++. The type is parenthesized so template commas survive the macro.JAI_OBSERVABLE_PROPERTYadds anon_change(old, new)signal that fires on a write that changes the value, from either side.JAI_SIGNAL_PROPERTY(signature, name)declares a private emitter you fire (name+Signal, hereonWaveSignal(n)), a publicjai::signalview others connect to, and a hidden property that saves it.JAI_SIGNAL_PROPERTY_AS(signature, name, emitter)names the emitter yourself.jai::registraris a self-registering static. Its configure function names only what the macros cannot know, the methods; it callsauto_bind()for the rest.
The host replays every registrar in the binary into an engine with one call:
auto eng = jai::engine::make();
jai::stdlib::register_all(eng); // formatted print, containers, math, json
jai::bind_registrar(*eng); // every jai::registrar in the binary, one callA host whose bindings need its own services gives the registrar a context type (jai::registrar<Monster, HostContext>); each configure function then receives the context that jai::bind_registrar<HostContext>(*eng, ctx) passes.
What makes a type visible, and when
| Step | Effect |
|---|---|
Declaring (property_owner and the macros) | Identity, inheritance, properties and serialization. Not script-visible. |
jai::registrar<T> | Every engine handed to bind_registrar knows the type. An engine that never makes that call knows none of them. |
dynamic_binder<T>(eng, "Name") | One engine knows the type, under the name and with the methods you choose. |
Registration decides which host types and functions a script can name; it is not a sandbox. An engine with nothing registered still runs the whole language, reads files through include and import, and has no memory cap. Safety Rails lists what a default engine can reach and gives the recipe for untrusted script.
- One name everywhere. A type is named by its
JAI_PROPERTY_OWNER(Name)orJAI_PROPERTY_OWNER_AS(Class, "Name")pin when it declares one, else by its bare class name, and that is also its serialization$type. - Static phase. Declarations, schemas, serialization identities and the registrar queue set themselves up at program start, in any file order, touching no engine.
- Runtime phase. The host calls
engine::make(),jai::stdlib::register_all,jai::bind_registraronce per engine, then any explicit binder chains, then scripts. Registrations may come in any order: a derived class registered before itsproperty_ownerbase registers that base under its own name, and a later explicit registration of the base adopts the same definition and gives it the explicit name. - A bound member may name a class that registers later, its own class included. The engine's first load, check or host call into script raises one error listing every type still unregistered; register them and load again.
- Registering after scripts have run is supported. Re-registering an explicitly bound type replaces its binding for objects constructed from then on; existing objects keep their shape.
- The one enforced rule: an explicit registration cannot adopt an automatically registered base while script instances of it are alive, because that would change their type identity. The binder throws. Finish registering before scripts construct objects, and pin a name on any class scripts construct directly.
Bindings are incomplete:
'Hangar.plane' uses the C++ type 'Plane', which is not registered. Register it: jai::dynamic_binder<Plane>(engine, "Plane");Scripted
No glue was written for id, level or onWave:
auto s = Spawner();
s.level = 3;
s.onWave.connect("hud", [=](int wave) -> void {
print("wave {} incoming", wave);
});
s.spawn_wave();
s.spawn_wave();
print("{} at level {}: {} waves so far", s.id, s.level, s.total_waves());wave 1 incoming
wave 2 incoming
north-gate at level 3: 2 waves so farA binding has a default constructor for any default-constructible, non-abstract type, preferring a T(engine*) constructor when one exists. A signal connection takes a name ("hud"): the signal owns a named connection, which lasts as long as the object and is replaced or removed by name.
Live objects, both directions
Script writes land in the C++ member, and C++ emits reach script receivers:
auto gate = std::make_shared<Spawner>();
eng->add_global("gate", eng->make_object(gate));
eng->execute("gate.id = \"east-gate\"; gate.level = 7;");
// gate->id.get() == "east-gate", gate->level.get() == 7
eng->execute(R"JAI(
gate.onWave.connect("hud", [=](int wave) -> void {
print("gate wave {}", wave);
});
)JAI");
gate->spawn_wave(); // C++ emit -> the script receiver runsgate wave 1An observable property carries change notifications the other way. A script observes:
gate.threat.on_change([=](int old_value, int new_value) -> void {
print("threat {} -> {}", old_value, new_value);
});and a write from either side fires it:
gate->threat = 5; // C++ write
eng->execute("gate.threat = gate.threat + 2;"); // script write; reads unwrap in expressionsthreat 0 -> 5
threat 5 -> 7One engine serves one thread at a time, and everything registered into it or made by it belongs to that thread. Another thread may use or drop those objects only while the engine is idle, with the hand-off synchronized. Two engines on two threads need no locks, because nothing is global; share a value between them with an atomic.
When a bound object is copied, and when it is shared
The boundary never copies: make_object(shared_ptr), a T& or std::shared_ptr<T> result, and a member whose type is a registered class all arrive as the C++ object. From there the object behaves as a script class instance does. The guide's Bound C++ objects walks each case with one class.
Every case, one per row: when a bound object is copied or shared. The three met daily:
| Script | Result |
|---|---|
| A value into a variable, field, element, by-value parameter or by-value capture | A separate C++ object made by the copy constructor. |
A reference: var& g = gate;, a & parameter | The same object. |
A handle: new Turret(...), a std::shared_ptr<Turret> the host returns | Every copy of the handle shares the one object. |
The bound = is the "=" method: .method("=", &C::operator=), or a lambda taking (C&, const C&); overloads from other types resolve like any overload set. auto_bind binds it only for a trivially copy-assignable class. Assigning through a reference to a class that binds none raises Cannot assign to a reference to 'C': its binding has no operator=. Binding "=" on a class whose assignment cannot compile fails at the binding, as in C++.
Reflected
The same declaration is what an editor inspector or debug panel walks. Writing through the property keeps change signals firing:
gate->visit_reflected_properties([](const std::string& name, jai::property_base* p) {
std::cout << "prop: " << name << "\n";
});
if (auto* lvl = gate->property_value<int64_t>("level")) {
std::cout << "level via reflection: " << *lvl << "\n";
}
gate->set_property_value<int64_t>("threat", int64_t{9}); // on_change firesprop: id
prop: interval
prop: level
prop: onWave
prop: threat
level via reflection: 7
threat 7 -> 9The walk is virtual: a base pointer to a derived object shows the whole chain. Each property carries value_type_id() (its C++ type) and allow_save(), and Spawner::schema() and Spawner::all_property_names() answer without an instance.
Serialized
No save or load method was written; one call walks the same property manager. The outer level is the archive's root record, whose value0 holds the pointer you passed with its $id; the object is the $val. The signal's named script-text receiver is saved with it:
eng->execute(R"JAI(
gate.onWave.connect("logger", "print(\"wave logged\");"); // named script-text receiver
)JAI");
auto json = jai::to_json(*eng, gate);
std::cout << json << "\n";{
"value0": {
"$id": 1,
"$val": {
"id": "east-gate",
"interval": 2.5,
"level": 7,
"onWave": {
"parameter_names": [],
"observers": [],
"owned_observers": {
"logger": {
"parameter_names": [],
"script": "print(\"wave logged\");"
}
}
},
"threat": 9
}
}
}Loading rebuilds the object and reconnects the receiver. Pass the engine as the load context so the receiver has an engine to run on:
auto loaded = jai::from_json<std::shared_ptr<Spawner>>(*eng, json, *eng);
// loaded->id.get() == "east-gate", level 7, threat 9
loaded->spawn_wave(); // the receiver came back from the save filewave loggedBinary is the same declaration through another archive:
auto bytes = jai::to_binary(*eng, gate); // std::vector<uint8_t>
auto again = jai::from_binary<std::shared_ptr<Spawner>>(*eng, bytes, *eng);
again->spawn_wave(); // receivers persist in binary toowave logged- Which receivers save. Script-text receivers persist. Function receivers (script lambdas and C++ callbacks) are runtime-only and are skipped without error.
- By value or by pointer. A
property_ownercopies and moves, sojai::from_json<Spawner>(*eng, jai::to_json(*eng, *gate))returns one by value: its properties sit flat at the root, under its static type. Ashared_ptrkeeps identity and a derived object's$type. - Across releases. JSON is the readable form for debugging and diffs, binary the compact one. Fields are found by name, so adding or removing one needs no migration, but neither layout is promised to load across 0.x releases (Stability).
A class that is not a property_owner and has no serialize, save or load serializes through its binder chain: the .property names are its record's keys, and .base_class<B>() adds the base's properties and the $type a base pointer loads by:
struct Actor {
virtual ~Actor() = default;
std::string name;
};
struct Archer : Actor {
int arrows = 12;
};
jai::dynamic_binder<Actor>(*eng, "Actor").property("name", &Actor::name);
jai::dynamic_binder<Archer>(*eng, "Archer").base_class<Actor>().property("arrows", &Archer::arrows);
std::shared_ptr<Actor> hero = std::make_shared<Archer>();
hero->name = "robin";
std::string json = jai::to_json(*eng, hero, 0);
auto back = jai::from_json<std::shared_ptr<Actor>>(*eng, json);
std::cout << json << "\n";
std::cout << back->name << " has " << std::dynamic_pointer_cast<Archer>(back)->arrows << " arrows\n";{"value0": {"$id": 1,"$type": "Archer","$val": {"name": "robin","arrows": 12}}}
robin has 12 arrowsThe record, its loads and the property a binding keeps out of it: a class you cannot edit. Four ways a class serializes sets the intrusive and non-intrusive routes side by side, and Serialization covers versioning, hooks, $ref aliasing and custom archives.
Hot reload with state intact
A script class can derive a bound C++ class with no extra registration; super(...) calls the bound constructor. The C++ half of such an object is the same live object across reloads:
class SmartSpawner : Spawner {
int aggression = 1;
SmartSpawner() : super() {}
}
auto smart = SmartSpawner();
smart.level = 5;
smart.aggression = 2;
smart.spawn_wave();Later in the session, another execute() redefines the class while smart is alive:
class SmartSpawner : Spawner {
int aggression = 1;
int burst = 4; // new field takes its initializer
SmartSpawner() : super() {}
}
print("reloaded: level={} aggression={} burst={} waves={}",
smart.level, smart.aggression, smart.burst, smart.total_waves());reloaded: level=5 aggression=2 burst=4 waves=1Script fields kept their values, the new field took its initializer, and the C++ base, private total_waves_ included, was neither destroyed nor rebuilt. Hot Reload has the migration rules.
The binder chain, verb by verb
A registrar drives a dynamic_binder<T>; drive one directly for a type that is not a property_owner:
struct Vec2 {
double x = 0, y = 0;
Vec2() = default;
Vec2(double x, double y) : x(x), y(y) {}
double length() const { return std::sqrt(x * x + y * y); }
Vec2& scale(double k) { x *= k; y *= k; return *this; }
Vec2& translate(double dx, double dy) { x += dx; y += dy; return *this; }
Vec2 operator+(const Vec2& o) const { return {x + o.x, y + o.y}; }
bool operator==(const Vec2& o) const { return x == o.x && y == o.y; }
};
jai::dynamic_binder<Vec2>(*eng, "Vec2")
.constructor<>()
.constructor<double, double>() // overloaded ctors resolve by arity + type
.method("length", &Vec2::length)
.method("scale", &Vec2::scale) // Vec2& scale(double) -> chainable
.method("translate", &Vec2::translate)
.method("+", &Vec2::operator+) // operators are methods named by token
.method("==", &Vec2::operator==)
.static_method("axes", []() { return 2; })
.static_method("dot", [](const Vec2& a, const Vec2& b) { return a.x * b.x + a.y * b.y; })
.property("x", &Vec2::x)
.property("y", &Vec2::y);var v = Vec2(3.0, 4.0);
print("length {}", v.length());
var a = Vec2(1.0, 2.0) + Vec2(3.0, 4.0);
print("sum ({}, {})", a.x, a.y);
print("chain {}", Vec2(1.0, 1.0).scale(3.0).translate(1.0, 0.0).x);
v.x = 30.0; // property writes hit the C++ member
print("after write {}", v.length());
print("eq {}", Vec2(1.0, 2.0) == Vec2(1.0, 2.0));
print("neq {}", Vec2(1.0, 2.0) != Vec2(9.0, 2.0)); // != falls back to negated ==
print("axes {}", Vec2::axes());
print("dot {}", Vec2::dot(Vec2(1.0, 2.0), Vec2(3.0, 4.0)));length 5.0
sum (4.0, 6.0)
chain 4.0
after write 30.265491900843113
eq true
neq true
axes 2
dot 11.0A method that returns T& hands script the object it refers to, and the receiver is kept alive across a chain, so Vec2(1.0, 1.0).scale(3.0).translate(1.0, 0.0).x is safe on temporaries. != worked without being bound because == supplies it.
Every verb with its rules: binder verbs. The ones most chains use:
| Verb | What it binds |
|---|---|
.constructor<Args...>() | A constructor overload, resolved by arity and C++ parameter types. |
.method(name, &T::fn), .method(name, lambda) | A member function, or a lambda whose first parameter is T& or const T&. |
.method(name, fn, option), .static_method(name, fn, option) | The trailing option: a privilege the call requires, jai::nodiscard (a result the script must use) and jai::serial (kept out of parallel bodies), combined with |: jai::nodiscard | jai::serial | jai::filesystem. add_function and add_variadic_function take the same slot. |
.property(name, &T::member) | Field access, and a key of the class's archive record when it has no route of its own. |
.transient_property(name, &T::member) | Field access its archive record leaves out. |
.base_class<Base>() | An inheritance edge. |
.build() | Optional: commits the binding at that point and throws what fails. A chain commits when its statement ends, and a binder still in scope when the first script loads is committed then; a failure in either is raised by the next load. |
Parameters. These hold for methods, static methods, constructors and add_function alike:
- A non-const
T¶meter writes the callee's final value back into the script argument (Host API):gate.add(total, 5)withvoid add(int64_t& target, int64_t by)leaves the sum intotal. - A
charparameter (by value orconst char&), and a store to acharproperty, take a char or a one-character string. Any other length raisesCannot convert 'string' to 'char': a char takes a string of exactly one character.
Operators are methods named by token, following the script rules of the operators chapter. A type can start as a script class and move to C++ without its call sites changing.
The tokens, the operators one binding supplies, ++ and --, compound and reflected forms, overload choice and the no-match error: bound operators.
Serial-only constructors
A parallel_for body may construct bound classes and script classes over them (parallel loops); the constructor and destructor then run on a worker thread. That is safe for a constructor that only builds its own members. One that touches state other objects share (a registry, a cache, a service lookup, a signal hub) is marked .serial_only_constructor(). A body that constructs the class, or a script class over it, is refused when the statement is reached, before any element runs.
struct Sprite {
static std::vector<Sprite*>& registry() { static std::vector<Sprite*> all; return all; }
double alpha = 1.0;
Sprite() { registry().push_back(this); } // every sprite joins the renderer's list
~Sprite() { std::erase(registry(), this); }
};
jai::dynamic_binder<Sprite>(*eng, "Sprite")
.constructor<>()
.serial_only_constructor()
.property("alpha", &Sprite::alpha);class Glowing : Sprite { float glow = 0.5; Glowing() : super() {} }
array<int> lights = [1, 2, 3];
try { parallel_for (auto& n : lights) { Sprite s; n = 0; } } catch (e) { print(e); }
try { parallel_for (auto& n : lights) { Glowing g; n = 0; } } catch (e) { print(e); }
Glowing one; // a serial construction is unaffected
print("{} {}", one.alpha, one.glow);parallel_for: C++ class 'Sprite' is marked serial_only_constructor() and cannot be constructed in a parallel body at 3:48
parallel_for: class 'Glowing' has a C++ base 'Sprite' marked serial_only_constructor() and cannot be constructed in a parallel body at 4:49
1.0 0.5The mark covers the destructor too. An object of a marked class that loses its last reference on a worker is released on the calling thread once the workers are done, in the order of the elements that dropped it.
Calls from parallel bodies
Bound functions, static methods and methods run on the worker thread unless registered with the jai::serial option. Leaving a call unmarked declares it safe to call from any worker at once, so mark every call that touches shared state (a manager, a registry, the scene graph beyond the object, a connection, a log):
| Registration | Covers | Refused |
|---|---|---|
.method("name", f, jai::serial) | On a non-const overload, the method's non-const overloads; a const getter of the same name stays parallel. On a const overload (a const member function, a const T&-self lambda), every overload of the name. | When called, since the receiver's class is known only then. |
.static_method("name", f, jai::serial) | A static method. | When the statement is reached. |
add_function("name", f, jai::serial), add_variadic_function | A free function, under the name it registers ("game::save"). |
The receiver follows the C++ signature. A non-const member function, a T&-self lambda or a property store runs only on an object the worker owns: the element it is running, a parallel_reduce accumulator, or an object it constructed or copied. A const member function also runs on an object the worker did not make, such as one a C++ function returns or one captured from the enclosing scope. A non-const T& parameter cannot take enclosing state. The library's own builtins follow the same rule; the guide lists which are serial.
struct Meter {
double level = 0.0;
void add(double amount) { level += amount; } // non-const: only an object the worker made
double reading() const { return level; } // const: any object
void publish() const { audio().play("tick"); } // the audio manager: serial
};
jai::dynamic_binder<Meter>(*eng, "Meter")
.constructor<>()
.method("add", &Meter::add)
.method("reading", &Meter::reading)
.method("publish", &Meter::publish, jai::serial); // const, but it reaches a shared manager
eng->add_function("master", []() { return master_meter; }); // the program's one Meter (level 100)
eng->add_function("log_line", [](const std::string& line) { logger().write(line); }, jai::serial);array<float> xs = [1.0, 2.0, 3.0];
parallel_for (auto& x : xs) {
Meter m;
m.add(x * 10.0); // the worker's own object
x = m.reading() + master().reading(); // a const method on the shared one
}
print("{}", to_json(xs));
try { parallel_for (auto& x : xs) { var shared = master(); shared.add(1.0); } } catch (e) { print(e); }
try { parallel_for (auto& x : xs) { Meter m; m.publish(); } } catch (e) { print(e); }
try { parallel_for (auto& x : xs) { log_line("x"); } } catch (e) { print(e); }[110.0,120.0,130.0]
C++ method 'add' of 'Meter' changes an object this parallel body did not make (only its const methods may run on it)
C++ method 'publish' of 'Meter' is marked jai::serial and cannot be called in a parallel body
parallel_for: host function 'log_line' is marked jai::serial and cannot be called in a parallel body at 10:37Signals: one view, every signature
A JAI_SIGNAL_PROPERTY is exposed by auto_bind; a plain jai::signal<Sig> or signal_emitter<Sig> member by one .property("name", &T::member) line. Script sees the same three verbs either way:
s.onWave.connect("hud", [=](int wave) -> void { print("wave {}", wave); }); // named function receiver
s.onWave.connect("logger", "print(\"wave logged\");"); // named script-text receiver
s.onWave.connected("hud"); // true
s.onWave.disconnect("hud");- One generic
Signalclass serves every signature. Reading a signal member hands script a view over three type-erased operations (connect, disconnect, connected) built where the emitter's type is known. - The lookup walks the inheritance chain, so a signal declared on a base connects through a derived registration, in either registration order.
- Script-text receivers work however the object was made (in script, in C++, or by a load): connecting gives the emitter the binding engine.
- A view does not own the signal. It is a member read of a live object and dangles past its owner.
- A view does not set
parameter_names. A text receiver sees the names the owner set in C++ (none by default). A function receiver takes the emit arguments positionally and needs no names. jai::bind_signal_type<Sig>registers a named class for one signature, which takes precedence over the generic view (when to use it).
Firing signals from C++, receiver lifetime and observable properties are in Signals & Observable Properties.
Inheritance and polymorphism
Declare C++ inheritance once, as property_owner<Derived, Base>, and the same edge serves script dispatch, upcasts and polymorphic saves. A class that is not a property_owner declares the edge with .base_class<Base>().
class Pickup : public jai::property_owner<Pickup> {
public:
JAI_PROPERTY((std::string), name, "pickup");
};
class HealthPickup : public jai::property_owner<HealthPickup, Pickup> {
public:
JAI_PROPERTY((int64_t), healing, 25);
};
// Registration order does not matter — either class may register first, or the base
// not at all: a derived registration materializes an unregistered property_owner base.
jai::dynamic_binder<Pickup>(*eng, "Pickup")
.auto_bind()
.method("kind", [](Pickup& p) { return std::string("pickup:") + p.name.get(); });
jai::dynamic_binder<HealthPickup>(*eng, "HealthPickup")
.auto_bind(); // derives the Pickup base edge: healing and the inherited name both bindA save through the base pointer records the concrete $type, and a load through the base pointer returns the derived object, with no factory map and no slicing:
std::shared_ptr<Pickup> loot = std::make_shared<HealthPickup>();
loot->name = std::string("elixir");
std::static_pointer_cast<HealthPickup>(loot)->healing = 40;
auto lootJson = jai::to_json(*eng, loot); // saved through the BASE pointer
std::cout << lootJson << "\n";
auto back = jai::from_json<std::shared_ptr<Pickup>>(*eng, lootJson);
auto* hp = dynamic_cast<HealthPickup*>(back.get()); // != nullptr, healing == 40{
"value0": {
"$id": 1,
"$type": "HealthPickup",
"$val": {
"name": "elixir",
"healing": 40
}
}
}In script, a C++ function taking shared_ptr<Pickup> accepts a HealthPickup, and base methods are callable on derived instances:
eng->add_function("describe", [](std::shared_ptr<Pickup> p) { return p->name.get(); });var h = HealthPickup();
h.name = "elixir";
h.healing = 40;
print("upcast: {} heals {}", describe(h), h.healing);
print("inherited method: {}", h.kind());upcast: elixir heals 40
inherited method: pickup:elixir- Methods,
JAI_PROPERTYfields and signals of a base resolve through a derived registration without being bound again, as serialization saves and loads every level of the chain. - A derived registration registers an unregistered
property_ownerbase on the spot (itsjai_auto_bindmethods, schema properties and signals, up the whole chain); the adoption rule above applies. A base whose name is already taken by another type waits for its own registration.
Plain structs and templates: static binders
A plain struct or a class template can carry its binding as a declaration beside the type, at global scope: JAI_STATIC_BINDER(Type, "Name", ...) for a type, JAI_TEMPLATE_BINDER(Template, "Name", ...) for every instantiation of a one-parameter template, and JAI_TEMPLATE_BINDER_2 for two. The chain takes .property and .method, written against T in a template binder, and the type gets a default constructor. Nothing reaches an engine until the host names the instantiations it wants. For constructor overloads, operators, base classes or signals use dynamic_binder.
#include <jaiscript/core/static_binder.hpp>
#include <jaiscript/core/static_binder_impl.hpp>
#include <jaiscript/core/template_binder.hpp>
struct Point { double x = 0, y = 0; double sum() const { return x + y; } };
JAI_STATIC_BINDER(Point, "Point",
.property("x", &Point::x)
.property("y", &Point::y)
.method("sum", &Point::sum)
);
template <typename T> struct Box { T value{}; T get() const { return value; } };
JAI_TEMPLATE_BINDER(Box, "Box",
.property("value", &Box<T>::value)
.method("get", &Box<T>::get)
);
eng->bind_static_types<Point, Box<int64_t>, Box<double>>(); // or bind_static_type<T>() one at a timePoint p;
p.x = 1.5;
p.y = 2.0;
print("sum {}", p.sum());
Box<int> b;
b.value = 4;
Box<float> f;
f.value = 2.5;
Box<int32> w; // Box<int32_t>, bound with Box<int64_t>
print("{} {} {}", b.get(), f.get(), w.get());
print(type_of(b), " ", type_of(f), " ", type_of(w));sum 3.5
4 2.5 0
Box<int> Box<float> Box<int32>An instantiation is named the way a script spells it:
int64_t is int and std::vector<E> is array<E>; every spelling: template instantiation names.
- Binding one width of a numeric argument binds its 32/64-bit sibling too:
Box<int32>withBox<int>,Box<float32>withBox<float>. - Two C++ types the script spells alike (
intandlongon MSVC) cannot both bind: the secondbind_static_typethrows. - A
jai::registrarfor a template instantiation needs an explicit name; without one,jai::registrar<Point<int>, Ctx>is a compile error that points to the named constructor. A template-syntax name such as"Point<int>"registers the template name with the parser.Pointstays an ordinary identifier: onlyPoint<followed by a type reads as the template.
Atomics: one value that every engine shares
jai::stdlib::register_atomics(eng) (<jaiscript/stdlib/atomic.hpp>; register_all leaves it out) binds std::atomic<int64_t>, std::atomic<double> and std::atomic<bool> as atomic<int>, atomic<float> and atomic<bool>. The host owns the atomic and binds it into each engine with add_global_ref. Every engine then reads and writes the one C++ object from its own thread, each operation one atomic step: a counter, flag or gauge shared with no lock and no copy. An atomic is the one bound type built to be shared across threads; no binder flag makes another object so.
std::atomic<int64_t> page_views{0}; // the host's: it outlives both engines
auto front = jai::engine::make();
auto back = jai::engine::make();
for (jai::engine* eng : {front.get(), back.get()}) {
jai::stdlib::register_all(eng);
jai::stdlib::register_atomics(*eng);
eng->add_global_ref("page_views", page_views); // a reference: no engine copies it
}
front->execute("page_views += 1; ++page_views;");
back->execute(R"(
int before = page_views.fetch_add(10);
print("back saw {} and left {}", before, page_views.load());
)");
std::cout << "the host reads " << page_views.load() << "\n";back saw 2 and left 12
the host reads 12Every call and operator, the types it takes and what it yields: atomics.
a = a + 1is a load and then a store, two steps another engine can come between;a += 1,++aanda.fetch_add(1)are one. Two atomics written one after the other are two separate facts: state that must change together belongs in a transaction.- An int step keeps the language's overflow rule, where a C++
std::atomicwraps: at the largest int,a += 1raisesInteger overflow in '+='('++', and'+'forfetch_add) and leaves the value as it was. atomic<float>has no++or--andatomic<bool>no arithmetic:load_avg++raisesCannot apply '++' to atomic<float>.- An atomic does not copy:
auto c = page_views;,var v = page_views;and a by-value parameter raiseCannot copy C++ object of type 'atomic<int>'.atomic<int>& views = page_views;and anatomic<int>¶meter name it. - A host may bind one atomic under different names in different engines, and a script may construct a local one (
atomic<int> local(5);) that nothing else shares.
Limits
auto_bindexposes primitive, string and signal properties.JAI_PROPERTYfields of the int family,float/double,boolandstd::stringare script-visible automatically. Container- and class-typed properties still reflect and serialize, but script access needs an explicit.property()or.method()in the configure function.- Reading an observable property yields its wrapper. It unwraps in expressions and in
print; assign it to a typed local for the value itself. - A
static_methodcannot return a bound class, and astd::weak_ptr<T>cannot be returned to script (both above). - No bound object is shared across threads except an atomic. Use one engine per thread and pass values by copy.