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.

Trivia

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);

Only the right-most 16 parameters can be Dynamic

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

How is this implemented?

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:

  1. the function’s namespace, if any,
  2. the function’s name,
  3. number of arguments (its arity),
  4. 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.

OrderHash calculation method
1foo + 3 + i64 + string + bool
2foo + 3 + i64 + string + Dynamic
3foo + 3 + i64 + Dynamic + bool
4foo + 3 + i64 + Dynamic + Dynamic
5foo + 3 + Dynamic + string + bool
6foo + 3 + Dynamic + string + Dynamic
7foo + 3 + Dynamic + Dynamic + bool
8foo + 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.

What?! It calculates 65,536 hashes for each function call???!!!

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.

But then… beware module functions

The functions resolution cache resides only in the global namespace. This is a limitation.

Therefore, calls to functions in an imported 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.