Dynamic
Parameters in Rust Functions
It is possible for Rust functions to contain parameters of type Dynamic
.
A Dynamic
value can hold any clonable type.
The push
method of an array is implemented as follows (minus code for safety protection
against over-sized arrays), allowing the function to be called with
all item types.
// 'item: Dynamic' matches all data types
fn push(array: &mut Array, item: Dynamic) {
array.push(item);
}
Precedence
Any parameter in a registered Rust function with a specific type has higher precedence over
Dynamic
, so it is important to understand which version of a function will be used.
Parameter matching starts from the left to the right. Candidate functions will be matched in order of parameter types.
Therefore, always leave Dynamic
parameters (up to 16, see below) as far to the right as possible.
use rhai::{Engine, Dynamic};
// Different versions of the same function 'foo'
// will be matched in the following order.
fn foo1(x: i64, y: &str, z: bool) { }
fn foo2(x: i64, y: &str, z: Dynamic) { }
fn foo3(x: i64, y: Dynamic, z: bool) { }
fn foo4(x: i64, y: Dynamic, z: Dynamic) { }
fn foo5(x: Dynamic, y: &str, z: bool) { }
fn foo6(x: Dynamic, y: &str, z: Dynamic) { }
fn foo7(x: Dynamic, y: Dynamic, z: bool) { }
fn foo8(x: Dynamic, y: Dynamic, z: Dynamic) { }
let mut engine = Engine::new();
// Register all functions under the same name (order does not matter)
engine.register_fn("foo", foo5)
.register_fn("foo", foo7)
.register_fn("foo", foo2)
.register_fn("foo", foo8)
.register_fn("foo", foo1)
.register_fn("foo", foo3)
.register_fn("foo", foo6)
.register_fn("foo", foo4);
The number of parameter permutations goes up exponentially, and therefore there is a realistic limit
of 16 parameters allowed to be Dynamic
, counting from the right-most side.
For example, Rhai will not find the following function – Oh! and those 16 parameters to the right certainly have nothing to do with it!
// The 'd' parameter counts 17th from the right!
fn weird(a: i64, d: Dynamic, x1: i64, x2: i64, x3: i64, x4: i64,
x5: i64, x6: i64, x7: i64, x8: i64,
x9: i64, x10: i64, x11: i64, x12: i64,
x13: i64, x14: i64, x15: i64, x16: i64) {
// ... do something unspeakably evil with all those parameters ...
}
TL;DR
Hash lookup
Since functions in Rhai can be overloaded, Rhai uses a single hash number to quickly lookup the actual function, based on argument types.
For each function call, a hash is calculated from:
- the function’s namespace, if any,
- the function’s name,
- number of arguments (its arity),
- unique ID of the type of each argument, if any.
The correct function is then obtained via a simple hash lookup.
Limitations
This method is fast, but at the expense of flexibility (such as multiple argument types that must map to a single version). That is because each type has a different ID, and thus they calculate to different hash numbers.
This is the reason why generic functions must be expanded into concrete types.
The type ID of Dynamic
is different from any other type, but it must match all types seamlessly.
Needless to say, this creates a slight problem.
Trying combinations
If the combined hash calculated from the actual argument type ID’s is not found, then the Engine
calculates hashes for different combinations of argument types and Dynamic
, systematically
replacing different arguments with Dynamic
starting from the right-most parameter.
Thus, assuming a three-argument function call:
foo(42, "hello", true);
The following hashes will be calculated, in order. They will be all different.
Order | Hash calculation method |
---|---|
1 | foo + 3 + i64 + string + bool |
2 | foo + 3 + i64 + string + Dynamic |
3 | foo + 3 + i64 + Dynamic + bool |
4 | foo + 3 + i64 + Dynamic + Dynamic |
5 | foo + 3 + Dynamic + string + bool |
6 | foo + 3 + Dynamic + string + Dynamic |
7 | foo + 3 + Dynamic + Dynamic + bool |
8 | foo + 3 + Dynamic + Dynamic + Dynamic |
Therefore, the version with all the correct parameter types will always be found first if it exists.
At soon as a hash is found, the process stops.
Otherwise, it goes on for up to 16 arguments, or at most 65,536 tries. That’s where the 16 parameters limit comes from.
Of course not. Don’t be silly.
Not every function has 16 parameters
Studies have repeatedly shown that most functions accept few parameters, with the mean between
2-3 parameters per function. Functions with more than 5 parameters are rare in normal code bases.
If at all, they are usually closures that capture lots of external variables, bumping up the
parameter count; but closures are always script-defined and thus all parameters are already
Dynamic
.
In fact, you have a bigger problem if you write such a function that you need to call regularly. It would be far more efficient to group those parameters into object maps.
Caching to the rescue
Function hashes are cached, so this process only happens once, and only up to the number of rounds for the correct function to be found.
If not, then yes, it will calculate up to 2n hashes where n is the number of arguments (up to 16). But again, this will only be done once for that particular combination of argument types.
The functions resolution cache resides only in the global namespace. This is a limitation.
Therefore, calls to functions in an import
ed module (i.e. qualified with
a namespace path) do not have the benefit of a cache.
Thus, up to 2n hashes are calculated during every function call. This is unlikely to cause a performance issue since most functions accept only a few parameters.