Classes​

A class is a value type with fields, constructors, methods and a destructor, declared as in C++. The short model:

  • Instances are values. Hero b = a; copies, and a class value slot never holds null. new Hero() and make_shared<Hero>() are one expression: a shared_ptr<Hero> handle whose copies share one object (Value Semantics).
  • Every method is virtual, and redefining a base method requires override.
  • Members start public. private: admits the declaring class and its subclasses.
  • Lifetimes are C++'s: fields initialize in declaration order, bases construct first, and every instance runs its destructor once.
  • Running a class declaration again reloads the class, and live instances keep their state.

Fields, constructors, methods​

jaiscript
class Money {
    int cents = 0;
    string unit = "$";
    string label = unit + "0";              // an initializer reads the fields above it
    Money() : this(0) {}                    // delegation
    Money(int c) { cents = c; }             // one true parameter: also a converting constructor
    Money(int dollars, int c) : this(dollars * 100 + c) {}
    void add(Money m) { cents += m.cents; } // a bare name in a method is the field
    string to_string() { return unit + ::to_string(cents / 100) + "." + ::to_string(cents % 100); }
}
Money price = 1250;                         // converts through Money(int)
price.add(5);                               // so does a by-value parameter
Money tip(2, 50);
auto held = new Money(99);                  // a shared_ptr<Money>
print(price.to_string(), " ", tip.to_string(), " ", held.cents, " ", Money().label);
print("total: " + price);                   // text joins through to_string()
output
$12.55 $2.50 99 $0
total: $12.55
  • Fields initialize top to bottom, and an initializer reads only what is already initialized (below). A typed field with no initializer starts at its type's default.
  • Constructors overload, and : this(...) delegates to another one. Money(5) makes a value and new Money(5) a handle; Money m(5);, Money m{5}; and Money m = Money(5); declare the same value.
  • Methods reach fields and other methods by bare name (this.cents also works).
  • A converting constructor is one with exactly one parameter. It converts its argument type into the class at a declaration, an assignment, a by-value parameter, a return and a field store. A constructor that reaches one parameter only through defaults (Money(float a, int unused = 0)) does not convert, which is the opt-out (there is no explicit). A conversion is one step: never to_int() first and Money(int) second.
  • A class's own to_string() is what print, format and + with a string use. Inside the class it hides the global function, so the sample calls ::to_string(cents); the bare call is the load error 'to_string' expects 0 argument(s), got 1: the method hides the function 'to_string' here; call that one as ::to_string(...).

Choosing an overload​

Constructors and methods overload by parameter count and types, as free functions do (Functions). A call takes the overload whose parameters fit its arguments best, in this order:

  1. An exact type.
  2. Int to float, bool to number, char to text.
  3. A var parameter.
  4. The looser conversions: truthiness into a bool, the other text kind into a string, a one-character string into a char, a to_int()- or to_string()-style method, a converting constructor. A string never selects a bool parameter.
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('c'); print(m.last);
m.add(1, 2); print(m.last);
var list = [1];
try { m.add(list, 2); } catch (e) { print("caught: ", e); }
output
int
float
string
two ints
caught: No overload of 'add' takes (array, int)

A mismatch the checker can see is the load error no overload of 'add' matches this call. When only one overload has the argument count, it binds like a lone function and its parameter raises its own text.

Aggregate initialization​

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 fields in declaration order, base classes' fields first.

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);
print(Move{a, b, 5}.cost);
output
1 2 -> 3 4, cost 1, step
5
  • Each initializer converts as a store to its field does, and fields past the last one run their own initializers.
  • One argument of the class itself (or a subclass) copies it instead. Too many 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 when an instance field is private or a reference: it answers No constructor of 'T' takes (int).
  • Nested braces are not supported: write Move m{Cell{1, 2}, Cell{3, 4}}, not Move m{{1, 2}, {3, 4}}.

A class value is never null​

A slot declared a class (a variable, parameter, field, static, return value or container element) always holds an object. Storing null into one is a load error, and so is a class that holds itself by value, directly, through a cycle of fields or through a base:

jaiscript
class Hero { int hp = 1; }
class Node { int v = 0; Node next; }
Hero h = null;
output
jaiscript: static check failed:
<script>:2:30: error: class 'Node' holds itself by value (Node.next): a class value is never null; make one link a 'shared_ptr<Node>' (an array or a map of them starts empty)
    class Node { int v = 0; Node next; }
                                 ^
<script>:3:6: error: cannot initialize 'h' declared 'Hero' with 'null': a 'Hero' is never null; declare 'shared_ptr<Hero>' (or var) for a slot that can be empty
    Hero h = null;
         ^

The slots that can be empty are shared_ptr<T>, weak_ptr<T>, var and function. A linked structure holds its links as handles (shared_ptr<Node> next; starts null) or in containers (array<Node> children; starts empty).

Construction, copies and destructors​

Object lifetime follows C++: a base is constructed before the derived class's fields, and every instance, each copy included, runs its destructor once.

~Name() { ... } declares a destructor. A value dies 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; 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");
}
print("done");
output
lit hall
visiting
out hall
lit lamp
two handles
out lamp
done

Constructors​

  • A bare return; ends a constructor early and the call still yields the instance. return expr; is the parse error A constructor cannot return a value (a bare 'return;' ends it early and the call yields the instance).
  • Field initializers read the fields above them, base fields, statics and the enclosing scope, never a constructor's parameters: with a global int n = 40;, class C { int x = n; C(int n = 5) {} } gives C(7).x == 40. Reading its own field or a later one is the load error field initializer of 'spd' reads 'slices' before its initialization: fields initialize in declaration order; declare 'slices' above 'spd'. A lambda the initializer creates may name any field, since it runs later.
  • A constructor that throws leaves the assignment target with its old value. The half-built object destroys only what finished: its members die, then each completed base runs its destructor, last constructed first; the throwing class's own destructor body does not run. After a delegated-to constructor finishes the object counts as constructed, so a throw in the delegating body runs the destructor too.

Copy constructors and assignment​

A constructor whose one parameter is the class itself (Token(const Token& o) or Token(Token& o)) is the copy constructor. It builds every copy C++ copy-constructs: a declaration from an existing object, a by-value argument or range-for variable, a returned global, member or parameter, a copy capture, a container literal that names an object, push, and each element of a copied container.

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
  • Elision is C++17's. A temporary that initializes a slot or a parameter is that object, and a function's own local it returns is the caller's object: neither copies.
  • A class with a copy constructor has no move, so a returned parameter of it and a member read off a temporary are copied. A class that declares none copies member by member. A copy constructor that throws leaves no object, so no destructor runs for it.
  • Assignment is not copy construction. x = y; into a slot declared a class runs the class's operator= when an overload takes the value, self-assignment included. Otherwise it is the implicit copy assignment: x keeps its object (references and handles to it see the new members) and every member takes the source's.
  • Assignment never slices. After Cat c = Cat(); c = Tiger();, c is the same object and a complete Tiger. An object assigned a sibling class first destroys the levels the source lacks (destructor body, then members), then takes the source's class and members; a reference to a field it lost raises Reference to a removed field.
  • How a var or a handle on the left of = behaves is in Value Semantics.

When an object dies​

objectdies
A localAt the end of its block, last declared first. A loop body's locals die each iteration; break, continue, return and a throw destroy every scope they leave, innermost first.
A parameterWhen the callee returns, after its locals.
A temporary range (for (Token t : make_list()))After the loop, however the loop ends, before the locals declared ahead of it.
A temporary a statement makes and consumesBy the end of the statement, after any member chain read through it. One in a condition dies before the branch runs.
A temporary bound to a const T&With the reference's scope.
A program-scope variable (namespace variables and statics included)When the engine is destroyed, last constructed first.
  • A dying object runs its destructor body, then its members die, last declared first, then its base's destructor and the base's members.
  • A dying container destroys its elements last to first: an array from its last element (clear, slice and filter do the same to what they drop), a map from its last key, each value before its key.
  • An error a destructor raises while another error is unwinding, or while the engine is destroyed, goes to the engine's script error handler instead of propagating, and the first error continues. C++ would terminate; a script must not end its host.

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

jaiscript
class Cat {
    int a = 0;
    Cat(int v) { a = v; print("Cat(): " + kind()); }
    string kind() { return "cat"; }
    string speak() { return "meow " + to_string(a); }
}
class Tiger : Cat {
    int stripes = 12;
    Tiger() : super(5) { print("Tiger(): " + kind()); }
    string kind() override { return "tiger"; }
    string speak() override { return "ROAR (base said: " + super::speak() + ")"; }
}
Cat c = Tiger();                       // a Cat slot holding a whole Tiger
print(c.speak(), " ", type_of(c));
// print(c.stripes);                   // load error: no member 'stripes' in class 'Cat'
cast<Tiger&>(c).stripes = 5;           // a reference to the same object
var anything = c;                      // a var receiver resolves members when it runs
print(anything.stripes);
try { cast<Tiger&>(Cat(1)).stripes; } catch (e) { print("caught: {}", e); }
output
Cat(): cat
Tiger(): tiger
ROAR (base said: meow 5) Tiger
5
Cat(): cat
caught: Cannot cast 'Cat' to 'Tiger&'
  • override is required to redefine a base method (method 'speak' redefines 'Cat::speak': mark it 'override'), and an error where there is nothing to redefine. Both fail the load, as does an unknown base (Base class 'Feline' not found).
  • The base is constructed first, before the derived fields initialize: through : super(args), or by its default constructor when none is named (Base 'Cat' has no default constructor; call super(...) when it has none).
  • A constructor or destructor calls its own class's overrides, as in C++: Cat() above printed cat while building a Tiger. A call there to a method only a derived class declares is the load error Cannot call 'layout' while 'Widget' is under construction: only a class derived from it declares it (under destruction in a destructor).
  • A base-typed slot keeps the whole object. C++ would slice; here Cat c = Tiger();, a Cat parameter and an array<Cat> element each hold a complete Tiger with its overrides. The slot's type decides only what the checker lets you name: cast<Tiger&>(c) reaches the rest by reference (cast<Tiger>(c) is a copy), and a wrong guess raises a catchable error.
  • A base value never enters a derived slot, nor a sibling's or an unrelated class's.
  • x instanceof T asks what an object is: true when its class is T or derives from T at any depth, through a value, a reference, a handle (a null one is false) or a var (one that holds no object is false). After the test cast<T&>(x) cannot fail; the checker does not narrow x itself. reflect::is_a(x, "T") takes the class name at run time.
jaiscript
class Hero { string name = "hero"; }
class Paladin : Hero { string smite() { return name + " smites"; } }
array<Hero> party = [Hero(), Paladin()];          // a Hero element keeps a whole Paladin
for (auto& h : party) {
    if (h instanceof Paladin) { print(cast<Paladin&>(h).smite()); }
    else { print(type_of(h), " ", h instanceof Hero, " ", reflect::is_a(h, "Paladin")); }
}
output
Hero true false
hero smites

Several bases construct in declaration order, each with its own bases first, and are destroyed in reverse. An initializer list names a base to pass it arguments (super(...) is the first base), in any order:

jaiscript
class Engine { Engine(int rpm) { print("Engine ", rpm); } ~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

The load reports a later base with no default constructor that the list omits (Class 'Car' must construct base 'Radio': it has no default constructor), a name that is no base, a base named twice, and a diamond, which is not supported (Class 'D' inherits 'A' through both 'B' and 'C' (diamond inheritance is not supported)).

Deriving a C++ class, and extend​

A class may derive a class the host bound from C++; constructing it constructs the C++ object underneath, and overriding a C++ method needs override like any other. extend(object, Class) gives an existing object a derived class in place, so every handle to it sees the new class. It works on script instances too, which lets this sample run without a host:

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(same.speak(), " ", same.chomp, " ", same.name);
output
voidkin 17 grunt
  • Fields both classes declare keep their values and the new class's own fields take their initializers.
  • The result names the object passed: var& w = extend(v, B) aliases it.
  • An object the host injected needs extern raw; so the checker knows the name (Chapter 13).

The host side is in Script-Extensible Objects.

nocopy classes​

nocopy class refuses by-value copies, like a deleted C++ copy constructor. It is inherited with no opt-out, and a script class over a C++ base that cannot be copied is nocopy too:

jaiscript
nocopy class Ledger { int n = 1; }
void audit(Ledger& l) { l.n += 1; }     // a reference binds as usual
Ledger l;
audit(l);
print(l.n);
try { var copy = l; } catch (e) { print("caught: {}", e); }
output
2
caught: Cannot copy an instance of nocopy class 'Ledger': hold it by reference (auto&/var&) or share it (shared_ptr)

Typed fields are enforced​

Fields follow the type ladder: a typed field converts and enforces as a typed variable does, an auto field takes its initializer's type, a var field stays dynamic. This holds for every store: direct, through a bare name in a method, compound, inherited, from the host's set_field and from a JSON load. A mismatch the checker sees is a load error (cannot assign 'string' to field 'hp' declared 'int'); one that arrives through a var raises where it lands (Cannot assign string to field 'hp' of Stats (declared int)).

Access control: public: / private:​

private: admits the declaring class and every class derived from it, which is C++'s protected, and protected: names the same level. Members start public, there is no class-only level, and constructors follow the same rule.

jaiscript
class Chest {
private:
    int gold = 250;
    Chest(int g) { gold = g; }                 // a private constructor
public:
    static Chest sealed(int g) { return Chest(g); }   // the class's own code constructs
    int peek() { return gold; }
}
class Vault : Chest {
    Vault() : super(400) {}                    // so does a derived class
    int double_gold() { return gold * 2; }     // and it reads the private field
}
Vault v;
print(Chest::sealed(7).peek(), " ", v.double_gold());
var anyone = v;                                // a var receiver is checked when the access runs
try { anyone.gold; } catch (e) { print("caught: {}", e); }
print(reflect::construct("Chest", 3).peek());  // reflection reaches private members
output
7 800
caught: Cannot access private member 'gold' of class 'Chest'
3
  • Outside the family, every read, write, call, compound assignment and ++/-- gets Cannot access private member 'x' of class 'C', and every construction through a private constructor (Name(...), new, a declaration, a conversion) gets Cannot access private constructor of class 'C'. Access is checked on the overload chosen, so a class may keep some constructors public.
  • The load reports either one when it knows the receiver's class; a var receiver is checked when the access runs.
  • Two deliberate exceptions: reflect:: reaches every private member (Reflection), and a load reconstitutes saved state whatever its access.

Loading saved objects​

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

  1. Construct. A zero-argument constructor runs, whatever its access. A class whose constructors all take arguments runs none: only its field initializers.
  2. Restore the fields in declaration order, base classes first, each by name. An unknown key is ignored, and a field the save lacks keeps the constructor's value. Storage is written directly, past any _get_x / _set_x, and a C++ property's observers stay quiet.
  3. Run post_load. Each loaded object that declares void post_load(int version) runs it once when the whole load is done, children before parents. Its error fails the load.

A saved value that no longer fits its field converts where = would (a float truncates into an int field); otherwise the field keeps the constructor's value, as a class value field does over a saved null.

Accessors: _get_x / _set_x​

Declare _get_x() or _set_x(v) and obj.x goes through it from outside the class. Inside, a bare x is still the field, so an accessor uses it without recursing. A property the host binds arrives as such a pair.

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
}
Thermostat t;
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 one parameterless getter with a declared return type and one by-value single-parameter setter: t.target = "warm"; is the load error cannot assign 'string' to property 'target' declared 'int'. Overloaded, var, reference or host-registered accessors are checked when they run.

Reference returns and keyword member names​

A method or function declared to return T& returns the storage itself, so its call is an assignment target for =, compound assignment and ++/--. Returning something with no storage (a temporary, a missing map key) raises Function with reference return type must return an lvalue reference, and assigning to a by-value call raises Cannot assign to a call result: 's.get' returns by value.

A reserved word is a name after ., ?., -> or ::, and as the declared name of a field, method, namespace member or enum value. Bare, it stays the keyword: inside the class write this.default. this and super are never member names, and delete is not reserved.

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; }   // inside the class, reach it through this
}
enum Mode { default, strict }
Slots s;
s.slot(1) = 5;
s.slot(2) += s.default;
print(to_json(s.items), " ", s.catch(1), " ", Mode::default == Mode::strict);
output
[0,5,7] 12 false

Statics​

A static field or method belongs to the class and is named Class::name. The class's own code, and its subclasses', name it bare, and Derived::count is the base's static unless Derived declares its own. Static fields enforce their type as instance fields do; static auto infers and static var stays dynamic.

jaiscript
class Counter {
    static int count = 0;
    static float ratio = 3;                 // the initializer converts: 3.0
    Counter() { count += 1; }               // the class's code names its statics bare
    static int twice() { return count * 2; }
}
class Sub : Counter { int next() { return count + 1; } }   // a base's statics too
Counter a;
Sub b;
Sub::count += 10;                           // the base's static, through the derived name
print(Counter::count, " ", Counter::twice(), " ", Counter::ratio, " ", b.next());
output
12 24 3.0 13

:: is the only spelling from outside. Counter.count is the error 'Counter' is a class; use Counter::count, and a static read through an instance (c.count) is the load error 'count' is a static member of class 'Counter': name it Counter::count. A namespace of the class's name can override its static methods (Namespaces).

Name resolution in methods​

A bare name in a method resolves as C++ unqualified lookup does, for reads and writes:

  1. the innermost catch variable,
  2. locals and parameters (a use before a local's declaration looks outward),
  3. enclosing scopes and lambda captures,
  4. class members: fields (inherited included), then methods, then statics,
  5. globals.

A member hides a global by name: a method flavor() hides a global flavor(int) entirely, and ::flavor(42) reaches the global. A lambda made in a method sees members at the same step; its capture list decides when:

jaiscript
int hp = 1;                                  // a global of the same name
class Tank {
    int hp = 7;
    int doubled() { return hp * 2; }
    void probe() {
        auto bare = [this]() { return hp; };        // reads the field when called
        auto copy = [=]() { return hp; };           // the field's value now
        auto live = [&]() { hp += 1; return hp; };  // the live field
        auto twice = () => doubled();               // a method, through the captured instance
        print("bare {} copy {} live {} twice {}", bare(), copy(), live(), twice());
        hp = 100;                                   // the member hides the global
        print("bare {} copy {} global {}", bare(), copy(), ::hp);
    }
}
Tank().probe();
output
bare 7 copy 7 live 8 twice 16
bare 100 copy 7 global 1

[this] captures the instance itself, never a copy, and [] reaches no instance state. A bare method call needs the instance: a lambda with [=] or [&], an arrow function and a function(...) expression capture it for the call as [this] does, and under [] the call is the error Lambda capture list does not capture enclosing name 'doubled'; name it, or use [this], [=] or [&]. Shadowing is legal, so the checker only warns: a local that shadows a field (local 'hp' shadows field 'hp' of class 'Creature'), an outer local, or a global the function already read. A constructor parameter named after the field it sets (C(int hp) { this.hp = hp; }) stays silent.

Methods are called, not read​

player.heal without parentheses is an error, for script, host and builtin methods (arr.push) alike, at load when the receiver's class is known. To hold a method as a value, bind it with reflect::bind(player, "heal"), which keeps the object alive (Reflection), or wrap the call in a lambda. A field that holds a callable, and a static method, read as values.

jaiscript
class Player { int hp = 5; void heal() { hp += 1; } }
Player player;
var heal = reflect::bind(player, "heal");
heal();
print(player.hp);
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
'heal' is a method of Player; call it with anyone.heal() or bind it with reflect::bind(anyone, "heal")

Operators and callable objects​

Operators are methods named operator plus the symbol (Operators). operator() makes an instance callable; it overloads and inherits like any method. A function slot takes a function or null, so wrap an instance in a lambda to pass it to one:

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, " ", twice([]() { return c(); }));
output
11 13

Hot reload​

Running a class declaration again on the same engine reloads the class. Each //=== execute below starts a separate execute() (in the REPL, a separate entry):

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
  • Fields that still exist keep their values (version stayed 1: initializers do not run again), new fields run their initializers on each live instance with this bound, and removed fields drop.
  • Methods, accessors and copy constructors come from the newest definition at the next call.
  • A reload may redefine a class or function that is on the call stack, and suspended method coroutines survive it.