Skip to content

Conversation

@trusch
Copy link

@trusch trusch commented Nov 13, 2025

Add Default Parameters and Named Arguments

This PR introduces default parameter values for function definitions and named argument support when calling functions. This feature is gated behind the default-parameters feature flag. I wanted this for a personal project and wasn't able to figure this out any other way than adding it directly to rhai.

Features

Default Parameter Values

Functions can now define default values for parameters using the = syntax:

fn add(a, b = 2, c = 3) { 
    a + b + c 
}

add(1)        // Returns 6 (b=2, c=3)
add(1, 5)     // Returns 9 (c=3)
add(1, 5, 7)  // Returns 13

Expression Defaults

Default parameter values can be any expression, including references to previous parameters, function calls, constants, and complex expressions:

// Reference to previous parameter
fn add(a, b = a + 1) {
    a + b
}
add(5)  // Returns 11 (5 + (5 + 1))

// Chained dependencies
fn multiply(a, b = a * 2, c = a + b) {
    a + b + c
}
multiply(3)  // Returns 18 (3 + 6 + 9)

// Function calls in defaults
fn double(x) { x * 2 }
fn calc(a, b = double(a), c = a + b) {
    a + b + c
}
calc(7)  // Returns 42 (7 + 14 + 21)

// Constants in defaults
const MULTIPLIER = 3;
fn calc(a, b = a * MULTIPLIER) {
    b
}
calc(7)  // Returns 21

// Complex expressions
fn process(text, length = text.len(), doubled = length * 2) {
    doubled
}
process("hello")  // Returns 10

// Conditional expressions
fn clamp(value, min = 0, max = if value < 100 { 100 } else { value * 2 }) {
    if value < min {
        min
    } else if value > max {
        max
    } else {
        value
    }
}

Named Arguments

When calling functions, you can specify arguments by name, allowing you to skip parameters with defaults:

fn greet(name, prefix = "Hello", suffix = "!") {
    prefix + ", " + name + suffix
}

greet("World")                    // "Hello, World!"
greet("World", "Hi")              // "Hi, World!"
greet("World", suffix = "?")      // "Hello, World?"
greet("World", "Hi", suffix = "?") // "Hi, World?"

Closures with Expression Defaults

Closures support default parameters and can capture outer scope variables in their default expressions:

// Closure with expression defaults
let f = |a, b = a * 2| a + b;
f.call(6)  // Returns 18 (6 + 12)

// Closure capturing outer variable in default
let multiplier = 10;
let f = |x, y = x * multiplier| x + y;
f.call(5)  // Returns 55 (5 + 50)

// 'this' accessible in closure default parameters
let obj = #{
    value: 42,
    process: |multiplier = this.value * 2| multiplier
};
obj.process()  // Returns 84

Supported Use Cases

  • Regular functions with default parameters
  • Anonymous functions (closures) with default parameters: let f = |a, b = 2| { a + b }
  • Method calls with default parameters
  • All parameter types can have defaults (integers, floats, strings, booleans)
  • Expression defaults referencing previous parameters, function calls, constants
  • Mix of positional and named arguments (positional must come before named)
  • All parameters can have defaults: fn add(a = 1, b = 2) { a + b }
  • Nested function calls with defaults
  • Recursive functions with defaults
  • Complex function bodies using default parameters
  • Closures capturing outer scope in default expressions
  • this in closure defaults for method-like behavior

Syntax Rules

  1. Positional arguments must come before named arguments:

    fn add(a, b = 2, c = 3) { a + b + c }
    add(1, c = 10)     // ✅ OK
    add(1, b = 5, 10)  // ❌ Error: positional after named
  2. Arguments cannot be provided both positionally and by name:

    add(1, 5, b = 10)  // ❌ Error: 'b' provided twice
  3. All required parameters must be provided:

    fn add(a, b = 2, c) { a + b + c }
    add(1)  // ❌ Error: missing required 'c'
  4. Named arguments must match actual parameter names:

    fn add(a, b = 2) { a + b }
    add(1, d = 5)  // ❌ Error: unknown parameter 'd'
  5. Default expressions are evaluated at runtime for each function call, allowing them to depend on previously evaluated parameters and the current execution context.

Implementation Details

  • Feature flag: default-parameters (opt-in)
  • Backward compatible: Functions without defaults work exactly as before
  • Error handling: Comprehensive validation with clear error messages
  • Performance: Default expressions are evaluated at runtime when needed, allowing dynamic behavior based on function arguments and execution context

Testing

Comprehensive test suite included in:

  • tests/default_params.rs - Basic default parameters, named arguments, error cases
  • tests/default_expr.rs - Expression defaults, parameter dependencies, closures, complex expressions

Test coverage includes:

  • Basic default parameters
  • Named arguments
  • Mixed positional and named arguments
  • Expression defaults (arithmetic, comparison, string operations, function calls)
  • Parameter dependencies (chaining defaults)
  • Closures with defaults and outer scope capture
  • this in closure defaults
  • Constants in defaults
  • Complex expressions and nested function calls
  • Error cases (invalid defaults, missing args, duplicate args, etc.)

Example Usage

// Simple defaults
fn power(base, exponent = 2) {
    let result = 1;
    for i in 0..exponent {
        result *= base;
    }
    result
}

power(3)        // 9 (3^2)
power(3, 3)     // 27 (3^3)
power(3, exp = 4) // 81 (3^4)

// All defaults with expressions
fn create_point(x = 0, y = 0, z = x + y) {
    #{ x: x, y: y, z: z }
}

create_point()           // #{ x: 0, y: 0, z: 0 }
create_point(10)         // #{ x: 10, y: 0, z: 10 }
create_point(y = 20)     // #{ x: 0, y: 20, z: 20 }
create_point(10, y = 20) // #{ x: 10, y: 20, z: 30 }

// Expression defaults with dependencies
fn calc(a, b = a * 2, c = a + b) {
    a + b + c
}
calc(5)  // Returns 20 (5 + 10 + 15)

@schungx
Copy link
Collaborator

schungx commented Nov 13, 2025

Wow, I'll need some time to look thru this, but great work!

Actually a lot of people have asked for it, but I'm not quite sure named arguments is a good idea.

Currently default values can be simulated by have multiple definitions of the same function with different parameters.

How is it going to work in this case:

fn foo(x, y, z = 1) {}
fn foo(x, y) {}

foo(0, 1);   // which version is called?

@trusch
Copy link
Author

trusch commented Nov 13, 2025

@schungx to answer your question, the second one would be called. But that case unfortunately poke at a fragile point of the code. It doesn't really play well with function overloading. While playing with your question I ran into this here:

repl> fn foo(x,y,z=1) {print("first")}
repl> fn foo(x,y) {print("second")}
repl> foo(1,1)
second
repl> foo(1,2,3)
first
repl> foo(1,2,z=3)

foo(1,2,z=3)
^ Function not found: foo (unknown named argument 'z')

I feel this is not acceptable, wdyt? The call code is not the easiest to work with, but I will try to figure out whats going on there.

@trusch
Copy link
Author

trusch commented Nov 17, 2025

@schungx I fixed the issue, from my perspective it does now what it should. User defined functions that overload a function name and match from the parameters are now always preferred over functions that match because of default parameters.

@trusch
Copy link
Author

trusch commented Nov 17, 2025

I went wild and dropped the requirement that the defaults are just Dynamics. Instead now any Expression is supported and it's even possible to access previously defined arguments like in fn foo(a, b = a/2).... Checkout the tests added in the last commit, I'm going to update the PR description accordingly tomorrow morning :)

@schungx
Copy link
Collaborator

schungx commented Nov 18, 2025

User defined functions that overload a function name and match from the parameters are now always preferred over functions that match because of default parameters.

I assume you enhance the function resolution process by searching for default parameters after searching for Dynamic parameters?

I can think of a scenario: when I call func with two arguments, Rhai searches for functions with arity two. If it failes, does it then search for functions of arity three with one default parameter? How about four with two defaults? When do we stop?

And how do you precalculate the call hashes for so many arities?

Finally I suppose it doesn't work with call_fn?

Also, I see that you haven't changed make_qualified_function_call, so it probably doesn't work with qualified function calls (e.g. hello::foo().

@trusch
Copy link
Author

trusch commented Nov 18, 2025

Yes, when no direct hit for a given name and arity is found, we search for functions with a greater arity. Currently it searches up to N+10 parameters, where N is 2 in your case. This is quite arbitrary, and perhaps it would make sense to crank this up to 32.

@schungx
Copy link
Collaborator

schungx commented Nov 18, 2025

Looking at this deeper, it seems like too ambitious a change at this point.

In particular, I am concerned about named arguments. They easily make a scripting API very brittle by hard-coding parameter names into all call sites. That exposes the internal structure of the function to public and thus make functions less self-contained (because parameter names are now part of the signature). Such a function is extremely costly to refactor... you change a parameter name, you possibly change ALL scripts that call this function. And it makes defining functions in Rust problematic because we must now provide all the parameter names...

Default parameters essentially make function arity variable... so the function's arity is no longer a defining factor, and it causes a caller/callee arity mismatch that would require manual searching to match. So you can't pre-calculate hashes for them. That means that all functions that use default parameters are automatically put on the "slow-path".

@trusch
Copy link
Author

trusch commented Nov 18, 2025

I also register functions with default parameters multiple times under hashes with all valid parameter counts. At some point I tried removing the guessing (that counting up to 10), but then some tests blew up and I kept it that way. Theoretically we should get a direct hit for a function with default parameters.

What do you mean with call_fn()?

@schungx
Copy link
Collaborator

schungx commented Nov 18, 2025

What do you mean with call_fn()?

You can Engine::call_fn a function directly, providing it with arguments.

@schungx
Copy link
Collaborator

schungx commented Nov 18, 2025

By the way, do you use Discord? Would be much easier for a conversation if we open up a thread inside the chats.

@trusch
Copy link
Author

trusch commented Nov 18, 2025

I don't have discord, but you can find me on telegram under the same handle @trusch.

I was able to remove the guessing code (that counting up to 10) because when making the function overloading right I somehow fixed the problem that was causing the need for this accidentally. So now we don't guess, we either find the function or we don't in the first lookup.

Good catch with the qualified function calls, fixed it.

Regarding the named arguments being a bad idea: I see your point that the argument names then become a part of the signature and that this causes more coupling between the function and the caller. But this is nothing bad inherently. Function developer and consumer just need to be aware of the fact, that changing parameter names may have consequences. After all its an opt-in feature, so when you as a developer of some scripting environment activates that feature, you also opt-in for this "restriction".

@schungx
Copy link
Collaborator

schungx commented Nov 18, 2025

I was able to remove the guessing code (that counting up to 10) because when making the function overloading right I somehow fixed the problem that was causing the need for this accidentally. So now we don't guess, we either find the function or we don't in the first lookup.

How did you do it? If I have foo(1,2), it would look for foo with two parameters. Then it'd need to look for 2 + N with N defaults...

I was concerned with the fact that all functions with default parameters are on the slow path, which may not be the case if you don't do an exhaustive search.

@schungx
Copy link
Collaborator

schungx commented Nov 18, 2025

Regarding the named arguments being a bad idea: I see your point that the argument names then become a part of the signature and that this causes more coupling between the function and the caller. But this is nothing bad inherently. Function developer and consumer just need to be aware of the fact, that changing parameter names may have consequences. After all its an opt-in feature, so when you as a developer of some scripting environment activates that feature, you also opt-in for this "restriction".

I'd suggest separating the default parameters features with the named parameters feature, and make them separate branches.

Because they are really two different language features.

Named parameters can be supported even without default parameters, and vice versa.

@trusch
Copy link
Author

trusch commented Nov 18, 2025

How did you do it? If I have foo(1,2), it would look for foo with two parameters. Then it'd need to look for 2 + N with N defaults...

When registering the functions in module/mod.rs, I register them for each valid parameter count. So for foo(a = 1, b =2) I register hash("foo", 0), hash("foo", 1), hash("foo", 2).

I'm not sure splitting the two features really make sense. I get where you are coming from, but default parameters alone don't make much sense if you can't leave out some of the defaults when calling and then specifying a later one. Like in fn foo(a = 1, b = 2, c = 3). Without named parameters you could only call foo(), foo(a), foo(a,b), foo(a,b,c), but never foo(a, c) where we take b from the defaults. Basically when you have a list of default parameters you are forced to manually specify all parameters up to the one you actually want to set.

Lets have a more plastic example.

fn create_sound(freq = 800., amp = 0.2, release = 0.1) { ... }

// without named parameters
create_sound();                    // 800 0.2 0.1
create_sound(900.);             // 900 0.2 0.1
create_sound(800, 0.3)        // 800 0.3 0.1
create_sound(800, 0.2, 0.2) // 800 0.2 0.2

// with named parameters
create_sound()                       // 800 0.2 0.1
create_sound(freq = 900.)     // 900 0.2 0.1
create_sound(amp = 0.3)      // 800 0.3 0.1
create_sound(release = 0.2) // 800 0.2 0.2

I feel this interacts really nicely and basically only makes sense together from my perspective. Without it the default parameters would just be syntactic suggar for function overloading

@schungx
Copy link
Collaborator

schungx commented Nov 20, 2025

When registering the functions in module/mod.rs, I register them for each valid parameter count. So for foo(a = 1, b =2) I register hash("foo", 0), hash("foo", 1), hash("foo", 2).

Ah. Gotcha.

In that case, assuming we'd support only literals for default values (not expressions at this time), then it would actually be a relative small change to the code base to support this feature. Essentially it would almost seem like we're registering a curried function instead of a standard function.

I'm not sure splitting the two features really make sense.

It does. Because in many practical use cases, it is often to have one optional parameter or some sort of a flag/config that can be left out.

For example, a sort function that can optionally specify a compare function. Or some mapping operation that has a default action covering 80% of the common cases.

Having more then one optionals, we're really talking about a "property bag". In which case it would be much easier to pass in a map named options then make that map optional.

@schungx
Copy link
Collaborator

schungx commented Dec 10, 2025

Revisiting this... I am still of the opinion that we should split default parameters from named parameters.

The named parameters can be refactored this way, which is actually more JavaScript-y:

fn create_sound(first_arg) {
    if type_of(first_arg) == "map" {
        make_sound(freq.freq ?? 800, freq.amp ?? 0.2, freq.release ?? 0.1)
    } else {
        make_sound(first_arg, 0.2, 0.1)
    }
}

fn create_sound(freq = 800, amp = 0.2, release = 0.1) {
    make_sound(freq, amp, release)
}

// with named parameters
create_sound()                       // 800 0.2 0.1
create_sound(900);             // 900 0.2 0.1
create_sound(800, 0.3)        // 800 0.3 0.1
create_sound(800, 0.2, 0.2) // 800 0.2 0.2
create_sound(#{ freq: 900 })     // 900 0.2 0.1
create_sound(#{ amp: 0.3 })      // 800 0.3 0.1
create_sound(#{ release = 0.2 }) // 800 0.2 0.2
create_sound(#{ freq: 900, amp: 0.3, release = 0.2 }) // 900 0.3 0.2

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants