Closures
Many functions in the standard API expect function pointer as parameters.
For example:
// Function 'double' defined here - used only once
fn double(x) { 2 * x }
// Function 'square' defined here - again used only once
fn square(x) { x * x }
let x = [1, 2, 3, 4, 5];
// Pass a function pointer to 'double'
let y = x.map(double);
// Pass a function pointer to 'square' using Fn(...) notation
let z = y.map(Fn("square"));
Sometimes it gets tedious to define separate functions only to dispatch them via single function pointers – essentially, those functions are only ever called in one place.
This scenario is especially common when simulating object-oriented programming ([OOP]).
// Define functions one-by-one
fn obj_inc(x, y) { this.data += x * y; }
fn obj_dec(x) { this.data -= x; }
fn obj_print() { print(this.data); }
// Define object
let obj = #{
data: 42,
increment: obj_inc, // use function pointers to
decrement: obj_dec, // refer to method functions
print: obj_print
};
Syntax
Closures have a syntax similar to Rust’s closures (they are not the same).
|
param 1,
param 2,
…,
param n|
statement
|
param 1,
param 2,
…,
param n| {
statements…}
No parameters:
||
statement
|| {
statements…}
Rewrite Using Closures
The above can be rewritten using closures.
let x = [1, 2, 3, 4, 5];
let y = x.map(|x| 2 * x);
let z = y.map(|x| x * x);
let obj = #{
data: 42,
increment: |x, y| this.data += x * y, // one statement
decrement: |x| this.data -= x, // one statement
print_obj: || {
print(this.data); // full function body
}
};
This de-sugars to:
// Automatically generated...
fn anon_fn_0001(x) { 2 * x }
fn anon_fn_0002(x) { x * x }
fn anon_fn_0003(x, y) { this.data += x * y; }
fn anon_fn_0004(x) { this.data -= x; }
fn anon_fn_0005() { print(this.data); }
let x = [1, 2, 3, 4, 5];
let y = x.map(anon_fn_0001);
let z = y.map(anon_fn_0002);
let obj = #{
data: 42,
increment: anon_fn_0003,
decrement: anon_fn_0004,
print: anon_fn_0005
};
Capture External Variables
Closures differ from standard functions because they can captures variables that are not defined within the current scope, but are instead defined in an external scope – i.e. where the it is created.
All variables that are accessible during the time the closure is created are automatically captured when they are used, as long as they are not shadowed by local variables defined within the function’s.
The captured variables are automatically converted into reference-counted shared values.
Therefore, similar to closures in many languages, these captured shared values persist through reference counting, and may be read or modified even after the variables that hold them go out of scope and no longer exist.
let x = 1; // a normal variable
x.is_shared() == false;
let f = |y| x + y; // variable 'x' is auto-curried (captured) into 'f'
x.is_shared() == true; // 'x' is now a shared value!
f.call(2) == 3; // 1 + 2 == 3
x = 40; // changing 'x'...
f.call(2) == 42; // the value of 'x' is 40 because 'x' is shared
// The above de-sugars into something like this:
fn anon_0001(x, y) { x + y } // parameter 'x' is inserted
make_shared(x); // convert variable 'x' into a shared value
let f = anon_0001.curry(x); // shared 'x' is curried
Data races are possible in Rhai scripts.
Avoid performing a method call on a captured shared variable (which essentially takes a mutable reference to the shared object) while using that same variable as a parameter in the method call – this is a sure-fire way to generate a data race error.
If a shared value is used as the this
pointer in a method call to a closure function,
then the same shared value must not be captured inside that function, or a data race
will occur and the script will terminate with an error.
let x = 20;
x.is_shared() == false; // 'x' not shared, so no data races
let f = |a| this += x + a; // 'x' is captured in this closure
x.is_shared() == true; // now 'x' is shared
x.call(f, 2); // <- error: data race detected on 'x'