Classes​

Script classes: fields with initializers (run in declaration order), constructors with overloading and : this(...) delegation, inheritance with super, statics, and hot reload with instance migration. Every method dispatches dynamically; redefining a base method requires the explicit override keyword. Remember from Value Semantics: instances are values: assignment copies; when you want sharing, hold a handle (shared_ptr<T>, which new T(…) spells for you).

Fields, constructors, implicit self​

jaiscript
class Creature {
    string name = "anonymous";
    int health = 100;
    int max_health = health;

    Creature(string n) { name = n; }

    void take_damage(int amount) {
        health -= amount;
        if (health <= 0) { print("{} died", name); }
    }

    auto describe() { return name + " (" + to_string(health) + "/" + to_string(max_health) + ")"; }
}
auto goblin = Creature("goblin");
goblin.take_damage(30);
print(goblin.describe());
goblin.take_damage(80);
goblin.describe();
output
goblin (70/100)
goblin died
== result: goblin (-10/100)

max_health = health works because field initializers run top-to-bottom in declaration order. Methods access fields directly (implicit self). Construction is a plain call: Creature("goblin") makes a value; new Creature("goblin") makes a shared_ptr handle instead (see Value Semantics for when you want which). private: / public: section labels work C++-style. See Access control below.

Constructor overloading and : this(...) delegation​

jaiscript
class Point {
    int x = 0;
    int y = 0;
    Point() : this(0, 0) {}
    Point(int v) : this(v, v) {}
    Point(int px, int py) { x = px; y = py; print("main ctor ({}, {})", px, py); }
}
auto a = Point();
auto b = Point(3);
auto c = Point(1, 2);
b.x + c.y;
output
main ctor (0, 0)
main ctor (3, 3)
main ctor (1, 2)
== result: 5

Choosing an overload​

Constructors and methods overload by parameter count and parameter types, and their parameters convert by the one table a free function's do. A call takes the overload whose parameters take its arguments best: an exact type first, then the int/float and bool-to-number conversions (3 picks add(int) over add(float), 2.5 picks add(float), true picks add(int)), then a var parameter, then the rows that take anything (a string parameter's, a bool parameter's, a to_int()-style method, a converting constructor), so a char reaches add(string) here. A string argument never selects a bool parameter among overloads of one argument count. The one overload an argument count admits binds and converts like a lone function (its parameter raises its own text when it cannot). When several fit the count and none takes the arguments, the call raises No overload of 'add' takes (array, int), naming the argument types; a literal the checker can see is a load error (no overload of 'add' matches this call). Free functions and namespace functions overload by parameter types and count by the same rules (see Functions).

jaiscript
class Meter {
    string last = "";
    void add(int v) { last = "int"; }
    void add(float v) { last = "float"; }
    void add(string v) { last = "string"; }
    void add(int a, int b) { last = "two ints"; }
    void add(float a, float b) { last = "two floats"; }
}
Meter m;
m.add(3); print(m.last);
m.add(2.5); print(m.last);
m.add("x"); print(m.last);
m.add(1, 2); print(m.last);
var c = 'c';
m.add(c); print(m.last);
var list = [1];
try { m.add(list, 2); } catch (e) { print("caught: ", e); }
output
int
float
string
two ints
string
caught: No overload of 'add' takes (array, int)

Converting constructors. A true one-parameter constructor makes the class a conversion target, and that conversion is one row of the one table. It runs for declarations, assignments, by-value parameters, returns and field stores alike:

jaiscript
class Money {
    int dollars = 0;
    int cents = 0;
    Money(int d, int c) { dollars = d; cents = c; }
    Money(int d) : this(d, 0) {}          // a true one-parameter constructor: converting
}
void pay(Money m) { print("paid {}.{}", m.dollars, m.cents); }
Money price = 5;                          // declaration, through Money(int)
pay(7);                                   // by-value parameter, the same table
pay(price);
output
paid 7.0
paid 5.0

Two deliberate C++ divergences keep this predictable. A constructor that reaches one parameter only through defaults (Money(float a, int _ignored = 0)) is not converting, so a defaulted sentinel is how you opt out, and there is no explicit keyword to remember. And conversion is always a single step: the argument must fit the constructor's parameter directly, never x.to_int() first and Money(int) second.

Aggregate initialization​

jaiscript
class Cell { int row; int col; }
class Move { Cell from; Cell to; int cost = 1; string note = "step"; }
Cell a{1, 2};                     // fields in declaration order
auto b = Cell(3, 4);              // parentheses work too, as in C++20
Move m{a, b};                     // cost and note keep their initializers
print("{} {} -> {} {}, cost {}, {}", m.from.row, m.from.col, m.to.row, m.to.col, m.cost, m.note);
Move{a, b, 5}.cost;
output
1 2 -> 3 4, cost 1, step
== result: 5

A class that declares no constructor is an aggregate, as in C++: T x{a, b}, T{a, b}, T x(a, b) and T(a, b) fill its instance fields in declaration order, base classes' fields first, each initializer converting through its field's typed slot like a field store. Fields past the last initializer run their own initializers, in order, so a later initializer reads the earlier fields; an initialized field's own initializer never runs. One argument of the class itself (or a subclass) copies it instead. More initializers than fields raise Too many initializers for 'Cell': it has 2 fields, got 3. A class is no aggregate when a class in its chain declares a constructor or is a C++ class, or an instance field is private: or a reference; those keep the constructor text No constructor of 'T' takes (int). Methods never disqualify a class (script methods dispatch dynamically without a virtual keyword). Nested braces (Move m{{1, 2}, {3, 4}}) are not part of it: spell each member's type, Move m{Cell{1, 2}, Cell{3, 4}}.

Constructor exits and destructors​

Object lifetime follows C++: a base is constructed before the derived class's fields, a constructor that names no super(...) default-constructs its base, and every instance, each copy included, runs its destructor once when it goes away.

A bare return; ends a constructor body early, and the call still yields the instance. return expr; in a constructor's own body is the parse error A constructor cannot return a value (a bare 'return;' ends it early and the call yields the instance); a lambda or nested function inside it returns freely. A throw leaves the constructor like any function and the assignment target keeps its old value. So does an error a field initializer or an initializer-list argument raises, at every level of the class chain: it leaves as raised, so a catch receives the value or the message itself. As in C++, the half-built object destroys only what finished constructing: its members die, then every base whose constructor completed runs its destructor, the last constructed first; the throwing class's own destructor body never runs. Once a delegating constructor's target finishes, the object counts as constructed, so a throw in the delegating body runs the class's destructor too.

Each constructor that runs is a call of its own, as in C++: a base's, a delegated-to one and the class's. Its parameters are bound once, and its initializer-list arguments and its body see them by name as bound (a defaulted parameter holds its default, a converted one its converted value), so what the initializer list stores to a parameter the body reads. Field initializers never see a constructor's parameters, as a C++ default member initializer does not: a name in one resolves to the class's members (inherited ones and this included), then to the enclosing and global scope. With int n = 40; and class C { int x = n; C(int n = 5) {} }, both C(7).x and C().x are 40; a name only a parameter has is an undeclared identifier there. It is a frame of a stack trace and of the debugger's stack, standing at the initializer-list argument or field initializer it is running, and it counts toward the call-depth cap.

~Name() { ... } declares a destructor. It runs when the object goes away: a value instance when the scope that holds it ends, a shared object when its last handle drops:

jaiscript
class Torch {
    string name;
    Torch(string n) {
        name = n;
        if (n == "") { return; }          // a bare return ends the constructor; the call yields the instance
        print("lit {}", name);
    }
    ~Torch() { print("out {}", name); }
}
function visit() {
    Torch t = Torch("hall");
    print("visiting");
}
visit();
{
    auto lamp = new Torch("lamp");
    auto other = lamp;                    // two handles, one object
    print("two handles");
}
Torch dark = Torch("");
print("dark is '{}'", dark.name);
output
lit hall
visiting
out hall
lit lamp
two handles
out lamp
dark is ''

Every instance runs its destructor once, copies included: Token b = a; makes a second object with its own destructor, and so does a container literal that names an existing instance ([a] holds a copy of a, as std::vector{a} does). A shared_ptr handle is the way to share one object, and it runs the destructor when the last handle drops:

jaiscript
class Token {
    string id;
    Token(string s) { id = s; }
    ~Token() { print("drop ", id); }
}
function scope() {
    Token a = Token("a");
    Token b = a;                 // a copy, with its own destructor
    b.id = "b";
    array<Token> list = [a];     // the literal copies a
    list[0].id = "in list";
    print("leaving");
}
scope();
output
leaving
drop in list
drop b
drop a

A constructor whose one parameter is the class itself (Token(const Token& o), or Token(Token& o)) is its copy constructor, and it builds every copy C++ copy-constructs: a declaration from an existing object (Token b = a;), a by-value argument, a returned global, member or parameter, a by-value range-for variable, a copy capture or an init capture of a variable, a container literal naming an object, push, and each element of a copied container. Elision stays where C++ guarantees it: a temporary that initializes a slot is that slot, and a function's own local it returns is the caller's object, so neither runs it. A class with a copy constructor has no move, so a returned parameter of it and a member read off a temporary of it are copied too, as is a field of it when its owner moves; a container moves its storage, so returning a by-value array<Token> parameter copies no element. Assignment never runs it: b = a; is the class's operator= or the implicit copy assignment. A copy constructor builds its object as any constructor does (an unnamed base default-constructed, the field initializers, then the body); a class that declares none copies its members, a member running its own class's copy constructor, and a base that declares one builds that base. A copy constructor that throws leaves no object behind, so no destructor runs for it, and the copies a container copy had already made die. A reload that adds or removes a copy constructor takes effect at the next copy.

jaiscript
class Token {
    string id;
    Token(string s) { id = s; }
    Token(const Token& o) { id = o.id + " copy"; print("copied {}", o.id); }
    ~Token() { print("drop {}", id); }
}
Token make(string s) { return Token(s); }
void use(Token t) { print("using {}", t.id); }
void scope() {
    Token a = make("a");          // a temporary initializes a: no copy
    Token b = a;                  // copy-constructed
    use(a);                       // copied into the parameter
    use(Token("t"));              // the temporary is the parameter
    b = a;                        // assignment: no copy constructor
    print("leaving");
}
scope();
output
copied a
copied a
using a copy
drop a copy
using t
drop t
leaving
drop a
drop a

Lifetimes are C++'s. A local dies at the end of the block that declares it, the last declared first; a loop body's locals die at the end of each iteration, and break, continue, return and a throw destroy every scope they leave, innermost first. Parameters die when the callee returns, after its locals, the last declared first. An argument written as a temporary (visit(Torch("hall"))) is the parameter itself, never a copy, as C++17 guarantees, whatever the call: a function, a method on any receiver, a constructor, new, or a super(...) or this(...) initializer. A converting constructor that makes a parameter, a declared variable or a returned value makes that object itself, and a temporary it converts from is the constructor's own parameter: visit(Token("t")) into visit(Torch t) with Torch(Token k) runs one Torch and one Token, and the Token dies when the constructor returns. A by-value range-for variable (for (Token t : list)) is a copy that lives one iteration. The range a range-for iterates lives as C++'s hidden range variable does: a temporary range (for (Token t : make_list())) dies after the loop variable and the body's locals and before the locals declared ahead of the loop, whether the loop completes, breaks, returns, throws or its coroutine is dropped while suspended in it, and nested loops end innermost first. A temporary a member chain reads through (make_room().torch.light.level) lives until the chain's last read completes, so a getter or operator in the chain runs on a live object, and it dies after the values read from it; a getter's or a method's receiver dies when the call returns, once nothing else owns it. A member returned or passed straight off a call that yields a class by value (return make_room().torch;) moves out of the dying temporary, as C++ moves an expiring member, unless a copy constructor of its value would run; read through a handle (a call that yields a shared_ptr) it is copied, the handle's last owner or not, whenever the copy runs a destructor or a copy constructor. A temporary a by-value parameter was copied from dies once the parameters are bound. A const T& bound to a temporary keeps it for the reference's whole scope (Value Semantics). Reading a variable, a field or an element keeps no hidden copy: after shared_ptr<Token> t = box.token; t = null; the token dies when box.token lets it go. A temporary a statement makes and consumes (a call's result compared with null, an operator's intermediate result) is gone by the end of the statement, and one in a condition before the branch runs: if (make() != null) { ... } destroys the made object ahead of the body. A dying object runs its class's destructor body, then its members die, the last declared first, then its base's destructor runs and the base's members die; a class that declares no destructor still runs its base's, and a dying container destroys its elements last to first, as a C++ array does: an array from its last element to its first (clearing it, shrinking it with slice or dropping elements with filter does the same to the elements it drops), a map from its last key to its first, each entry's value before its key, as std::map does (when it dies, is cleared or an assignment replaces it). Program-scope variables die when the engine is destroyed, the last constructed first (namespace variables, class statics and a lambda's captures held at program scope among them), and an error a destructor raises there goes to the engine's script error handler instead of propagating. A reference among them is let go without being read through: no property getter runs while the engine is destroyed. So does an error a destructor raises while another error is unwinding (a throw or a run-time error leaving the destructor's scope), and the first error goes on to its handler: C++ would terminate there, and a script must not end its host.

Assignment into a variable, field, static, element or map value declared as a class runs the class's operator= when one of its overloads takes the right-hand value, as C++ does: x = y; is x.operator=(y) on x's object, self-assignment included, and an error the operator raises leaves the object as the operator left it. A declaration (Token x = y;), a parameter and a return are copy construction and never run it. With no overload that takes the value the assignment is C++'s implicit copy assignment: x = Token("b"); keeps x's object, so a reference or shared_ptr to it sees the new members, every member takes the source's (a value member is copied, a handle shares), and the temporary dies after. A source of another class the declaration admits (the declared class or a class derived from it) never slices: the object first loses the levels the source's class lacks (each lost level's destructor body runs, then that level's members die, as for a dying object), then takes the source's class in place and every member from the source. After Cat c = Cat(); c = Tiger();, c is still the same object and now a complete Tiger; a Tiger assigned a sibling Lion runs only the Tiger level's destruction (its Cat level stays) and becomes a Lion; a reference to a member the object lost raises Reference to a removed field. A compound assignment (x += y through operator+) assigns its result the same way, and so does a value the class converts from: x = Token("q"); into a Torch with Torch(Token k) makes a Torch from the token, copy-assigns it into x's object and destroys it. A var holding an object runs the class's operator= when one of its overloads takes the value and otherwise takes the new value, and a handle on the right rebinds a shared_ptr or a var (the rules are in Value Semantics). A temporary stored where nothing was (a new map key, a var) moves in with no copy; push copies its argument, and a temporary argument dies at the end of the statement.

Inheritance: super(...), super::, explicit override​

jaiscript
class Cat {
    int a = 0;
    Cat(int v) { a = v; }
    auto speak() { return "meow " + to_string(a); }
}
class Tiger : Cat {
    Tiger() : super(5) {}
    auto speak() override {
        return "ROAR! (base said: " + super::speak() + ")";
    }
}
auto t = Tiger();
print(t.speak());
Cat c = t;
type_of(c);
output
ROAR! (base said: meow 5)
== result: Tiger

Redefining a base method without override is an error: the checker reports it at load, and in a [[warn_mode]] region the class declaration raises it when it runs (Method 'speak' redefines 'Cat::speak': mark it 'override'). An override with no base method to redefine is the same kind of error (Method 'speak' marked as override but no base class method found), and so is a base class nothing defines (Base class 'Feline' not found). The checker sees them before anything runs, so a module or an import group with one applies none of its declarations.

jaiscript
class Cat { auto speak() { return "meow"; } }
class Tiger : Cat { auto speak() { return "ROAR"; } }   // no override
Tiger().speak();
output
try.jai:2:26: error: method 'speak' redefines 'Cat::speak': mark it 'override'

Assigning a derived instance to a base-typed variable is allowed; the reverse (parent to child), siblings, and structurally-identical unrelated classes are rejected.

The base is constructed first, as in C++. A constructor that names no : super(...), and a class that declares no constructor at all, default-construct the base: its default constructor runs (a C++ base class is created) before the derived class's field initializers and body, so they can read the base's state. A base whose constructors all need arguments has no default constructor, and constructing the derived class raises Base 'Cat' has no default constructor; call super(...) (the strict checker reports it at load when it sees both classes).

A class may name several bases. They construct in declaration order, each with its own bases first, then the class's members and its body run; destruction runs the same steps in reverse. An initializer list names a base to pass it arguments, as in C++ (Car() : Engine(800), Radio(98.5) {}); the order it names them in changes nothing, super(...) is the first base, and an unnamed base is default-constructed. A later base with no default constructor must be named (Class 'Car' must construct base 'Radio': it has no default constructor); an initializer list may name only bases, each once (Class 'Car' has no base 'Wheel' to construct, Class 'Car' constructs base 'Engine' twice). The checker reports all three at load. A class two bases both reach is a diamond, which JaiScript does not support: the checker reports it at load, and in a [[warn_mode]] region the declaration raises Class 'D' inherits 'A' through both 'B' and 'C' (diamond inheritance is not supported).

jaiscript
class Engine { int rpm = 0; Engine(int r) { rpm = r; print("Engine " + to_string(r)); } ~Engine() { print("~Engine"); } }
class Radio { Radio() { print("Radio"); } ~Radio() { print("~Radio"); } }
class Car : Engine, Radio { Car() : Engine(800) { print("Car"); } ~Car() { print("~Car"); } }
function drive() -> void { Car c; }
drive();
output
Engine 800
Radio
Car
~Car
~Radio
~Engine
jaiscript
class Engine { int rpm = 0; Engine() { rpm = 800; } }
class Car : Engine { int idle = this.rpm; }      // no constructor: Engine() still runs first
class Truck : Engine { int load = 0; Truck() { load = rpm * 2; } }
Car().idle + Truck().load;
output
== result: 2400

A constructor or destructor calls its own class's overrides, as in C++. While a class's constructor runs (its initializer-list arguments, its field initializers and its body) or its destructor body runs, the object is that class: a virtual call on it, bare, through this or through any other handle to the object, runs that class's version, and super:: names that class's base. Once its constructor returns, the next derived class's overrides apply; destruction runs the same steps in reverse. A copy taken there is a copy of that class's object.

jaiscript
class Shape {
    Shape() { print("Shape(): " + name()); }
    ~Shape() { print("~Shape(): " + name()); }
    string name() { return "shape"; }
}
class Circle : Shape {
    Circle() { print("Circle(): " + name()); }
    ~Circle() { print("~Circle(): " + name()); }
    string name() override { return "circle"; }
}
function draw() -> void {
    Circle c;
    print("constructed: " + c.name());
}
draw();
output
Shape(): shape
Circle(): circle
constructed: circle
~Circle(): circle
~Shape(): shape

A call there to a method only a derived class declares therefore reaches nothing. The strict checker reports it at load. In a [[warn_mode]] region, the call raises Cannot call 'layout' while 'Widget' is under construction: only a class derived from it declares it, or under destruction from a destructor. Name lookup is unchanged: a name the class does not declare goes on to the program-scope functions, so a global layout would answer the call. A lambda the constructor creates may run after it returns, so the checker does not flag calls inside the lambda.

jaiscript
class Widget { Widget() { layout(); } }
class Button : Widget { void layout() { } }
Button b;
output
try.jai:1:27: error: Cannot call 'layout' while 'Widget' is under construction: only a class derived from it declares it

A subtype member through a base-typed receiver​

Once a variable is declared Cat, the checker holds you to Cat's members, exactly like C++. The object inside may well be a Tiger, but reaching for a member only Tiger declares is a load error:

jaiscript
class Cat { int a = 0; }
class Tiger : Cat { int stripes = 12; }
Cat c = Tiger();
print(c.stripes);
output
jaiscript: static check failed:
<script>:4:9: error: no member 'stripes' in class 'Cat' (declared at 1:7)
    print(c.stripes);
            ^

The spelled fix is cast<Tiger&>(c): a reference to the same object, so reads and writes through it land in c. (A value-form cast<Tiger>(c) would hand you a copy. See cast in The Type Ladder.) A var receiver is the other way out: it is unchecked, so the member resolves at run time. Either way, a wrong guess about the dynamic class is a catchable error, never a silent read:

jaiscript
class Cat { int a = 0; }
class Tiger : Cat { int stripes = 12; }
Cat c = Tiger();
print(cast<Tiger&>(c).stripes);     // a reference to the same object: reads go through
cast<Tiger&>(c).stripes = 5;        // and so do writes
print(cast<Tiger&>(c).stripes);
var anything = c;                   // a var receiver is unchecked: members resolve at run time
print(anything.stripes);
try { cast<Tiger&>(Cat()).stripes; } catch (e) { print("caught: {}", e); }
output
12
5
5
caught: Cannot cast 'Cat' to 'Tiger&'

A base-typed slot keeps the whole object, a deliberate difference from C++, which slices a Tiger copied into a Cat down to its Cat part. In JaiScript Cat c = Tiger(); holds a complete Tiger: type_of(c) is Tiger, its overrides run, and cast<Tiger&>(c) reaches its fields. The same holds for every copy into a base-typed place (another Cat variable, a Cat parameter, an element of an array<Cat>, cast<Cat>(tiger)): the copy is a copy of the whole Tiger, and an assignment c = tiger; into a Cat already holding an object turns that object into a Tiger. The slot's type decides what the checker lets you name, never what the value is.

Deriving a C++ class, and extend​

A class may derive a class the host bound from C++. Constructing it (Voidkin()) constructs the C++ object underneath; an object the host already owns is given the class with extend(object, Class), in place: the handle you pass, and every other handle to that object, is the new class from then on. extend re-classes pure script instances the same way, which is what lets the sample below run on its own. Swap the script Creature for one the host bound from C++ and the remaining lines do not change:

jaiscript
class Creature {
    string name = "grunt";
    string speak() { return "..."; }
}
class Voidkin : Creature {
    int chomp = 3;
    string speak() override { return "voidkin"; }
}
auto raw = new Creature();          // a handle the "host" already holds
auto same = raw;                    // another handle to the same object
extend(raw, Voidkin);               // re-class it in place
raw.chomp = 17;
print(raw.speak(), " ", raw.chomp);
print(same.speak(), " ", same.chomp, " ", same.name);
output
voidkin 17
voidkin 17 grunt

extend is a no-op for the class the object already is and re-derives for another class that derives the object's C++ class (fields both classes declare keep their values; the new class's own fields take their initializers). Its result names the object you passed, as C++'s T& extend(T&, Class) would: extend(v, B).speak() reaches v, var& w = extend(v, B) aliases it, and var w = extend(v, B) is a copy of it (a handle's copy shares the object, as copying a shared_ptr does). Overrides of C++ methods need override like any other; bare members and inherited C++ methods resolve inside methods as usual. When the object arrives from the host rather than from script (a raw the engine injected before the file ran), tell the checker it exists with extern raw; at the top of the file, otherwise the name is an undeclared-identifier load error (see the escape hatches in Chapter 13). The host side, ownership, and serialization of these hybrids are in Script-Extensible Objects.

nocopy classes​

nocopy class Ledger { ... } refuses by-value copies, like a deleted C++ copy constructor: auto copy = ledger; and a by-value parameter both raise an error naming the class (a try around the copy catches it), while auto& / var& and Ledger& parameters bind as usual:

jaiscript
nocopy class Ledger { int n = 1; }
auto l = Ledger();
auto copy = l;
output
== error: Cannot copy an instance of nocopy class 'Ledger': hold it by reference (auto&/var&) or share it (shared_ptr)

nocopy is inherited: every class derived from a nocopy class is nocopy, and so is a script class over a C++ base that has no copy constructor. There is no opt-out further down the chain.

Typed fields are enforced​

Fields follow the same type ladder as variables: explicit types enforce and convert through the one conversion table; var fields stay dynamic; auto fields lock on their initializer. A store the checker cannot see through (the value came out of a var) is checked when it lands, and is catchable there:

jaiscript
class Stats {
    int hp = 10;
    var anything = 0;
}
auto s = Stats();
s.hp = 3.9;
print("hp = {} ({})", s.hp, type_of(s.hp));
s.anything = "free";
print("anything = {}", s.anything);
var bad = "not a number";
try {
    s.hp = bad;
} catch (e) {
    print("caught: {}", e);
}
s.hp;
output
hp = 3 (int)
anything = free
caught: Cannot assign string to field 'hp' of Stats (declared int)
== result: 3

Write the literal directly (s.hp = "not a number";) and it is a load error instead: error: cannot assign 'string' to field 'hp' declared 'int' (declared at 2:9), and the try never runs. Enforcement covers direct assigns, implicit-this assigns in methods/ctors, compound assigns, inherited fields, the C++ set_field API, and JSON round-trips.

Access control: public: / private:​

Section labels control access, and there are two levels. Members start public: everyone reaches them. A member after a private: label is reachable from the class that declares it and from every class derived from it, by a bare name in a method, through this., and through any object of that class family. protected: is accepted as a synonym of private:, so C++ habits compile and read the same. Code outside the family (a free function, top-level code, an unrelated class) gets Cannot access private member 'x' of class 'C' for every read, write, call, compound assignment and ++/--:

jaiscript
class Chest {
private:
    int gold = 250;
public:
    int looted = 0;
    auto peek() { return gold; }              // the class's own code sees private members
}
class Vault : Chest {
    int double_gold() { return gold * 2; }    // so does every class derived from it
}
auto v = Vault();
print("peek() = {}, double_gold() = {}", v.peek(), v.double_gold());
var anyone = v;
try {
    anyone.gold;
} catch (e) {
    print("caught: {}", e);
}
output
peek() = 250, double_gold() = 500
caught: Cannot access private member 'gold' of class 'Chest'

A var receiver is checked when the access runs, as above. When the receiver's class is known at load, the strict checker reports the same text before anything runs (in a [[warn_mode]] region it is a warning and the run reaches the raise):

jaiscript
class Chest {
private:
    int gold = 250;
}
auto c = Chest();
print(c.gold);
output
<script>:6:9: error: Cannot access private member 'gold' of class 'Chest'
    print(c.gold);
            ^

Constructors follow the same rule. A constructor declared after private: (or protected:) is callable from its own class (a static factory, a method) and from every class derived from it (super(...), a derived class's implicit base construction). Any other construction raises Cannot access private constructor of class 'C', whatever the spelling: Name(...), new, a declaration, an implicit conversion. Reflection is the deliberate exception: reflect::construct reaches a private constructor, as reflection reaches every private member (Reflection). Access is checked on the overload that overload resolution picks, so a class may keep some constructors public. The strict checker reports a construction it resolves at load, with the same text. A load is different: from_json and from_binary reconstitute the state that was saved, and access rules never block them (Loading saved objects). A binary layout's read runs a private default constructor the same way:

jaiscript
class Ticket {
private:
    Ticket(int n) { number = n; }
public:
    int number = 0;
    static Ticket issue(int n) { return Ticket(n); }   // the class's own code constructs
}
class VipTicket : Ticket {
    VipTicket() : super(1) {}                           // so does a derived class
}
print("issued {}, vip {}", Ticket::issue(7).number, VipTicket().number);
print("through reflection: {}", reflect::construct("Ticket", 3).number);
output
issued 7, vip 1
through reflection: 3

Loading saved objects​

from_json and from_binary rebuild an object in one fixed order, on both backends and in both formats, so saving what a load restored writes the same bytes:

  1. Construct. A class with a zero-argument constructor runs it, whatever its access, initializer list included (for a class deriving a C++ class, that builds the C++ base). A class whose constructors all take arguments runs none: its field initializers run instead.
  2. Restore the fields in declaration order, base classes first, each looked up by name in the saved object. A key the class does not declare is ignored, a field the save lacks keeps what the constructor gave it, and a nested object is built when its field is reached. A saved value replaces what the constructor put there: a container the constructor filled ends equal to the saved one, and a handle field ends as the saved object, aliasing kept.
  3. Notify nothing. A field's storage is written directly, past any _get_x / _set_x the class declares (a save reads the storage too; an accessor with no field holds no state and is not saved). A C++ property is written through its binding with its observers quiet: no on_change, on_x_change or script signal receiver fires while a load is in progress. What a host setter's own code does is the host's: a signal it emits still reaches its C++ receivers, so a host type that must stay quiet through a load asks engine::loading(), or writes an observable property with set_silent.
  4. Then post_load. Once the whole load is done, each loaded object that declares void post_load(int version) runs it once, children before parents (inside a cycle, in the order the load completed them). A hook's error fails the load.
  5. A value that no longer fits its field (the class changed since the save) converts where = would convert it; otherwise the field keeps the constructor's value. Both are recorded as hot reload records a retype: with hot_reload_reporting(true) the host reads one row per field from hot_reload_reports().

Accessors: _get_x / _set_x​

Every field comes with synthesized accessors, which is why obj.x just works. Declare your own _get_x() / _set_x(v) and they win: obj.x reads through the getter, obj.x = v writes through the setter. Inside the class a bare x is still the backing field, so the accessors can use it without recursing:

jaiscript
class Thermostat {
    int target = 20;
    int _get_target() { return target * 10; }       // obj.target reads through this
    void _set_target(int v) { target = v + 1; }     // obj.target = v writes through this
    auto backing() { return target; }               // a bare name inside the class is the field
}
auto t = Thermostat();
print("t.target = {}", t.target);
t.target = 5;
print("field = {}, t.target = {}", t.backing(), t.target);
output
t.target = 200
field = 6, t.target = 60

The checker types obj.x from the accessor when the declaration is unambiguous: one parameterless getter with a declared (non-var) return type gives reads that type, one single-parameter by-value setter gives writes theirs, so t.target = "warm"; is the load error cannot assign 'string' to property 'target' declared 'int', and char c = t.target; fails the same way. Anything murkier (two overloads, a var/auto getter, a getter with parameters, a reference or untyped setter parameter, an accessor on a class the engine registered from C++) leaves the member unchecked and the run-time conversion does the work. A property the host binds with dynamic_binder arrives in script as an accessor pair of this kind.

Reference returns and keyword member names​

A method declared with a reference return type (int& slot(int i), Box& pick(), int& operator[](int i)) returns the storage itself, so its call is an assignment target: =, every compound assignment and ++/-- store through it, as in C++. A call that returns by value is not: s.get() = 4 raises Cannot assign to a call result: 's.get' returns by value. A free function returns by reference the same way. The returned expression may be any lvalue: a variable, a field, an element, a map's existing entry by any key (int& count(string k) { return counts[k]; }), a member reached through a computed index or key (return units[i + 1].hp;), or another call that returns T&. One that names no storage (a temporary, a by-value call's result, a key with no entry, since a read never inserts) raises Function with reference return type must return an lvalue reference when the return runs.

A reserved word is a name wherever a qualifier or a member declaration makes it one: after a dot (or ?., ->) and after :: (p.catch(f), obj.default, spider::catch(url), Mode::default, super::catch()), and as the declared name of a field, a method, a namespace member or an enum value. A host API or a data shape that uses one keeps its spelling: a bound C++ method named catch, to_json's keys and reflect::get(obj, "default") all see the word as written. Bare, it stays the keyword, so nothing that parsed before changes meaning: inside the class the member is this.default or this.catch(f), a statement catch(x); is a parse error, and so is a program-scope void catch() {}. this and super name the receiver and the base, so neither is declared as a member ('this' is the receiver and cannot be used as a member name). delete is not reserved at all, so it is a name everywhere.

jaiscript
class Slots {
    array<int> items = [0, 0, 0];
    int default = 7;                         // a keyword names a field
    int& slot(int i) { return items[i]; }    // a reference return: the call is an lvalue
    int catch(int i) { return items[i] + this.default; }   // and a method; inside, reach it through this
    void delete(int i) { items[i] = -1; }
    void clear_all() { delete(1); this.delete(2); }
}
namespace spider {
    string catch(string url) { return "fetched " + url; }
}
enum Mode { default, strict }
Slots s;
s.slot(1) = 5;
s.slot(2) += s.default;
s.delete(0);
print(to_json(s.items));
print(s.catch(1));
s.clear_all();
print(to_json(s));
print(spider::catch("a.com"));
print(Mode::default == Mode::strict);
output
[-1,5,7]
12
{"$type":"Slots","$val":{"items":[-1,-1,-1],"default":7}}
fetched a.com
false

Statics​

Static fields and methods belong to the class and are reached with Class::name. Instance constructors/methods see them directly:

jaiscript
class Counter {
    static int count = 0;
    static float PI = 3.14159;
    Counter() { count = count + 1; }
    static int twice_count() { return count * 2; }
}
auto a = Counter();
auto b = Counter();
auto c = Counter();
print("Counter::count = {}", Counter::count);
print("Counter::twice_count() = {}", Counter::twice_count());
Counter::PI;
output
Counter::count = 3
Counter::twice_count() = 6
== result: 3.14159

Class::name is the only spelling from outside. Counter.count is an error naming the right spelling ('Counter' is a class; use Counter::count), reported at load by the checker and raised the same way when checking is off. Static fields also enforce their declared type exactly like instance fields, through the same table: a literal mismatch is a load error, a var-sourced one raises where it lands, and the initializer converts on the way in:

jaiscript
class Registry {
    static int count = 0;
    static float ratio = 3;       // the initializer converts: holds 3.0
    static int last_id;           // no initializer: an int's default, 0
}
Registry::count = 2.9;            // the same table as an instance field: truncates
print("count = {} ({}), ratio = {}, last_id = {}", Registry::count, type_of(Registry::count), Registry::ratio, Registry::last_id);
var bad = "many";
try { Registry::count = bad; } catch (e) { print("caught: {}", e); }
output
count = 2 (int), ratio = 3.0, last_id = 0
caught: Cannot assign string to static 'count' of Registry (declared int)

(Registry::count = "many"; written as a literal is the load error cannot assign 'string' to field 'count' declared 'int'.) Statics are inherited names, as in C++: a class's code sees its own and its bases' statics bare, a base method running for a subclass instance reads its own class's statics, and Derived::count names the base's static unless Derived declares a member of that name:

jaiscript
class Base { static int count = 0; static int bump() { count += 1; return count; } }
class Derived : Base {
    auto bump_twice() { bump(); return bump(); }   // a base's statics by bare name
}
print(Derived().bump_twice());
Derived::count += 10;                              // the base's static, through the derived name
print(Base::count);
output
2
12

Class::name op= v and ++Class::name are lvalues; static auto infers from its initializer and static var stays dynamic. A namespace can override class statics of the same name. See Namespaces.

Name resolution in methods​

A bare identifier inside a method resolves in C++ unqualified-lookup order, identical on both backends and for reads AND writes:

  1. the active catch variable (innermost catch)
  2. locals and parameters of the current function, including range-for variables, auto& reference declarations, and constructor parameters (declaration-point scoped: a use before the local's declaration resolves outward)
  3. enclosing scopes / explicit lambda captures
  4. class members: instance fields (implicit this, including inherited), then instance methods (called; a bare method name without a call is an error, see below), then static fields and static methods (those of the class whose code runs and its bases, then the receiver's own class)
  5. globals (variables and free functions)

Three consequences are worth knowing.

First, members shadow globals: if a class has a field or method hp, a bare hp in its methods reads and writes the member even when a global hp exists, and defining a new global after the class never steals the class's bare-name member accesses. The shadow is by name, exactly like C++: a method flavor() hides a global flavor(int) entirely, so a bare flavor(42) in that class is an argument-count error rather than a silent call to the global. The escape hatch is also C++'s: ::flavor(42) resolves against the global scope only (locals, captures, and members are all skipped) and works everywhere an identifier does (::hp, ::hp = 5, ::hp += 1, ::grid[i], refill(::ammo)). The global stays reachable outside the class, and the checker's shadowing warnings (below) flag the declaration-side collisions.

Second, lambdas created inside a method see fields too, at the same member step of the lookup as the method body. What differs is when the field is read: a [this] lambda resolves the field when it is called; [] cannot reach instance state; [=] captures the field's value at creation; [&] shares the live field, so writes through the capture land in the instance:

jaiscript
class Tank {
    int hp = 7;
    auto probe() {
        auto bare = [this]() { return hp; };        // resolves hp when called, like the method body
        auto copy = [=]() { return hp; };        // captures the field's value now
        auto live = [&]() { hp += 1; return hp; }; // shares the live field
        print("bare {} copy {} live {}", bare(), copy(), live());
        hp = 100;
        print("after hp = 100: bare {} copy {} field {}", bare(), copy(), hp);
    }
}
Tank().probe();
output
bare 7 copy 7 live 8
after hp = 100: bare 100 copy 7 field 100

Third, [this] captures the instance itself, as C++ captures the this pointer: the lambda reads and writes the object it was made in, never a copy ([this]() { v = 9; } sets that instance's v).

The checker emits warning-severity shadowing diagnostics, never errors, because shadowing is legal: a local/parameter/range-for/catch variable that shadows a field of the enclosing class ("local 'hp' shadows field 'hp' of class 'Creature' (field declared at 4:2)"), a local that shadows an outer local, and a declaration that shadows a global the same function already read. Constructor parameters named after the field they initialize (C(int hp) { this.hp = hp; }) are the established idiom and stay silent.

Methods are called, not read​

player.heal without the parentheses reads too easily as a call, so it is an error, for your methods, a host class's methods and the builtin ones (arr.push, s.to_upper, co.next) alike. The strict checker reports it at load when it can see the receiver's class; otherwise it raises when it runs, and the text says what to write instead. To keep a method as a value, bind it: reflect::bind(player, "heal") holds the object and keeps it alive (see Reflection). A string, array or map is a value, so its methods go in a lambda. A field that holds a callable is a value and reads as one. Inside a method the bare name is the same thing: heal without the parentheses is an error (call it with heal() or bind it with reflect::bind(this, "heal")), and so is binding a reference to it (auto& r = heal), while a local or parameter of that name still wins, and a static method stays a function value.

jaiscript
class Player { int hp = 5; void heal() { hp += 1; } }
Player player;
var heal = reflect::bind(player, "heal");
heal();
string name = "kim";
var shout = []() { name.to_upper(); };
shout();
print("{} {}", player.hp, name);
var anyone = player;   // a var: the checker cannot see its class, so the run raises
try { var f = anyone.heal; } catch (e) { print("{}", e); }
output
6 KIM
'heal' is a method of Player; call it with anyone.heal() or bind it with reflect::bind(anyone, "heal")

Operators and callable objects​

A class declares operators as methods named operator plus the symbol; the full set and its rules are in Operators. operator() turns an instance into something you call like a function: f(x) on an instance runs its operator(), overloads by arity and argument types like any method, inherited like any method, and the instance keeps its fields between calls. A function slot takes a function or null, so to hand an instance to one, wrap it in a lambda.

jaiscript
class Counter {
    int n = 0;
    int operator()() { n += 1; return n; }
    int operator()(int step) { n += step; return n; }
}
function twice(function f) -> int { f(); return f(); }

Counter c;
c();
c(10);
print("{}", c.n);
print("{}", twice([]() { return c(); }));
output
11
13

Hot reload with permissive migration​

Re-executing a class declaration on the same engine reloads the class. Live instances migrate permissively: fields that still exist keep their current values (including still-default ones), brand-new fields get the new defaults, removed fields drop. A new field whose initializer reads the object (a method call, another field) runs it on each live instance with this bound, as construction would, so every instance starts from its own state. The two execute() calls below run on one engine (marked //=== execute):

jaiscript
class Config {
    auto version = 1;
    auto name = "default";
}
auto cfg = Config();
cfg.name = "production";
print("before reload: v{} {}", cfg.version, cfg.name);
//=== execute
class Config {
    auto version = 2;
    auto name = "default";
    auto retries = 3;
}
print("after reload:  v{} {} retries={}", cfg.version, cfg.name, cfg.retries);
output
before reload: v1 production
== result: null
after reload:  v1 production retries=3
== result: null

To try it from a terminal, start the REPL (jaiscript with no file) and paste the two halves as two entries. Each entry is one execute().

Note version stayed 1: migration preserves existing per-instance state, it does not re-run initializers for fields that already exist; only the new retries field picked up its default. Methods always come from the newest class definition, user accessors included: a _get_x / _set_x that a reload adds or removes applies to existing instances at their next obj.x. Hot reload even works while a function/class on the current call stack is redefined, and suspended method coroutines survive a field-changing reload of their class.