Value Semantics & References
If you read only one chapter of this guide, read this one. The single most common ambush for a newcomer lives here: JaiScript copies by default, everywhere. Assignment copies. Function arguments copy. Even class instances copy. If you mutate a copy, the original never hears about it, and nothing warns you.
The ambush: mutations on copies vanish silently
Classes, arrays and maps are values, as a C++ struct or std::vector is: b = a copies, a by-value parameter receives a copy, and a change to the copy never reaches the original. & parameters, auto& and shared_ptr<T> are how two names share one object, with their C++ meanings.
auto a = [1, 2, 3];
auto b = a; // assignment COPIES the array
b[0] = 99;
print("a[0] = {} (the original is untouched)", a[0]);
print("b[0] = {}", b[0]);
function double_all(arr) {
for (auto& x : arr) { x *= 2; } // mutates the local COPY
}
auto nums = [1, 2, 3];
double_all(nums); // parameter passed BY VALUE
print("after double_all(nums): nums[0] = {} <- mutation vanished!", nums[0]);a[0] = 1 (the original is untouched)
b[0] = 99
after double_all(nums): nums[0] = 1 <- mutation vanished!This applies to class instances too. Unlike Python/JS/Lua/C#, objects are values:
class Point {
int x = 0;
void set(int v) { x = v; } // methods mutate their own instance
}
auto p = Point();
p.x = 1;
auto q = p; // class instances COPY on assignment too
q.x = 99;
print("p.x = {} q.x = {}", p.x, q.x);
function try_mutate(o) { o.x = 77; } // by-value param: mutation is lost
try_mutate(p);
print("after try_mutate(p): p.x = {}", p.x);
p.set(42); // method calls DO mutate the receiver
print("after p.set(42): p.x = {}", p.x);p.x = 1 q.x = 99
after try_mutate(p): p.x = 1
after p.set(42): p.x = 42So: methods mutate their receiver; free functions receive copies. When a mutation "doesn't stick", check whether you passed the object by value.
Container literals copy the same way, as std::vector{a, a} does in C++. An element that names existing storage (a variable, a field, an element) goes into the literal as a copy, exactly the copy push or an assignment makes, so [h, h] holds two separate objects and changing one changes neither the other nor h. Map literal keys and values, nested literals, typed declarations such as array<Hero> party = [h, h]; and field initializers follow the rule, and a bound C++ object copies through its copy constructor. An element that is a temporary (a call result, a construction, a nested literal, a number or a string) goes in without a copy, because nothing else can see it. A shared_ptr element copies the handle, so the elements share its object:
class Hero { int hp = 10; }
Hero h = Hero();
auto party = [h, h]; // two copies of h
party[0].hp = 1;
print("{} {} {}", h.hp, party[0].hp, party[1].hp);
shared_ptr<Hero> s = new Hero();
auto links = [s, s]; // two handles to s's object
links[0].hp = 3;
print("{} {}", s.hp, links[1].hp);10 1 10
3 3Returns follow the same rule. A function that returns by value hands back a value that aliases nothing the function could name: a global, a field or an element it returns is copied (a local or a parameter simply moves out), so changing the result never reaches the callee's storage. A -> T& or T& name() return is the alias:
array<int> stock = [1, 2, 3];
array<int> get_stock() { return stock; } // by value: the caller gets a copy
array<int>& stock_ref() { return stock; } // by reference: the caller names stock
get_stock().push(4);
print("after get_stock().push(4): {}", stock.size());
stock_ref().push(4);
print("after stock_ref().push(4): {}", stock.size());after get_stock().push(4): 3
after stock_ref().push(4): 4Escape hatch 1: & reference parameters
Declare a parameter with & and the callee binds the caller's variable. Works with explicit types (int& x) and with auto& (any type):
function bump(int& x) { x += 1; } // typed by-ref parameter
auto n = 41;
bump(n);
print("after bump(n): {}", n);
function fill(auto& arr, v) { arr.push(v); } // auto& works for any type
auto items = [];
fill(items, "sword");
print("items.size() = {}", items.size());
class Hero { int hp = 10; }
function heal(auto& who) { who.hp += 5; }
auto h = Hero();
heal(h); // & is how object mutations survive a call
print("h.hp = {}", h.hp);after bump(n): 42
items.size() = 1
h.hp = 15Reference parameters survive pass-through chains, block and loop scopes, methods, globals, and recursion. The aliasing follows the value wherever the call takes it.
A field or element argument binds when the argument is evaluated, as in C++: its receiver and its index are fixed at that point, so a later argument that reassigns them does not change the storage the parameter names (Evaluation order is a contract).
A string& parameter takes any string. A string variable binds its storage, so writes reach the caller. A literal or a computed string binds a temporary for the call, writable, whose writes stay inside the call; its characters are shared until the first write. Lambdas take string temporaries the same way, and a lambda that captures the parameter, or a reference return, keeps the temporary alive:
string upper(string& name) {
name.to_upper();
return name;
}
string live = "alice";
print(upper(live));
print(live);
print(upper("bob"));ALICE
ALICE
BOBAny other reference parameter takes a literal when the function only reads it. For every other & parameter of a declared function or method, the checker works out whether the body can write through it. One that is only read behaves like C++'s const T& without the keyword: a literal or a temporary binds a temporary copy for the call, and a variable still binds with no copy:
function total(array<int>& xs) { // reads xs, never writes through it
int sum = 0;
for (auto x : xs) { sum += x; }
return sum;
}
print("total([1, 2, 3]) = {}", total([1, 2, 3])); // the literal binds a temporary
auto scores = [4, 5];
print("total(scores) = {}", total(scores)); // the variable binds, no copytotal([1, 2, 3]) = 6
total(scores) = 9The checker counts each of these as a write to the parameter:
- An assignment, a compound assignment (
+=and the rest),++or--on the parameter or on anything inside it, such as an element or a field. - The same through a reference alias of it, such as
auto& r = xs;orfor (auto& x : xs). - A method call on it. On an array, map or string, the library's reading methods (
size,find,keysand the like) are reads, not writes. - Passing it on to an
¶meter that writes. The checker follows the call to find out. - Passing it to a
shared_ptrparameter, to a constructor, or to a function the checker cannot see into: a lambda, a host function or a method.print,to_string,math::,string::and the container methods take copies, so passing to them is a read. - Returning it by reference.
For a method, a write in any override in a class derived from the receiver's class counts too.
int first(array<int>& xs) { return xs[0]; } // only reads xs
void grow(array<int>& xs) { xs.push(0); } // push writes xs
print(first([7, 8])); // a literal binds a temporary
array<int> held = [1];
grow(held); // a writer needs a variable
print(held.size());
// grow([1]); // load error: 'grow' writes through parameter 'xs' (line 2); pass a variable7
2A parameter that is written keeps needing a variable. A literal argument fails the load, and the error names the line of the write, as the commented line shows. A temporary the checker cannot see at load raises Cannot pass non-lvalue to reference parameter when the call runs. A reference parameter of a lambda called through a variable keeps needing a variable too.
A host function is the other way round. Its non-const T& parameter takes a literal or a temporary as a copy, and writes its final value back only into an lvalue argument (Embedding Quickstart).
The body becomes part of the interface: adding a write to a function that callers pass literals to breaks those calls loudly, at the next load or hot reload (which checks them again), instead of letting the write vanish into a temporary. A string& is the exception, because a string temporary is written on purpose: a function that edits a name in place serves a variable and a literal alike.
Escape hatch 2: field and element lvalues as ref arguments
You can pass obj.field or arr[i] straight into a & parameter. Element references re-resolve through the container, so they stay correct even if the array reallocates its storage:
function bump(int& x) { x += 1; }
class Hero { int hp = 10; }
auto h = Hero();
bump(h.hp); // field lvalue passes by reference
print("h.hp = {}", h.hp);
auto arr = [5, 6, 7];
bump(arr[1]); // array element lvalue passes by reference
print("arr[1] = {}", arr[1]);
for (int i = 0; i < 64; ++i) { arr.push(0); } // force reallocation
bump(arr[1]); // element refs re-resolve through the container
print("arr[1] after realloc = {}", arr[1]);h.hp = 11
arr[1] = 7
arr[1] after realloc = 8Shrinking the array out from under an element reference stops the script with Reference to a removed array element rather than corrupting memory, on both backends. The error is catchable, and the store it interrupted writes nothing.
A vector or color component (Math) is an lvalue too, as a struct member is in C++: f(v.x) with void f(float& a), auto& r = pos.y;, cast<float&>(v.x) and a -> float& return of a component all bind it. Writes land in the vector the component belongs to (a local, a field, an element of an array<math::vec2>, a C++ property through its setter), with the same narrowing as v.x = value.
void scale(float& a) { a *= 2.0; }
math::vec2 v = math::vec2(1.5, 2.0);
scale(v.x); // the component binds float&
auto& r = v.y; // and an auto& declaration
r = 9;
print(v);(3.0, 9.0)Escape hatch 3: reference declarations (auto&)
auto& r = x; declares an alias. References chain, and element references survive reallocation just like ref params:
var v = 1;
auto& g = v; // reference declaration: g aliases v
auto& h = g; // references chain
h = h + 3;
print("v = {}", v);
var arr = [7, 8];
auto& x = arr[1]; // element reference
for (int i = 0; i < 64; ++i) { arr.push(0); }
x = 42; // still writes through after the array reallocated
print("arr[1] = {}", arr[1]);v = 4
arr[1] = 42References respect the type system, and builtin methods work through them. A write through an auto& bound to a typed array's element converts when compatible and rejects when not (exactly like writing to the element directly), and calling a string/array/map method through a reference mutates the referent in place:
array<int> nums = [1, 2, 3];
auto& e = nums[0];
e = 9.7; // converts to the element type, like any typed write
print("after e = 9.7: {}", to_json(nums));
try {
e = "nope"; // incompatible writes are rejected through refs too
} catch (err) {
print("caught: {}", err);
}
var s = " hello ";
auto& r = s;
r.trim(); // string methods work through ref declarations
print("[{}]", s);after e = 9.7: [9,2,3]
caught: Cannot assign 'string' to element of type 'int'
[hello]A spelled type works the same way: at statement level Node& r = n; declares a reference, as in C++, where whatever can be read as a declaration is one; it is never read as the expression Node & r = n. A reference needs something to refer to, so Node& r; without an initializer does not load: reference variable 'r' must be initialized: a reference names a variable from its declaration on.
A reference declaration binds any lvalue, as in C++: a variable, a field, an element, a map's existing entry by any key (auto& hp = units[name].hp;), a member reached through a computed index (auto& c = cells[y * w + x].color;), and a call that returns T&. A key with no entry names no storage, since reading a map never inserts: int& e = m["absent"]; raises instead of adding the key.
A const T& also binds a temporary, as in C++: const Lamp& hall = make("hall"); holds the made object itself, with no copy, and the object lives as long as hall, dying when its scope ends among the other locals, the last declared first. A value of another type that a T declaration takes binds a T made from it. A reference that is not const binds only an lvalue, so Lamp& attic = make("attic"); raises Cannot take reference of non-lvalue expression (...) and the made lamp dies with the statement:
class Lamp {
string name;
Lamp(string n) { name = n; print("lit {}", name); }
~Lamp() { print("out {}", name); }
}
Lamp make(string n) { return Lamp(n); }
void room() {
const Lamp& hall = make("hall"); // holds the made lamp itself, as long as hall lives
{
const Lamp& closet = make("closet");
print("in the {}", closet.name);
} // closet's lamp goes out here
print("back in the {}", hall.name);
}
room();
try { Lamp& attic = make("attic"); } catch (e) { print("caught: {}", e.starts_with("Cannot take reference of non-lvalue")); }lit hall
lit closet
in the closet
out closet
back in the hall
out hall
lit attic
out attic
caught: trueA reference binds only its type
A T& refers to a T, as in C++. An int& parameter or declaration binds an int variable, element or field, and a var only while it holds an int; a reference to a class T binds a T or a subclass. A float never binds an int&: the checker rejects one it can see at load (argument 1 of 'bump': cannot bind 'float' to 'n' declared 'int&'), and a var holding the wrong kind raises when the call binds it. The other direction holds too: a store through any reference obeys the declared type of the variable it names, so var& r = hp; over an int hp stores ints:
function bump(int& n) { n += 1; }
var count = 4; // a var holding an int binds an int&
bump(count);
print("count = {}", count);
var label = "four"; // a var holding a string does not
try { bump(label); } catch (err) { print("caught: {}", err); }
int hp = 10;
var& r = hp; // the reference names an int variable
r = 7.9; // so the store converts as hp = 7.9 would
print("hp = {}", hp);
try { r = "full"; } catch (err) { print("caught: {}", err); }count = 5
caught: Cannot pass string to parameter 'n' of 'bump' (declared int&)
hp = 7
caught: Cannot assign string to 'r' (declared int&)Escape hatch 4: range-for with auto&
auto nums = [1, 2, 3];
for (auto x : nums) { x *= 2; } // copies: original unchanged
print("after value loop: {}", nums[0]);
for (auto& x : nums) { x *= 2; } // references: mutates in place
print("after ref loop: {}", nums[0]);after value loop: 1
after ref loop: 2Escape hatch 5: shared_ptr<T> (opt-in reference semantics)
When you want an object shared by handle (entity registries, cross-references), declare it shared_ptr<T>. Copies of the handle alias the same object:
class P { int x = 3; }
shared_ptr<P> sp = P();
auto alias = sp;
alias.x = 99;
print("sp.x = {} (shared!)", sp.x);
function poke(o) { o.x = 42; }
poke(sp);
print("after poke: {}", sp.x);sp.x = 99 (shared!)
after poke: 42new P() is the direct spelling for a handle (shared_ptr<P> sp = P(); wraps a value the same way), and every copy of the handle shares the object. make_shared<P>(args) is the same expression as new P(args): the same handle, constructor choice, checked type and errors. A bare make_shared not followed by < is an ordinary name, so a function of that name stays callable.
A handle on the right of = points the variable at another object (sp = new P();); a value on the right assigns into the object it shares (sp = P();, every alias sees it). sp.reset(h) is the explicit repoint, C++'s sp.reset(new P()): sp takes h's object, the object it held stays with its other owners, and nothing writes through it or runs operator=. h is a handle, new P(), make_shared<P>() or another shared_ptr (a null one empties sp), of a class sp admits as an assignment would; a value or a primitive raises. sp.reset() empties it.
class P { int x = 3; }
shared_ptr<P> sp = new P();
shared_ptr<P> alias = sp;
sp.reset(new P()); // sp names a new object; alias keeps the old one
sp.x = 7;
print("{} {} {}", sp.x, alias.x, same_object(sp, alias));7 3 falseweak_ptr<T> observes a handle without owning it:
class P { int x = 3; }
shared_ptr<P> sp = P();
weak_ptr<P> w = sp;
print("expired: {}", w.expired());
auto l = w.lock();
l.x;expired: false
== result: 3Note the guard rail: P p = P(); weak_ptr<P> w = p; is rejected (a load error when the checker sees the value, a catchable run-time error otherwise): a value has no shared owner to observe, so only a shared_ptr (or new P()) can seed a weak_ptr. A weak_ptr<P> field or static takes a shared handle the same way a declaration does.
The handle keeps sharing inside a copy, exactly as a std::shared_ptr member does in C++. Copying a class instance, an array or a map copies every value member and element and shares every shared_ptr and weak_ptr one:
class Node { int v = 1; }
class Holder { shared_ptr<Node> link; Node own; Holder() { link = new Node(); own = Node(); } }
Holder a = Holder();
Holder b = a; // b.own is a copy; b.link is the same Node as a.link
b.link.v = 5;
b.own.v = 50;
print(to_string(a.link.v) + " " + to_string(a.own.v) + " " + to_string(same_object(a.link, b.link)));5 1 trueThe same holds for array<shared_ptr<Node>> and map<string, shared_ptr<Node>>: a copy of the container is a new container whose handles point at the old objects. Cloning through a handle is an explicit act: copy the object the handle names (Node fresh = a.link;, a value declaration from a handle) and wrap it again. A handle stored into a class-typed element is that act too, as std::vector<Node>{*h} is in C++: array<Node> xs = [h];, xs.push(h), xs[0] = h and map<string, Node> m = {"k": h} each hold their own Node, while an untyped container or a shared_ptr<Node> element keeps the handle.
A handle converts the way a C++ shared_ptr does. A shared_ptr<Derived> enters a shared_ptr<Base> slot (an upcast). A handle typed shared_ptr<Base> does not enter a shared_ptr<Derived> slot, even when the object it holds is a Derived: that downcast is spelled cast<shared_ptr<Derived>>(b). A var source has no static class, so it is judged by the class its object really has: it enters when that class is Derived or a subclass of it. A handle holding an unrelated class never enters. The rule is the same at every slot (a declaration, an assignment, a field, a namespace variable, a parameter, a return value, a container element), and the strict checker reports a downcast or an unrelated class it can see at load.
class Base { int a = 1; }
class Derived : Base { int t = 9; }
shared_ptr<Derived> d = new Derived();
shared_ptr<Base> b = d; // upcast: converts
shared_ptr<Derived> back = cast<shared_ptr<Derived>>(b); // downcast: spelled
var v = b;
shared_ptr<Derived> fromVar = v; // a var source: its object is a Derived
// shared_ptr<Derived> bad = b; // load error: a shared_ptr<Base> is not a shared_ptr<Derived>
Escape hatch 6: cast<T&> and cast<shared_ptr<T>>
cast comes in three forms, and the difference between them is exactly this chapter's subject: cast<T>(e) is a copy, cast<T&>(e) is a reference to the same object, cast<shared_ptr<T>>(e) is a handle sharing it. The reference form earns its place here as the strict-mode fix for a classic situation: a base-typed variable holding a derived object, and you need a member only the derived class declares.
class Base { int a = 1; }
class Derived : Base { int t = 9; }
Base h = Derived(); // a Base-typed slot; the object inside is still a Derived
// print(h.t); // load error: no member 't' in class 'Base'
print("alias: ", cast<Derived&>(h).t); // cast<T&>: a reference to the SAME object
cast<Derived&>(h).t = 5; // writes go through it
print("after write: ", cast<Derived&>(h).t);
shared_ptr<Derived> s = cast<shared_ptr<Derived>>(h); // cast<shared_ptr<T>>: a handle sharing it
s.t = 77;
print("via handle: ", cast<Derived&>(h).t);
Derived copy = cast<Derived>(h); // the value form COPIES - a separate object from here on
copy.t = 0;
print("original: ", cast<Derived&>(h).t, " copy: ", copy.t);alias: 9
after write: 5
via handle: 77
original: 77 copy: 0The reference form is itself an lvalue, as in C++, so you can assign to it and bind through it:
cast<Derived&>(h) = dassigns the objecthholds. Whenhis ashared_ptr, that is the object it shares, so every alias sees the new members, as*h = dwould; the handle itself is not rebound.- A member of it binds by reference:
auto& t = cast<Derived&>(h).t. cast<int&>(n) += 2.5andcast<int&>(n)++store an int inton, asint& r = n; r += 2.5;would, even whennis avar.
Every C++ lvalue is assignable the same way: a function or method returning T& (first() = 5), a conditional whose arms are lvalues ((useA ? a : b) = 5), (a = b) = c and ++x = v. So is an element or member of any of them: getref()["a"] = 5 through a function returning map<string, int>&, or (useA ? xs : ys)[0] = 1. Such a call also binds a reference parameter: bump(holder.child()) writes the object a host method returning T& names, while a call returning by value stays a temporary.
array<int> slots = [1, 2, 3];
int& first() { return slots[0]; }
int a = 1;
int b = 2;
bool use_a = false;
first() = 5; // a call returning int& names slots[0]
(use_a ? a : b) = 9; // a conditional of two lvalues names one of them
(a = b) = 7; // an assignment yields its left side
var n = 3;
cast<int&>(n) += 2.5; // stores an int into the var, as int& r = n would
print("{} {} {} {}", slots[0], a, b, n);
int count() { return 3; }
var f = count; // called through a var, so only the run time sees it
try { f() = 4; } catch (err) { print("caught: {}", err); }5 7 9 5
caught: Cannot assign to a call result: 'f' returns by valueA conditional whose arms both name storage is that lvalue wherever a reference binds it, as in C++: f(use_a ? a : b) passes the chosen variable to a T& parameter, auto& r = use_a ? a : b; aliases it, and a -> T& return, a T& default and cast<T&>(use_a ? a : b) name it too. A store, return or yield of it by value still copies the picked value. Arms of two types make a converted temporary, which a non-const reference cannot bind: the checker reports cannot bind a reference to a conditional of 'int' and 'float': its arms must have one type, and an arm that names no storage (use_a ? a : 5) keeps the whole conditional a temporary.
Anything else names no storage, and the error says what was written: Cannot assign to a literal, Cannot assign to x++: it yields the old value, Cannot assign to the result of '+': it is a temporary value, Cannot assign to the result of cast<int>(x): it is a temporary value (cast<int&>(x) names the variable), Cannot assign to a conditional: its arm '5' names no storage. A member of a class value no storage holds is a temporary too, as in C++: make().x = 1, Point().x = 1 and (a + b).x = 1 raise Cannot assign to a member of a temporary when they run (a handle result names its object, so makeShared().x = 1 stores). The checker reports the same text at load (with a lowercase first letter) wherever it can see the target, a call to a function it knows or a subscript through a by-value operator[] included (cannot assign to a call result: 'count' returns by value). A call it cannot see through, like f() above, is judged when it runs: the right side and the call run first and a by-value result raises after them.
Two things to keep in mind. The value form applied to a handle or a reference still copies, and because that is almost never what you meant, the checker warns about it (cast<C>(h) copies the instance - use cast<C&>(h) to alias it or cast<shared_ptr<C>>(h) to share it). And a cast to an unrelated class raises the catchable Cannot cast 'X' to 'T' at run time; the checker never second-guesses the operand, since saying cast is how you tell it you know better. The primitive forms, the char rules and the full contract are in the Type Ladder.
Bound C++ objects
A C++ class the host binds (dynamic_binder) follows the same rules as a script class. Take a host that binds a Turret, and a Tower that owns one and hands it out by reference:
struct Turret {
std::string name = "turret";
int64_t ammo = 0;
};
struct Tower {
Turret turret;
Turret& child() { return turret; }
};
jai::dynamic_binder<Turret>(*eng, "Turret")
.property("name", &Turret::name)
.property("ammo", &Turret::ammo)
.method("=", [](Turret& self, const Turret& other) -> Turret& { return self = other; })
.build();
jai::dynamic_binder<Tower>(*eng, "Tower")
.method("child", &Tower::child)
.build();Turret a = Turret();
a.ammo = 5;
Turret b = a; // a copy, made by Turret's copy constructor
b.ammo = 9;
print(a.ammo, " ", b.ammo);
var& r = a; // r names a itself
r.ammo = 6;
print(a.ammo);
r = b; // runs Turret's bound operator= on a
print(a.ammo);
Tower tower = Tower();
var c = tower.child(); // a copy of the Turret inside tower
c.ammo = 40;
print(tower.child().ammo);
var& t = tower.child(); // a reference to that Turret
t = b; // runs Turret's bound operator=
print(tower.child().ammo);5 9
6
9
0
9Line by line:
Turret b = a;copies, as it would for a script class. The copy is a separate C++ object, built by the class's copy constructor. A by-value parameter (var porTurret p) and a by-value return copy the same way, whatever the argument or the returned expression is: a variable, a member or a call that returnsTurret&.var& r = a;is a reference to the variablea. Nothing is copied, and writes throughrland ina.r = b;assigns intoa, so it runs the class's boundoperator=(the"="method) ona's Turret, asa = b;does.var c = tower.child();copies.child()returns aTurret&, and a declaration that is not a reference drops the reference, asauto c = tower.child();does in C++, socis a new Turret made by the copy constructor and writes through it leave the Tower's Turret alone.auto candTurret ccopy the same way.var& t = tower.child();binds a reference to the Tower's Turret, asTurret& t = tower.child();does in C++.t = b;then runs the class's boundoperator=on that Turret. A class that binds no"="refuses withCannot assign to a reference to 'Turret': its binding has no operator=.
A variable, field or element declared a bound class holds its own object, and an assignment into it, directly or through a reference, runs the bound operator= on that object, as C++ assigns into an object. When the class binds no "=", the assignment replaces the held object with a copy of the source, built by the copy constructor; script owns that storage, so nothing else holds the object it replaces. A var variable is dynamic and takes the new value instead: after var c = a;, the line c = b; makes c a copy of b.
To share one bound object between script variables, use a handle: new Turret() makes a shared_ptr<Turret>, and so does a host function that returns a std::shared_ptr<Turret>. Copies of a handle share the object, in a variable, a parameter or a lambda capture, as escape hatch 5 shows.
const: set once, then only read
const marks a declaration that its initializer sets and nothing writes afterwards: a variable (const int LIMIT = 10;, or const limit = 10;, which is const auto), a parameter (const T& p, const T p), a loop variable (for (const auto& x : xs)) or a class constant. The rules are C++'s. A const value is read-only all the way down: its elements, its fields, and the methods that write their receiver are off limits through it. A const handle only stops the handle from rebinding, and the object it points at stays writable. Copies are ordinary values, so auto copy = LIMIT_TABLE; can change freely.
A const in a class body is a class constant: one value for every instance, read as Hero::MAX_HP or as a bare MAX_HP in the class's methods. A const T& parameter binds literals and temporaries as well as variables, and const is never part of a signature, so f(const T&) and f(T&) are one function.
class Hero {
const int MAX_HP = 100; // a class constant, shared by every Hero
int hp = 40;
void heal() { hp = MAX_HP; }
int read() { return hp; }
}
int twice(const int& x) { return x * 2; } // a const reference takes a literal
const Hero h; // a const value: nothing reached through it changes
print(h.read(), " ", Hero::MAX_HP);
const shared_ptr<Hero> p = new Hero();
p.heal(); // a const handle keeps its object writable
print(p.hp, " ", twice(21));
const var table = [1, 2, 3]; // a var path is decided when it runs
try { table[0] = 9; } catch (e) { print("caught: ", e); }40 100
100 42
caught: Cannot assign to 'table[0]': it is read-only ('table' is const)Strict checking reports every write it can see at load: h.heal(); above is cannot call 'heal' on 'h': 'h' is const and 'heal' writes its receiver (line 4), naming the line of the write, and Hero::MAX_HP = 1; is cannot assign to const 'Hero::MAX_HP'. A path through a var is checked when it runs, as the last line shows.
Choosing a strategy
The short version: passing by value pays for a deep copy that grows with the payload; & and shared_ptr alias the same object at essentially the same cost. All three strategies are quantified side by side in Performance.
The copy is elided when it cannot be observed. A by-value class, array or map parameter that the callee only reads binds the argument itself, with no copy at all, when the engine can prove the copy would make no difference. The proof needs all of these:
- The body never stores through the parameter and never calls a mutating method on it.
- The body never compares it, aliases it, returns it, or passes it on whole or in a nested loop.
- The body reads only members and elements whose declared type is a primitive or a string.
- The callee, with everything it calls, writes nothing but its own locals (the same purity the parallel bodies are held to).
Anything outside that shape keeps the clone. The semantics never change, only the cost: a read-only helper taking a struct by value runs at reference speed. Objects of a nocopy class still raise when passed by value, whatever the callee does. The proof is per function. It is computed on the first call and redone whenever definitions change, and engine::parameter_alias(false) turns the elision off for a differential check.
A by-value local initialized from an lvalue (Big copy = registry[i];) is elided the same way: when the statements after the declaration, and everything they call, only read the local's members and elements, never use it whole and never write the source, the local reads the source's object directly instead of a copy. Again only the cost changes; engine::local_alias(false) turns this elision off.
Decision guide:
- Plain values (default) for scalars, strings (O(1) shared copies), small structs, and anywhere you want snapshot semantics. Stop when the object carries containers and sits in a hot path: the per-call deep copy is the one cost here that grows with the object.
T¶ms /auto&decls when a function needs to see or mutate the caller's object: cheapest aliasing, fastest shape for in-place algorithms. Constraint: references are aliases, not storage. Plain assignment stores a value through them, and a reference can't live in a field or container.&can't be your data model.shared_ptr<T>(new T(...)) when identity must outlive the call: fields holding objects, containers of entities, registries, trees/graphs where nodes hold nodes. Same cost as&at call boundaries. Copies follow the declaration: if the object was declaredshared_ptr(new T(...), or a host function'sstd::shared_ptr<T>result),var/autocopies and lambda captures share the handle (as in C++: copying ashared_ptrcopies the handle, not the object); if it was declared a value (MyType(5)), copies deep-copy.
A var holding a handle. You can skip this on a first read. A var that holds a handle behaves exactly like a typed handle wherever the typed spelling would be legal; the only thing var adds is accepting what a typed holder would reject. What = does to such a variable depends on what is on the right:
- A handle on the right (
p = new Foo();) rebindsp. - A value of a compatible class (the held class or a subclass) assigns into the shared object, so every alias sees it, exactly what a
shared_ptr<Foo>variable does: through the class'soperator=when one of its overloads takes the value, otherwise member by member.Foo()is a value,new Foo()is a handle: the same distinction the typed rungs draw. A bound C++ class assigns into its shared object only through the"="its binding binds; without one, a typed handle raises and thevarrebinds to an independent copy, as it does for an unrelated value. - A value of an unrelated class, or a primitive (
p = Bar();,p = 5;) rebindspto an independent copy. This is the one movevaradds, and it sits precisely where a typed holder would raise. auto q = p;takes the static type ofp, which isvar, soqis avartoo (it shares the handle); spellshared_ptr<Foo> q = p;to lock it.
The same four moves, in order, with alias watching. same_as asks whether two handles point at one object; the general form is same_object(x, y), which answers identity for handles, references, containers and instances alike, because == compares values memberwise (see Equality):
class Foo { int x = 1; }
class Bar { int y = 2; }
var p = new Foo();
var alias = p; // a var copy of a handle shares the handle, as in C++
p.x = 10;
print("alias.x = ", alias.x);
Foo f = Foo(); f.x = 77;
p = f; // a compatible VALUE assigns INTO the shared object
print("alias.x = ", alias.x, " same object: ", p.same_as(alias));
p = new Foo(); // a HANDLE rebinds p; alias keeps the old object
p.x = 5;
print("alias.x = ", alias.x, " p.x = ", p.x);
p = Bar(); // an unrelated value rebinds p to an independent copy
print("p.y = ", p.y, " alias.x = ", alias.x);alias.x = 10
alias.x = 77 same object: true
alias.x = 77 p.x = 5
p.y = 2 alias.x = 77A var holding an object. A var holding an instance by value asks the instance's class first. When the class has an operator= with an overload that takes the right-hand value, the assignment runs it and the variable keeps its object; otherwise the variable takes the new value, whatever its type. A handle or null on the right never runs it. These rules, and the handle rules above, hold wherever a var lives: a local, a parameter, a loop variable, a global, a captured variable, a field, an element of an array<var> and a value of a map<K, var>.
class Celsius { int deg = 0; void operator=(int d) { deg = d; } }
var t = Celsius();
t = 21; // operator=(int) takes an int: t keeps its Celsius
print(type_of(t), " ", t.deg);
t = "warm"; // no overload takes a string: t takes the string
print(type_of(t), " ", t);Celsius 21
string warmAssignment runs operator=. A variable, field, static, element or map value declared a class answers the same way, as C++ does: = into an existing object runs the class's operator= when one of its overloads takes the right-hand value, and the implicit memberwise copy only when none does. A class's implicit copy assignment runs each member's operator= in turn. Initialization is copy construction: a declaration, a by-value parameter and a return copy the object and never run the operator.
class Gauge { int v = 0; int writes = 0; void operator=(Gauge o) { v = o.v; writes += 1; } }
Gauge a = Gauge();
Gauge b = Gauge();
b.v = 7;
a = b; // operator=(Gauge) runs on a
Gauge c = b; // copy construction: no operator= call
print(a.v, " ", a.writes, " ", c.writes);7 1 0Rule of thumb: values by default; & when a call needs the real thing; new/shared_ptr when the alias needs to live somewhere.
clone vs copy (host API note)
Inside script, assignment of value-declared objects behaves as a (deep) copy, as shown above (shared_ptr-declared objects share the handle instead). On the C++ side the rule is inverted: copying a jai::script_value is a cheap O(1) shallow handle copy (heavy types sit behind strong_ptr), and value.clone() is the explicit deep copy. Don't confuse the two worlds: script = copies; C++ = aliases.