Functions & Lambdas​

A function is a value: store it, pass it, return it. The rules that govern the chapter:

  • A function is declared in C++'s shape (int f(int a)) or JavaScript's (function f(a)). The language blends the two on purpose, and the spellings mean the same.
  • A typed parameter or return is a typed slot: it converts or rejects exactly as a declaration of that type does.
  • The checker checks every call it can resolve at load. A call through a variable is checked when it runs.
  • A lambda's capture list says what it captures: [x] copies, [&x] shares. Arrow functions and function(...) expressions share everything, as [&] does.
  • Functions of one name with different parameter lists overload.

Declaration forms​

JaiScript blends C++ and JavaScript, so most things about a function have a spelling from each. The two spellings in a row mean the same thing and mix freely; a type, where one is written, is enforced.

One behavioral difference: a lambda captures only what its list names ([] captures nothing, [x] copies x, [&x] shares it), while an arrow function or a function(...) expression captures every local it uses by reference, as a [&] lambda does.

C++ spellingJavaScript spelling
functionint add(int a, int b) { ... }function add(int a, int b) -> int { ... }
inferred return typeauto add(int a, int b) { ... }, auto add(int a, int b) -> auto { ... }function add(int a, int b) { ... }
trailing return typeauto add(int a, int b) -> int { ... }function add(int a, int b) -> int { ... }
dynamic parametervar xx
dynamic resultvar pick(bool flag) { ... }function pick(flag) { ... }
no resultvoid hello(string who) { ... }function hello(who) { ... } with no return
anonymous function[](int v) -> int { return v * v; }, with a capture list (below)(v) => v * v, v => v * v, (v) => { ... }, function(v) { ... } (below)
function held in a variableauto f = add;, function f = add;var f = add;, let f = add;, const f = add;
methodint heal(int by) { ... }, static int limit() { ... }function heal(by) { ... }, static function limit() { ... }
coroutinecoroutine int countdown(int from) { ... }coroutine function countdown(from) { ... }, coroutine countdown(from) -> int { ... }

A parameter may also be auto x (locked to its argument's type), and a bare -> before the { infers the return type.

jaiscript
int twice(int v) { return v * 2; }
auto mul(int a, int b) { return a * b; }
void hello(string who) { print("hello ", who); }
function add(a, b) { return a + b; }
function scale(float f, int k) -> float { return f * k; }
auto area(int w, int h) -> int { return w * h; }
auto square = [](int v) -> int { return v * v; };
auto half = (int v) => v / 2;
var negate = function(int v) { return -v; };
class Counter {
    int n = 0;
    int add(int by) { n += by; return n; }
    function clear() { n = 0; }
    static int limit() { return 100; }
}
coroutine int countdown(int from) { for (int i = from; i > 0; i--) { yield i; } }

hello("jai");
print(twice(21), " ", mul(4, 2), " ", add(2, 3), " ", scale(1.5, 4), " ", area(2, 3));
print(square(7), " ", half(9), " ", negate(5), " ", Counter::limit());
for (auto v : countdown(3)) { print(v); }
auto ops = {"double": (x) => x * 2, "square": square};      // functions are values
print(ops["double"](7), " ", ops["square"](3));
output
hello jai
42 8 5 6.0 6
49 4 -5 100
3
2
1
14 9
  • A method is not read as a value by writing obj.method; use reflect::bind(obj, "method") or a lambda (Classes).
  • A constructor initializer list (: super(...), : this(...)) on anything but a constructor is a parse error: constructor initializer lists (': super(...)' / ': this(...)') are only allowed on constructors.
  • A script function has a fixed parameter count, plus defaults. Variadic functions such as print come from the host (below).

Return types​

CaseResult
leading and trailing types both writtenlegal only when they agree (int f() -> int). int f() -> string is the parse error conflicting return types 'int' and 'string' (leading and trailing '->' types must match).
a value of another type, known to the checkerconverts by the conversion table (float f() returning 3 yields 3.0), or fails the load: cannot return 'string' from 'f' declared to return 'int'.
a value that came through a varconverts at the return statement, inside the function, as in C++. A failure raises there: Cannot return string from 'f' (declared -> int).
a value returned from a void functiona load error
an int, float, bool, char, string or reference result that may fall off the end, or return; in onea load error: 'f' declared to return 'int' may reach the end of its body without returning. A lambda with a declared return type follows the same rule.
a class, handle or container result that may fall off the enda warning; such a call yields null
a coroutinecalling one yields a handle; of its yield and return types only a float into an int is checked (Coroutines)
jaiscript
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(true), " ", parse_level("seven"));
output
caught in parse_level: Cannot return string from 'parse_level' (declared -> int)
1 0

Parameters and defaults​

ParameterMeaning
int xtyped: converts its argument as int x = argument; would (a float or a string is rejected, and so is a number into a string parameter)
x, var xdynamic: a var local initialized from the argument
auto x, :xlocked to the argument's type, as auto locks to its initializer's
int: nshorthand for int n
int& x, var& x, Creature& cbinds the caller's variable. A literal or temporary binds only when the function never writes through the parameter (Value Semantics).
int x = 2a default; every parameter after it needs one too

A by-value parameter holds a copy of its argument, and a by-value return hands out a copy. A class instance or a bound C++ object is copied, so writes stay in the callee; a handle (new T(), a shared_ptr<T>) is copied and shares its object. To write into the caller's object, take it by reference.

Defaults work on functions, lambdas, methods, static methods, constructors and namespace functions. A default is evaluated at each call that leaves it out, and binds as the argument it stands in for:

  • A by-value default converts like an argument: float p = 2 holds 2.0.
  • A reference default names storage: int& p = total writes total, and int& p = 5 is the load error cannot bind non-lvalue default to reference parameter 'p'. const int& p = 5 binds a temporary.
  • A default cannot name another parameter. function f(a, b = a * 2) is the load error default value of 'b' names parameter 'a': a default cannot read a parameter. Compute such a value in the body.
jaiscript
function greet(name, string greeting = "hello") { return greeting + " " + name; }
int square(int: n) { return n * n; }
auto id = [](:x) -> { return x; };
void heal(int& hp, int amount = 25) { hp += amount; }
int health = 50;
heal(health);
print(greet("world"), " / ", greet("world", "salve"), " / ", square(6), " / ", id("same"), " / ", health);
output
hello world / salve world / 36 / same / 75

Calls: what is checked, and when​

A call to a function the program declares is resolved at load: the argument count (defaults included) and each argument against its parameter. Every such error in a file is listed at once:

jaiscript
int fib(int n) { return n < 2 ? n : fib(n - 1) + fib(n - 2); }
print(fib(10, 2));
print(fib("ten"));
output
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"));
                   ^
  • Through a function held in a variable the same mistakes are run-time errors, and catchable: 'fib' expects 1 argument, got 2, Cannot pass string to parameter 'n' of 'fib' (declared int).
  • A var argument to a direct call is converted when the call runs, and raises there if it does not fit.
  • A reference parameter takes a variable of its type (cannot bind 'float' to 'x' declared 'int&'), or a literal when the callee never writes through it ('f' writes through parameter 'x' (line 1); pass a variable).
  • A call to an overloaded name is an error only when no overload fits.
  • Host (C++) functions are checked for argument count and for the certain cases (Static Checking). Chapter 13 lists everything the checker sees.

A call evaluates its callee first, then its arguments left to right, and each by-value argument takes its value as it is evaluated (Evaluation order): f(swap()) calls the f held before swap() replaced it. The rule covers functions, lambdas, methods, constructors and new, and adds:

  • A conversion the binding runs for one parameter, such as a converting constructor, runs after every argument has been taken.
  • A constructor takes its arguments before its field initializers and initializer list run.
  • A coroutine takes its arguments at the call and binds them at its first next().
  • A host function receives scalars, strings and elements by value the same way. An instance, array or map argument reaches it as the object named: a C++ T& parameter works on that object, and a by-value C++ parameter copies it when the function is entered.

Every call is also a point where the execution budget is checked.

[[nodiscard]]: a result the caller must use​

[[nodiscard]] before a function, a method (static too), a coroutine or a namespace member makes a statement that drops its result a load error, as it does either arm of a conditional written as a statement. Keep a result you do not want in the placeholder _:

jaiscript
[[nodiscard]] int checked_add(int a, int b) { return a + b; }
class Wallet {
    int gold = 3;
    [[nodiscard]] bool spend(int n) {
        if (n > gold) { return false; }
        gold -= n;
        return true;
    }
}
Wallet w;
auto paid = w.spend(5);          // kept
auto _ = w.spend(1);             // dropped on purpose
auto _ = w.spend(1);             // _ is declared again freely
print(checked_add(2, 3), " ", paid, " ", w.gold);
output
5 false 1

A dropped result fails the load: the result of 'checked_add' is discarded, but 'checked_add' is [[nodiscard]]: use it, or keep it in the placeholder (auto _ = checked_add(...);).

  • Where it is checked. At every call the checker resolves at load: a function of the program, a method of a class it knows, a namespace member, a builtin, and a host function marked with engine::nodiscard_function (Host API). A call through a function value or a var receiver is not checked, at load or at run time. A call to an overloaded name fails when every overload it can reach is [[nodiscard]]. A program's last expression is the value of execute(), so it is not discarded.
  • Where it may be written. Anywhere else it is a parse error ([[nodiscard]] applies to a function or a method), and so is a void function ([[nodiscard]] function 'f' returns void: there is no result to keep).
  • The placeholder. There is no cast to void; auto _ = f(); is the spelling. As in C++26, a local or a structured binding named _ may be declared any number of times in a scope, each living to the scope's end. Once a scope declares more than one, naming _ is a load error ('_' is declared more than once in this scope, so it names none of them: give the variable you use its own name). A single _ is an ordinary variable, and a parameter list declares _ once.
  • The library's copying functions are [[nodiscard]]: string::to_lower, to_upper, trim, replace, repeat, pad_left, pad_right, append; array::append, concat, slice, filter, map, sort, reverse; map::filter. The error names the in-place method: the result of 'string::to_upper' is discarded: it returns a new value and leaves its argument unchanged, so the statement does nothing; keep the result, or call the in-place method (name.to_upper(...)).

Function-typed variables and fields​

function name = expr; declares a variable that holds a function or null, and function name; starts null. It works at top level, in a function body, in a namespace and as a class field. Any other value is rejected, at load (cannot assign 'int' to 'cb' declared 'function') or at the store (Cannot assign int to 'cb' (declared function)).

jaiscript
function scale = [](int v) { return v * 10; };
function cb;
cb = scale;
class Button { function on_click = []() { return "unbound"; }; }
auto b = Button();
b.on_click = []() { return "clicked!"; };
print(cb(5), " ", b.on_click());
output
50 clicked!

Lambdas and captures​

Capture lists read as in C++: [] captures nothing, [x] copies, [&x] shares, and naming a local the list does not admit is an error. Three differences: a program-scope variable is never captured (the lambda reads it when it runs, unless the list copies it with [g]); a copied capture is writable and there is no mutable; and a copy of a lambda shares its captured state with the original.

CaptureWhat the lambda holds
[]nothing. Naming an enclosing local is the parse error lambda capture list does not capture enclosing local 'n'; name it or use [=] or [&].
[x], [=]its own copy of x (of every local it uses), made when the lambda is made, as auto x = name; copies: a class instance or a bound C++ object is copied, a handle is shared. A C++ class without a copy constructor cannot be copied: share it with [&c].
[&x], [&]the variable itself. A shared local outlives the function that declared it.
[x = expr]the lambda's own x, initialized as auto x = expr; when the lambda is made
[g] on a globalthe global's value now. Uncaptured, a global or namespace variable is read when the lambda runs, by every lambda, [] included.
jaiscript
int level = 1;                                    // program scope
auto live = []() { return level; };               // read when the lambda runs
auto pinned = [level]() { return level; };        // [g] copies the global now
auto scaled = [k = level * 10]() { return k; };   // init-capture
level = 2;
print(live(), " ", pinned(), " ", scaled());

class Counter { int n = 0; }
function tally() {
    int hits = 0;
    Counter value = Counter();
    auto handle = new Counter();
    auto hit = [&hits]() { hits += 1; };
    auto read = [hits, value, handle]() { return [hits, value.n, handle.n]; };
    hit(); hit();
    value.n = 5;                                  // read holds its own Counter
    handle.n = 5;                                 // and shares this one
    print(hits, " ", read());
}
tally();

function make_counter() {
    int n = 0;
    return [=]() { n += 1; return n; };           // a copied capture is the lambda's own, kept between calls
}
auto next = make_counter();
auto same = next;                                 // a copy of a lambda shares its captures
print(next(), " ", next(), " ", same(), " ", next());
output
2 1 10
2 [0, 0, 5]
1 2 3 4
  • A copied capture belongs to the lambda. Its body may write it, and the next call sees the value, as in a C++ mutable lambda. Writing mutable is an error: A lambda takes no 'mutable': its copied captures are already its own, writable and kept between calls.
  • A copy of a lambda is the same closure. auto same = next; is a second name for one set of captures, where C++ would copy them: the counter above counts through both.
  • Nested lambdas reach an enclosing function's local through every lambda between them, and each of those must admit the name with a default or named capture, even if it does not use it: [=](int step) { return [=]() { return base + step; }; }. In a method the same holds for the instance: a field or method a nested lambda names by bare name is captured by every lambda around it, so () => { var inner = x => bonus + x; return inner(5); } reads the instance's bonus wherever it is called, and an enclosing [] is the capture-list error.
  • In a method, [this], [=] and [&] reach the instance's fields and methods by bare name; [] reaches neither (Name resolution in methods).
  • A named function declared inside a function body captures nothing. It sees the program scope and its own parameters and locals. Naming an enclosing local 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 coroutine declared in a function body follows the same rule.
  • Cycles. A reference capture can close a cycle: var again; again = [&]() { again(); };. When the frame exits, a shared local that only its own closures hold is released, and its destructor runs then. A closure that escaped keeps its shared locals while anything holds it; the engine reclaims an abandoned cycle in a later sweep, at the latest when it is destroyed.

A closure reads a global when it fires. A stored hook or a signal receiver can outlive the file that made it, and a later file or the host may have rebound that global by then. Pass context as a parameter or pin it in the capture list.

Arrow functions and function expressions​

(params) => expr, (params) => { body } and function(params) { body } have no capture list: each is a [&] lambda. It reads and writes the locals around it, sees later writes, and keeps those locals alive after the enclosing function returns.

jaiscript
auto add = (int a, int b) => a + b;
auto shout = name => string::to_upper(name) + "!";       // one untyped parameter needs no parentheses
function make_account(int opening) {
    int balance = opening;
    var deposit = function(int amount) { balance += amount; return balance; };
    balance += 100;                                       // a later write: the closure sees it
    return [deposit, () => balance];
}
auto account = make_account(10);
print(add(2, 3), " ", shout("ada"), " ", account[0](1), " ", account[1]());
var curried = function(int a) { return function(int b) { return a + b; }; };
array<var> readers = [];
for (int i = 0; i < 3; ++i) { readers.push(() => i); }
print(curried(2)(3), " ", readers[0](), readers[1](), readers[2]());
output
5 ADA! 111 111
5 012
  • A loop variable captured by reference is the iteration's own. The variable a C-style for declares is a fresh one each iteration for the closures that share it ([&], [&i], an arrow function, function(...)): its value is copied forward before the update runs, as JavaScript's let and Go do, and a closure that writes it changes the loop only in its own iteration. A variable declared in a loop body and a range-for variable are each iteration's own already.
  • An arrow function's parameters may be typed ((Unit& u) => ...), and it declares no return type. Write a lambda ([&](int x) -> int { ... }) when the type matters, for instance to make array::map return an array<int>.
  • A parenthesized expression stays one: only a ) followed by => makes the group a parameter list.

Overloads 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.

A signature is the parameter count and each parameter's declared type (an untyped one is var). References and default values are not part of it. Overloading works at program scope, in namespaces, for methods and for constructors. A call takes the best match, in this order:

  1. every argument exact;
  2. fewer and cheaper conversions;
  3. fewer var parameters;
  4. fewer defaults filled in;
  5. the nearest base class.
jaiscript
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()));
output
2 5 gator is hit gator
SituationResult
two overloads match a call equally wellAmbiguous call to 'hit' with (int, int): 'hit(int, float)' and 'hit(float, int)' match equally, at load when the checker knows the argument types, else when the call runs
two definitions differ only in which parameters are referencesan error: 'f(var x)' differs from 'f(var& x)' (line 1) only by reference; overloads must differ in parameter types or count. Ones that also differ in a type (mix(var& r, string s), mix(var r, int n)) coexist.
the same signature twice in one filea load error, 'damage' is already defined (declared at 1:5); in a [[warn_mode]] region the second definition raises when it runs ('damage(int a)' is already defined (line 1))
the same signature from another unit: a later execute, another file, a module re-run because its file changedreplaces the earlier one, which is how hot reload swaps a function. A unit that runs again replaces or drops only the overloads it declared itself.
a function and a coroutine of one namea coroutine does not overload. With different parameter lists they replace each other, with the warning duplicate definition of function 'f' replaces the earlier one (a coroutine does not overload); with the same list it is the redefinition error.

Recursion​

jaiscript
function fib(int n) -> int { return n < 2 ? n : fib(n - 1) + fib(n - 2); }
function sum_to(int n, int acc = 0) -> int { return n == 0 ? acc : sum_to(n - 1, acc + n); }
print(fib(20), " ", sum_to(100));
output
6765 5050
LimitVMInterpreter
call-depth cap250,000 active calls256
over the capMaximum recursion depth exceeded - possible infinite recursion, catchablethe same
what bounds script-to-script recursionthe cap: frames live on the heap, 1 to 3 KB a level, counted toward the host's memory cap (charged every 10,000 levels)the native stack: a guard raises the catchable Native stack exhausted before the call-depth cap: the host thread's stack bounds this recursion, at 150 to 200 levels on a 1 MB thread (the standalone runner's)
a bare self tail call, return f(args); outside any tryreuses its frame: tail recursion runs in constant space and adds no depthan ordinary call
  • 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) is bounded by the host thread's stack on the VM too, and raises the Native stack exhausted error.
  • Deep recursion belongs on the VM or on a host thread with a larger stack; raising the cap does not move the stack guard. The execution budget stops a runaway recursion or tail loop on either backend.
  • The host sets a cap between executions with eng->max_call_depth(jai::backend_type::vm, 250000) (a positive value up to INT_MAX) and reads it with the one-argument overload. Parallel workers take the settings at region entry. Safety Rails has the host controls and portability limits.

Variadics live on the host side​

A host function registered with engine::add_variadic_function receives all its arguments as a vector. print and format are such functions:

C++
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