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 andfunction(...)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++ spelling | JavaScript spelling | |
|---|---|---|
| function | int add(int a, int b) { ... } | function add(int a, int b) -> int { ... } |
| inferred return type | auto add(int a, int b) { ... }, auto add(int a, int b) -> auto { ... } | function add(int a, int b) { ... } |
| trailing return type | auto add(int a, int b) -> int { ... } | function add(int a, int b) -> int { ... } |
| dynamic parameter | var x | x |
| dynamic result | var pick(bool flag) { ... } | function pick(flag) { ... } |
| no result | void 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 variable | auto f = add;, function f = add; | var f = add;, let f = add;, const f = add; |
| method | int heal(int by) { ... }, static int limit() { ... } | function heal(by) { ... }, static function limit() { ... } |
| coroutine | coroutine 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.
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));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; usereflect::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
printcome from the host (below).
Return types
| Case | Result |
|---|---|
| leading and trailing types both written | legal 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 checker | converts 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 var | converts 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 function | a load error |
an int, float, bool, char, string or reference result that may fall off the end, or return; in one | a 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 end | a warning; such a call yields null |
| a coroutine | calling one yields a handle; of its yield and return types only a float into an int is checked (Coroutines) |
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"));caught in parse_level: Cannot return string from 'parse_level' (declared -> int)
1 0Parameters and defaults
| Parameter | Meaning |
|---|---|
int x | typed: 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 x | dynamic: a var local initialized from the argument |
auto x, :x | locked to the argument's type, as auto locks to its initializer's |
int: n | shorthand for int n |
int& x, var& x, Creature& c | binds the caller's variable. A literal or temporary binds only when the function never writes through the parameter (Value Semantics). |
int x = 2 | a 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 = 2holds2.0. - A reference default names storage:
int& p = totalwritestotal, andint& p = 5is the load errorcannot bind non-lvalue default to reference parameter 'p'.const int& p = 5binds a temporary. - A default cannot name another parameter.
function f(a, b = a * 2)is the load errordefault value of 'b' names parameter 'a': a default cannot read a parameter. Compute such a value in the body.
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);hello world / salve world / 36 / same / 75Calls: 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:
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"));
^- 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
varargument 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¶meter 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 _:
[[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);5 false 1A 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 avarreceiver 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 ofexecute(), 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 avoidfunction ([[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)).
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());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.
| Capture | What 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 global | the global's value now. Uncaptured, a global or namespace variable is read when the lambda runs, by every lambda, [] included. |
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());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++
mutablelambda. Writingmutableis 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'sbonuswherever 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.
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]());5 ADA! 111 111
5 012- A loop variable captured by reference is the iteration's own. The variable a C-style
fordeclares 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'sletand 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 makearray::mapreturn anarray<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:
- every argument exact;
- fewer and cheaper conversions;
- fewer
varparameters; - fewer defaults filled in;
- 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 gator| Situation | Result |
|---|---|
| two overloads match a call equally well | Ambiguous 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 references | an 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 file | a 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 changed | replaces 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 name | a 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
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));6765 5050| Limit | VM | Interpreter |
|---|---|---|
| call-depth cap | 250,000 active calls | 256 |
| over the cap | Maximum recursion depth exceeded - possible infinite recursion, catchable | the same |
| what bounds script-to-script recursion | the 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 try | reuses its frame: tail recursion runs in constant space and adds no depth | an ordinary call |
- A recursion that passes through C++ on every level (a host callback, a builtin's callback such as
maporsort, 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 theNative stack exhaustederror. - 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 toINT_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:
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