Functions & Lambdas
Functions are ordinary values: store them, pass them, return them. Lambdas capture like C++ lambdas (explicitly, visibly), and closures keep captured locals alive. Whatever way you'd spell a function in C++, that spelling almost certainly parses here.
Declaration forms: the full catalog
JaiScript accepts two grammars for a function and treats them as one thing. The C++ shape puts the return type first: int twice(int v) { ... }. The JavaScript shape uses the function keyword, with types optional and an arrow return type optional: function add(a, b) { ... }. Mix them as you like; the two mean exactly the same to the checker and both backends.
The pieces you can combine:
- Return type: leading (
int f(),auto f(),var f(),void f(), any class or container type), trailing (function f() -> int,auto f() -> int, and the bare-> autoor-> {which both mean an inferred return), or omitted (function f(), which infers). Writing both is legal only when they agree;int f() -> stringis a parse error. - Parameters: typed (
int a), untyped (a, the same asvar a),auto a, defaults (int b = 2, trailing only), and by reference (int& counter, which binds the caller's variable; a literal or temporary binds when the function never writes through the parameter, see Value Semantics). - Function values: a lambda (
[](int v) -> int { ... }, with[=],[&]or named captures) or the keyword as an expression (function(int v) { ... }, the JavaScript form, which closes over its scope as[&]does). Arrow functions use(args) => expression,(args) => { body }, orname => expressionand share surrounding locals. There is no script-side variadic. - Methods and statics take every spelling above, with
staticin front. - Coroutines take every spelling with
coroutinein front:coroutine int g(),coroutine g() -> int,coroutine function g().
// C++ shape: return type first
int twice(int v) { return v * 2; }
auto mul(int a, int b) { return a * b; }
auto mul2(a, b) -> int { return a * b; }
auto mul3(auto a, auto b) { return a * b; }
var mul4(var a, var b) { return a * b; }
var pick(bool flag) { return flag ? 42 : "none"; }
void hello(string who) { print("hello {}", who); }
// JavaScript shape: the function keyword, types optional, arrow return type optional
function add(a, b) { return a + b; }
function add3(int a, int b, int c) { return a + b + c; }
function scale(float f, int k) -> float { return f * k; }
// parameters: typed, untyped, var, auto, defaults, by reference
function greet(string name, string punct = "!") { return name + punct; }
function bump(int& counter, int by) { counter += by; }
// function values: lambdas with captures, or the function keyword as an expression
auto square = [](int v) -> int { return v * v; };
var offset = 10;
auto shifted = [&](int v) { return v + offset; };
var halve = function(int v) { return v / 2; };
// methods take every spelling too
class Counter {
int n = 0;
int add(int by) { n += by; return n; }
function clear() { n = 0; }
static int limit() { return 100; }
}
// coroutines: any of the three, with the coroutine keyword in front
coroutine int countdown(int from) { for (int i = from; i > 0; i--) { yield i; } }
print(twice(21));
print(mul(4, 2) + mul2(3, 3));
print(mul3(2, 5));
print(mul4(2.5, 2));
print(pick(true));
print(pick(false));
hello("jai");
print(add(2, 3));
print(add3(1, 2, 3));
print(scale(1.5, 4));
print(greet("hi"));
int c = 0; bump(c, 5); print(c);
print(square(7) + shifted(1) + halve(9));
Counter k = Counter(); k.add(4); print(k.add(1)); print(Counter::limit());
for (auto v : countdown(3)) { print(v); }42
17
10
5.0
42
none
hello jai
5
6
6.0
hi!
5
64
5
100
3
2
1A function is a value: assign it, pass it, return it, store it in a container. Calling one resolves the callee before any argument is evaluated, and arguments go left to right; see Evaluation order is a contract.
Return-type rules
A leading return type and a trailing -> type may both be written only when they match (redundant but legal: int f() -> int). A contradictory pair is a parse error. An arrow with no type before the { adds no information. It keeps the leading type if one was given, otherwise means auto:
int f() -> string { return "s"; }jaiscript: parse failed:
<script>:1:12: error: conflicting return types 'int' and 'string' (leading and trailing '->' types must match)
int f() -> string { return "s"; }
^A declared return type is a typed slot like any other, and it runs the one conversion table: int f() returning 2.9 yields 2. Return a string literal from it and the checker stops the load before anything runs:
function f() -> int { return "s"; }jaiscript: static check failed:
<script>:1:23: error: cannot return 'string' from 'f' declared to return 'int' (declared at 1:10)
function f() -> int { return "s"; }
^A value the checker cannot type (anything that came through a var) is converted at the return statement, inside the returning function, exactly as in C++. So a try around the return catches the failure in the callee; without one, the error surfaces in the caller with the callee as the top frame of the stack trace:
function parse_level(var raw) -> int {
try {
return raw; // the conversion runs here, inside parse_level
} catch (e) {
print("caught in parse_level: {}", e);
return 0;
}
}
print(parse_level(7.9));
print(parse_level("seven"));
function strict_level(var raw) -> int { return raw; }
try { strict_level("seven"); } catch (e) { print("caught by the caller: {}", e); }7
caught in parse_level: Cannot return string from 'parse_level' (declared -> int)
0
caught by the caller: Cannot return string from 'strict_level' (declared -> int)Returning a value from a void function is a load error; so is a function whose result holds no null (int, float, bool, char, string, a reference) that can fall off the end of its body ('f' declared to return 'int' may reach the end of its body without returning) or return; without a value, and a lambda with a declared return type follows the same rule; a class, handle or container result draws only a warning there (such a call yields null). The rule is the strict checker's (Chapter 13): it runs at load and adds nothing at run time. Coroutines are the exception to all of this: calling one yields a handle, and their yield and return types are not checked (see Coroutines).
Parameters and defaults
Four parameter spellings: classic int x / Creature& c (typed, enforced + converted like any typed target), bare x (dynamic), the typed shorthand int: n, and the auto shorthand :x, an auto-like parameter, the same as writing auto x. The shorthands exist for terse lambdas; int x and bare x are what you will write most. Defaults may follow any of them (once a parameter has a default, all later ones need one too):
function greet(name, string greeting = "hello") {
return greeting + " " + name;
}
print(greet("world"));
print(greet("world", "salve"));
function pad(x = 3) { return x; } // bare params take defaults too
print(pad());
print(pad(7));
int square(int: n) { return n * n; } // typed shorthand
auto id = [](:x) -> { return x; }; // :x — auto-like parameter
print(square(6));
print(id("passthrough"));
void heal(int& hp, int amount) { hp += amount; } // by-reference parameter
int health = 50;
heal(health, 25);
print(health);hello world
salve world
3
7
36
passthrough
75Defaults work on every kind of callable: free functions, lambdas, instance methods, static methods, constructors, and namespace functions. A default binds as the argument it stands in for, evaluated at each call that leaves it out: a by-value parameter takes a converted copy (int p = 2.7 holds 2, and C p = c copies c), and a reference parameter binds the variable its default names (int& p = total writes total). As in C++, a reference parameter's default must name storage: int& p = 5 is the load error cannot bind non-lvalue default to reference parameter 'p', while const int& p = 5 binds a temporary. By-reference parameters bind the caller's variable, covered in depth in Value Semantics.
A typed by-value parameter converts its argument with the same table a declaration uses. There is only one. So int half(int n) called with 7.9 sees n as 7, and a string parameter accepts anything (label(5) into string s gives "5"), while a string into an int parameter is rejected. When it is rejected is the checker's call, below.
A by-value parameter (var p, T p) holds a copy of an object argument, as a declaration from it would, and a by-value return hands out a copy: a class instance, a bound C++ object (through its copy constructor) and the object a host call returning T& refers to are all copied, so writes to p stay in the callee. A handle (new T(), a shared_ptr<T>, a host std::shared_ptr<T>) copies the handle and shares its object. To write into the caller's object, take it by reference: void heal(var& c), void heal(Creature& c).
What the checker sees about calls
When the callee is a function the same program declares, the checker resolves it and checks the call at load time: the argument count (defaults included) and each argument against its parameter. A wrong count or a literal that cannot convert is a load error, and every such error in the file is listed at once:
int fib(int n) { return n < 2 ? n : fib(n - 1) + fib(n - 2); }
print(fib(10, 2));
print(fib("ten"));jaiscript: static check failed:
<script>:2:16: error: 'fib' expects 1 argument(s), got 2 (declared at 1:5)
print(fib(10, 2));
^
<script>:3:16: error: argument 1 of 'fib': cannot pass 'string' to 'n' declared 'int' (declared at 1:5)
print(fib("ten"));
^A callable held in a variable is opaque to the checker (it could be anything by the time the call runs), so the same mistakes through a variable are run-time errors, and catchable:
int fib(int n) { return n < 2 ? n : fib(n - 1) + fib(n - 2); }
var f = fib; // a callable in a variable: the checker cannot see through it
try { f(10, 2); } catch (e) { print("caught: {}", e); }
try { f("ten"); } catch (e) { print("caught: {}", e); }
print(f(10));caught: 'fib' expects 1 argument, got 2
caught: Cannot pass string to parameter 'n' of 'fib' (declared int)
55The same split applies to a var-typed argument to a direct call: the checker has no type to compare, so the parameter's conversion runs at the call and raises there if it fails. Overloaded methods and namespace functions are an error only when no overload fits. A reference parameter is checked too: the argument must be a variable of the parameter's type (cannot bind 'float' to 'x' declared 'int&'), and a literal is admitted only when the callee never writes through the parameter ('f' writes through parameter 'x' (line 1); pass a variable, see Value Semantics). Host (C++) functions are checked for arity and for the certain cases only. See Static Checking in the Embedding docs, and Chapter 13 for the whole list of what the checker sees.
Once a call is admitted, its evaluation order is a contract on both backends: the callee expression is evaluated first, then the arguments left to right, as in C++17. So a call whose argument swaps the callee out from under it still calls the callee that was bound before the arguments ran:
var f = [](x) { return "old lambda got " + to_string(x); };
function swap() { f = [](x) { return "new lambda got " + to_string(x); }; return 1; }
print(f(swap()));
print(f(2));old lambda got 1
new lambda got 2Each argument takes its value as it is evaluated. The callee receives what the argument was at that point, even when a later argument changes it before the call runs. The rule covers functions, lambdas, methods, constructors and new, and every kind of value: an array argument followed by an argument that pushes onto that array binds the array as it was. A conversion the binding runs, such as a converting constructor for one parameter, runs after every argument has been taken. A constructor takes its arguments before its field initializers and initializer list run, and the arguments of : super(...) and : this(...) take their values as they are evaluated. A coroutine takes its arguments at the call and binds them at its first next(). A reference parameter binds the caller's storage instead, so it sees every write made before the body runs:
var queue = [1, 2, 3];
function drain() { queue.push(4); return 0; }
function count(q, extra) { return q.size() + extra; }
function count_ref(var& q, extra) { return q.size() + extra; }
print(count(queue, drain()));
print(count_ref(queue, drain()));
coroutine function later(x) { yield x; }
var h = later(queue[0]);
queue[0] = 100;
print(h.next());3
5
1A host (C++) function receives each scalar, string and element argument as its value at evaluation too. A class instance, array or map argument reaches it as the object the argument named, because the C++ parameter decides what happens to it: a T& or bound_array<T>& parameter works on that object, and a by-value C++ parameter copies it when the function is entered.
Taking the value costs nothing unless it could matter. An argument is taken early only when a later argument may run code that could write it (a call, a member read that may run a getter, an assignment). A math:: intrinsic runs no user code, so it does not count. Otherwise the arguments bind exactly as they were evaluated, with no extra step.
The Operators chapter covers evaluation order in general. Every call entry is also a point where the execution budget is checked.
Initializer lists are constructor-only
: super(…) / : this(…) delegation belongs to constructors (see Classes). On a free function, method, or destructor it is a parse error rather than a silent no-op:
function setup() : super(1) { return 3; }jaiscript: parse failed:
<script>:1:18: error: constructor initializer lists (': super(...)' / ': this(...)') are only allowed on constructors
function setup() : super(1) { return 3; }
^Function-typed variables and fields
function name = expr; declares a variable that holds a callable or null (the checker rejects any other value at load: cannot assign 'int' to 'cb' declared 'function', and a value it cannot see raises at the store: Cannot assign int to 'cb' (declared function)); function name; starts null for later assignment. Both work at top level, inside function bodies, in namespaces, and as class fields:
function scale = [](int v) { return v * 10; };
print(scale(4));
function cb;
cb = scale;
print(cb(5));
class Button {
function on_click = [](){ return "unbound"; };
}
auto b = Button();
b.on_click = [](){ return "clicked!"; };
print(b.on_click());40
50
clicked!Lambdas and captures
Capture lists mean what they mean in C++: [] captures nothing, [x] copies, [&x] shares, and naming a local the list does not admit is an error. One difference: a program-scope variable is never captured; the lambda reads it when it runs, unless the list copies it ([g]).
C++11-style lambdas with explicit captures: [x] copies, [&x] references, [=] copies everything used, [&] references everything used:
auto add = [](int a, int b) -> int { return a + b; };
print("add(5, 3) = {}", add(5, 3));
int multiplier = 10;
auto scale = [multiplier](int x) { return x * multiplier; };
multiplier = 999;
print("scale(4) = {}", scale(4));
int counter = 0;
auto inc = [&counter]() { counter++; };
inc(); inc(); inc();
print("counter = {}", counter);
function snapshot() {
auto a = 2; auto b = 3;
auto sum_all = [=]() { return a + b; };
auto set_a = [&]() { a = 100; };
set_a();
print("a = {}, sum_all() = {} (captured before set_a)", a, sum_all());
}
snapshot();add(5, 3) = 8
scale(4) = 40
counter = 3
a = 100, sum_all() = 5 (captured before set_a)Where the variable lives decides what a lambda sees, and there are exactly two cases. A program-scope (global) variable is never captured: every lambda, [] included, reads it when it runs. A function's local reaches a lambda only through the capture list:
auto n = 5;
auto f = []() { return n; };
n = 6;
print("top-level n is a global: f() = {}", f());
function g() {
auto m = 5;
auto h = [=]() { return m; };
m = 6;
return h();
}
print("function-local m is snapshot: g() = {}", g());top-level n is a global: f() = 6
function-local m is snapshot: g() = 5A capture list is exhaustive. [] captures nothing, [x] captures x and nothing else, and a lambda that names an enclosing local its list does not admit is a parse error that names the local:
function counter() {
int n = 0;
auto bump = []() { n += 1; };
bump();
return n;
}jaiscript: parse failed:
<script>:3:24: error: lambda capture list does not capture enclosing local 'n'; name it or use [=] or [&]
auto bump = []() { n += 1; };
^Use [=] or [name] for a snapshot of an enclosing local, [&] or [&name] to share its storage, and [x = expression] for an init-capture: the lambda's own x, initialized when the lambda is made, as auto x = expression; would be (a temporary is the member itself, a variable is copied). A copied capture belongs to the lambda: its body may write it, and the value it leaves is the one the next call sees, as in a C++ mutable lambda, so there is no mutable to write (A lambda takes no 'mutable': its copied captures are already its own, writable and kept between calls). Shared cells outlive the creating frame. A global or namespace variable is a live program binding, read when the lambda runs, unless the list copies it: [g] takes the global's value at creation.
int level = 1;
auto live = []() { return level; }; // a global: read when the lambda runs
auto pinned = [level]() { return level; }; // [g] copies the global now
auto scaled = [k = level * 10]() { return k; }; // init-capture: k is the lambda's own
level = 2;
print("live {} pinned {} scaled {}", live(), pinned(), scaled());
function tally() {
int hits = 0;
int misses = 0;
auto hit = [&hits]() { hits += 1; }; // names hits and nothing else
auto report = [hits, &misses]() { misses += 1; return hits; };
hit(); hit();
print("report sees hits = {} (copied before the hits)", report());
print("hits = {}, misses = {}", hits, misses);
}
tally();live 2 pinned 1 scaled 10
report sees hits = 0 (copied before the hits)
hits = 2, misses = 1What a copy capture copies is what auto x = name; would copy, as in C++. A class instance is a value, so [c] and [=] hold their own instance, made when the lambda is made: later writes to c do not reach the lambda, and the lambda's writes do not reach c. A bound C++ object held by value copies the same way, through its class's copy constructor (a class without one cannot be captured by copy: [&c] shares it). A handle copies the handle, so the lambda and the variable share one object: new T(), a shared_ptr<T> variable, and a host function that returns a std::shared_ptr<T>. The rule is the same through every nested lambda.
class Counter { int n = 0; }
string snapshot() {
Counter value = Counter();
auto handle = new Counter();
auto read = [value, handle]() { return to_string(value.n) + " " + to_string(handle.n); };
value.n = 5; // the lambda holds its own Counter
handle.n = 5; // the lambda shares this one
return read();
}
print(snapshot());0 5A reference capture can close a cycle: var again; again = [&]() { again(); }; stores in again a lambda that holds again. Such a cycle never outlives its frame on its own: when the frame exits, every shared local that only its own closures still hold is released, with whatever those locals own (a destructor runs at that point, as it would without the lambda). A closure that escaped the frame (returned, stored in a global or a field) keeps its shared locals for as long as anything holds it; once nothing does, the engine reclaims the cycle in a later sweep, at the latest when the engine is destroyed.
The JavaScript closure. An anonymous function(...) expression has no capture list: it closes over its scope by reference, as [&] does, so it sees writes made after it was created, nests and curries, and keeps the shared locals alive after the creating frame returns:
function make_account(int opening) {
int balance = opening;
var deposit = function(int amount) { balance += amount; return balance; };
var peek = function() { return balance; };
deposit(5);
balance += 100; // a later write: both closures see it
return [deposit, peek];
}
auto acct = make_account(10);
print("{} {}", acct[0](1), acct[1]());
var add = function(int a) { return function(int b) { return a + b; }; }; // curried
print(add(2)(3));116 116
5The global case has a consequence for callbacks that outlive the file that made them: a stored hook, a signal receiver. Such a closure reads a global when it fires, and on an engine that re-runs files, a later file (or the host) may have rebound that global by then. Hand context to the hook as a parameter and let the capture pin it; never read injected globals from a deferred closure.
Nested lambdas see the enclosing function's locals through every lambda between them: the lambda created in the declaring frame captures the variable, the ones in between carry it, and the innermost reads it, by value with [=] or a named value capture, write-through with [&]. Every intervening lambda must admit the name through a default or named capture, even if it does not read the name directly:
function make_adder(int base) -> var {
return [=](int step) -> var {
return [=]() -> int { return base + step; };
};
}
auto plus = make_adder(10)(5);
print("plus() = {}", plus());plus() = 15A named function declared inside a function body captures nothing. It sees the program scope (globals, functions, classes) and its own parameters and locals, never the enclosing function's locals: naming one is a parse error, Nested function 'helper' cannot use enclosing local 'y': a named function captures nothing; use a lambda with a capture ([&], [=] or [y]) or function(...) / =>. A lambda, an anonymous function(...) expression or an arrow function is the spelling for a helper that reads the enclosing frame. A coroutine declared in a function body follows the same rule: it takes what it needs as parameters.
Arrow functions
(params) => expr and (params) => { body } are function expressions, JavaScript's and C#'s arrow functions. The expression form returns its value; one untyped parameter may drop its parentheses (x => x * 2), and parameters may be typed ((int a, int b) => a + b, (Unit& u) => ...). An arrow function closes over its scope by reference, as function(...) does: it is a [&] lambda, so it reads and writes the locals around it and they outlive the function that made it.
auto twice = (x) => x * 2;
auto add = (int a, int b) => a + b;
auto shout = name => { string s = name; return s.to_upper() + "!"; };
function make_counter() {
int count = 0;
return () => { count += 1; return count; }; // shares count, as function(...) does
}
auto next = make_counter();
next();
print(twice(21), " ", add(2, 3), " ", shout("ada"), " ", next());
print(to_json(array::filter([3, 1, 2], x => x > 1)));42 5 ADA! 2
[3,2]A parenthesized expression stays one: (a + b) * 2 is arithmetic, because only a ) followed by => makes the group a parameter list. An arrow function declares no return type; spell a lambda ([&](int x) -> int { ... }) when the type matters, for instance to make map return an array<int>.
Functions are values; closures outlive their frame
Functions and lambdas can be stored in variables, arrays, and maps, passed as arguments, and returned. A method is not read as a value by writing obj.method: that is an error, and reflect::bind(obj, "method") or a lambda is the spelling (see Classes). A by-reference capture keeps the captured local alive after the declaring function returns. The classic counter factory works:
function apply_twice(f, x) { return f(f(x)); }
print(apply_twice([](v) { return v * 3; }, 2));
function make_counter() {
var count = 0;
return [&count]() { count += 1; return count; };
}
auto next = make_counter();
print("{} {} {}", next(), next(), next());
auto ops = {"double": [](x) { return x * 2; }, "square": [](x) { return x * x; }};
print("ops map: {}", ops["square"](7));18
1 2 3
ops map: 49Overloads and redefinition
C++'s rule: functions of one name with different signatures coexist, a call takes the best match, and a tie is an ambiguous call.
Functions of one name with different parameter lists are overloads, at program scope as in namespaces, methods and constructors. A signature is the parameter count and each parameter's declared type (an untyped one is var). Whether a parameter is a reference does not tell two overloads apart, so f(int& x) and f(int x) cannot both be declared (below), and default values are not part of a signature. A call takes the best match: every argument exact, then fewer and cheaper conversions, then fewer var parameters, then fewer defaults filled in, then the nearest base class:
int damage(int a) { return a; }
int damage(int a, int b) { return a + b; }
string damage(string who) { return who + " is hit"; }
class Pawn { } class Gator : Pawn { } class BigGator : Gator { }
string kind(Pawn p) { return "pawn"; }
string kind(Gator g) { return "gator"; }
print(damage(2), " ", damage(2, 3), " ", damage("gator"), " ", kind(BigGator()));2 5 gator is hit gatorTwo overloads whose parameters differ only in which ones are references, like f(var& x) and f(var x), could only tie, so the second definition is an error that names both: 'f(var x)' differs from 'f(var& x)' (line 1) only by reference; overloads must differ in parameter types or count. That holds within one run: a file, what it includes and the modules it first imports. A definition that arrives with a later execute, or from a module re-run because its file changed, is a redefinition and replaces the earlier one. Overloads that also differ in a parameter's type, like mix(var& r, string s) and mix(var r, int n), coexist.
Two overloads that match a call equally well are an ambiguous call, an error that names both: Ambiguous call to 'hit' with (int, int): 'hit(int, float)' and 'hit(float, int)' match equally. The checker reports it at load when it knows the argument types, and the call raises it otherwise, for free functions, namespace functions, methods and constructors alike.
A second definition of the same signature in one file is an error: the checker reports it at load ('damage' is already defined (declared at 1:5)), and in a [[warn_mode]] region the second definition raises when it runs ('damage(int a)' is already defined (line 1)). Another unit's definition of the same signature replaces it, which is how hot reload swaps a function, and a unit that runs again replaces or drops only the overloads it declared itself: an overload its new text no longer declares is gone, another unit's stays. A coroutine does not overload; a function and a coroutine of one name in one file with different parameter lists replace each other, and the checker notes it as a warning (duplicate definition of function 'f' replaces the earlier one (a coroutine does not overload)). With the same parameter list the second is the redefinition error ('f' is already defined (declared at 1:15)).
Recursion
function fib(int n) -> int {
if (n < 2) { return n; }
return fib(n - 1) + fib(n - 2);
}
auto out = [];
for (int i = 0; i < 10; ++i) { out.push(fib(i)); }
out.join(", ");== result: 0, 1, 1, 2, 3, 5, 8, 13, 21, 34Each engine owns its call-depth limits: by default, 256 active calls on the interpreter and 250,000 on the VM. The host can configure either limit between executions with eng->max_call_depth(jai::backend_type::interpreter, 256) or eng->max_call_depth(jai::backend_type::vm, 250000), and read it with the one-argument overload. Values must be positive and no greater than INT_MAX; parallel workers snapshot the settings at region entry. On the VM the cap is a backstop: the frames' storage counts toward the host's memory cap (charged every 10,000 levels) and the execution budget is sampled at every call, so a runaway recursion usually stops on one of those. Exceeding a cap raises the catchable error Maximum recursion depth exceeded - possible infinite recursion. The interpreter recurses on the native stack, and a headroom guard stops it first when the host thread runs low, with its own catchable text, Native stack exhausted before the call-depth cap: the host thread's stack bounds this recursion. On a 1 MB thread (the standalone runner's, a default Windows main thread) that is about 150 to 200 interpreter levels, depending on the function, so deep recursion belongs on the VM or on a host thread with a larger stack; raising the configured cap does not move the guard. See Safety Rails for the host controls and portability limits.
VM script-to-script calls (functions, methods, constructors, lambdas, function values, default arguments, bound methods and callable objects) run as in-loop frames on the heap, about 1 to 3 KB a level, so the cap alone bounds them. A recursion that passes through C++ on every level (a host callback, a builtin's callback such as map or sort, an operator overload, a _get_/_set_ accessor, a field initializer that constructs) re-enters the VM natively each level and is bounded by the host thread's stack: it raises Native stack exhausted before the call-depth cap: the host thread's stack bounds this recursion at the re-entry. A bare VM self tail call (return f(args) outside any try) reuses the live frame: accumulator-style tail recursion runs in constant space without increasing call depth. The execution budget still stops a runaway tail loop. The interpreter does not perform this tail-call optimization.
Variadics live on the host side
Script functions have fixed arity (plus defaults). Variadic functions are provided by the host via engine::add_variadic_function, which receives all arguments as a vector. print and format are exactly this:
eng->add_variadic_function("sum_all", [&eng](const std::vector<jai::script_value>& args) {
jai::script_int total = 0;
for (auto& a : args) total += a.as_int();
return jai::script_value(total, eng.get());
});
// script: sum_all(1, 2, 3, 4) -> 10