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 holdsnull.new Hero()andmake_shared<Hero>()are one expression: ashared_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
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()$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 andnew Money(5)a handle;Money m(5);,Money m{5};andMoney m = Money(5);declare the same value. - Methods reach fields and other methods by bare name (
this.centsalso 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 noexplicit). A conversion is one step: neverto_int()first andMoney(int)second. - A class's own
to_string()is whatprint,formatand+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:
- An exact type.
- Int to float, bool to number, char to text.
- A
varparameter. - The looser conversions: truthiness into a
bool, the other text kind into astring, a one-character string into achar, ato_int()- orto_string()-style method, a converting constructor. A string never selects aboolparameter.
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); }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.
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);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}}, notMove 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:
class Hero { int hp = 1; }
class Node { int v = 0; Node next; }
Hero h = null;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:
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");lit hall
visiting
out hall
lit lamp
two handles
out lamp
doneConstructors
- A bare
return;ends a constructor early and the call still yields the instance.return expr;is the parse errorA 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) {} }givesC(7).x == 40. Reading its own field or a later one is the load errorfield 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.
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();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'soperator=when an overload takes the value, self-assignment included. Otherwise it is the implicit copy assignment:xkeeps 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();,cis the same object and a completeTiger. 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 raisesReference to a removed field. - How a
varor a handle on the left of=behaves is in Value Semantics.
When an object dies
| object | dies |
|---|---|
| A local | At 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 parameter | When 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 consumes | By 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,sliceandfilterdo 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
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); }Cat(): cat
Tiger(): tiger
ROAR (base said: meow 5) Tiger
5
Cat(): cat
caught: Cannot cast 'Cat' to 'Tiger&'overrideis 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 printedcatwhile building aTiger. A call there to a method only a derived class declares is the load errorCannot call 'layout' while 'Widget' is under construction: only a class derived from it declares it(under destructionin a destructor). - A base-typed slot keeps the whole object. C++ would slice; here
Cat c = Tiger();, aCatparameter and anarray<Cat>element each hold a completeTigerwith 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 Tasks what an object is: true when its class isTor derives fromTat any depth, through a value, a reference, a handle (a null one is false) or avar(one that holds no object is false). After the testcast<T&>(x)cannot fail; the checker does not narrowxitself.reflect::is_a(x, "T")takes the class name at run time.
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")); }
}Hero true false
hero smitesSeveral 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:
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();Engine 800
Radio
Car
~Car
~Radio
~EngineThe 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:
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);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:
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); }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.
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 members7 800
caught: Cannot access private member 'gold' of class 'Chest'
3- Outside the family, every read, write, call, compound assignment and
++/--getsCannot access private member 'x' of class 'C', and every construction through a private constructor (Name(...),new, a declaration, a conversion) getsCannot 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
varreceiver 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:
- Construct. A zero-argument constructor runs, whatever its access. A class whose constructors all take arguments runs none: only its field initializers.
- 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. - Run
post_load. Each loaded object that declaresvoid 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.
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);t.target = 200
field = 6, t.target = 60The 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.
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);[0,5,7] 12 falseStatics
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.
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());
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:
- the innermost
catchvariable, - locals and parameters (a use before a local's declaration looks outward),
- enclosing scopes and lambda captures,
- class members: fields (inherited included), then methods, then statics,
- 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:
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();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.
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); }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:
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(); }));11 13Hot 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):
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);before reload: v1 production
== result: null
after reload: v1 production retries=3
== result: null- Fields that still exist keep their values (
versionstayed 1: initializers do not run again), new fields run their initializers on each live instance withthisbound, 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.