Optional function arguments

Why can't optional function arguments be truely optional?

fn myfn(a: i32, b: Option<i32>) -> i32 {
    a + b.unwrap_or(0)
}

// All arguments
myfn(1, 2); // 3

// only one argument (current Rust language design)
myfn(2, None); // 2

// only one argument (my proposal)
myfn(1); // 1

Why should we type None even when we know this is optional?

Firstly, there's a general tendency to avoid special-casing types if possible; if Rust was going to allow you to omit an argument and have the compiler fill in a None or similar, it'd probably be done via a mechanism that allows the bit it fills in to be something other than Option::None if desired (e.g. so that I can use my own enum where one variant is MyArg::Missing instead of Option).

Second, optional arguments in general create a bunch of interesting ambiguous cases to consider:

fn myfn2(arg1: String, arg2: Option<u32>, arg3: f64, arg4: Option<i32>);
fn myfn3(arg1: String, arg2: Option<u32>, arg3: Option<f64>, arg4: Option<i32>);
// Should the following type-check? If so, what should they expand to?
myfn2("a".to_string(), 0.0, Some(42)); // Skipped an option in the middle
myfn3("a".to_string(), Some(42), Some(42)); // Skipped the float
myfn3("a".to_string(), Some(1.2), Some(42)); // Skipped arg2
myfn3("a".to_string(), Some(42)); // Could be arg1 and arg2 or arg1 and arg4.
myfn3("a".to_string(), None, Some(42)); // Which arg does `None` bind to?

On top of that, there's type inference excitement to consider:

myfn3(string, number); // what type should be inferred for number?
myfn3(string, Some(42), number); // what type should be inferred for number?
myfn3(string, None, Some(number)); // what type should be inferred for number?

This is not to say that such a mechanism would never happen; just that there's a lot of details to work through to get it right.

5 Likes

I think the only way this could work (reasonably) is you're only allowed to omit arguments if you omit everything after, thus avoiding this ambiguity because the Nth argument (if present) always is the Nth parameter of the function (or requiring them to be named). That also sidesteps the type inference problem.

But I think this should be an opt-in on a per-function-definition, as you may not always want this behavior and prefer signature changes to result in hard errors most of the time (especially internally):

// Adding arg2 afterwards, but you WANT the user to think about
// whether he has to change his code, as None will not be the desired
// value for all places.
fn myfn1(arg1: String, arg2: Option<usize>);
// Opt-into allowing `myfn2("".into())` (using syntax from Python)
fn myfn2(arg1: String, arg2: Option<usize> = None);

It's the same reason why Rust doesn't allow omitting struct fields (without using ..Default::default()):

struct MyStruct {
    a: usize,
    b: Option<usize>,
}

// Not allowed because you didn't decide/think about if
// you really want b to be None.
MyStruct{a: 5}

This could also be useful for extending a function without needing a breaking change, as it allows exposing previously hidden/constant values without having to come up with a new name.

3 Likes

(Observation: this has come up many times before, and this thread seems like it's re-having some of the same discussions that it has previously. That's not an argument for or against such proposals, just that this same discussion has happened many many times.)

13 Likes

Just to help avoid going over old ground, the following discussions all appear relevant:

And a very similar syntax proposal is in Pre-RFC: make function arguments for Option-typed arguments - any proposal here should cover all the critiques made there.

4 Likes

Thank you.

I think this is this is the core Question that needs to be answered first (if the goal is to have optional arguments of any kind), as it's one of (if not the) most fundamental part of functions. So far I haven't seen much discussion regarding this. Most of the discussion has been around syntax.

My answer to this would be:

  • Compile the function as if all arguments are required and provided by the caller
  • For fn foo(u32, u32) and impl fn foo(u32, u32): Use the function directly
  • For fn(u32) use an wrapper that adds the default (thus the call-side doesn't need to be aware of optional arguments), it should probably be reused between all calls using this to reduce load on LLVM.
  • For impl Fn(u32) we can also use the (#[inline]) wrapper (probably easier to implement than manually ensuring the second argument is there). LLVM will most likely inline our function into the wrapper or the wrapper into the function.
// Or whatever syntax to denote something as optional
fn foo(a: u32, b: u32 = 0) {}

// Added by the compiler when used as a `fn(u32)` or `impl Fn(u32)`
fn foo__1(a: u32) {
    foo(a, 0)
}

When generics are involved, you also run into

The only way I see this working is that the definition provides a const initializing expression which is WF even in the generic definition, and a call omitting that argument is treated as supplying that const value. f is then a function name overloaded on airity, with all of the inference fallout that occurs from that. You can emulate this already by implementing FnOnce multiple times, or see this on stable by bounding a generic on FnOnce multiple times.

I expect a properly gated experimental impl could be accepted into the compiler, but nobody has made it their priority. There's a large backlog of accepted improvements to be worked on first.

7 Likes

TBH, functions and languages that do this seem to always end up needing things like

myfn(2, None, None, None, None, 7);

because the combination of positional and optional just never seems to go well.

14 Likes

It usually works well in languages with support for named parameters, like Scala.

1 Like

This is exactly why I said positional in my post.

Optional but nominal can work great -- indeed, that's exactly what https://github.com/rust-lang/rfcs/pull/3681#issuecomment-2344330061 is all about.

3 Likes

Rather than see optional positional arguments, I'd like to see keyword arguments. Then those could be optional (if a default value is provided) because their presence/absence wouldn't affect the interpretation of any other arguments.

Optional arguments also preclude overloading a function by arity. (Unless you're Swift... let's not be Swift, we don't need literally everything imaginable to be possible.) I think I'd much rather have arity-based overloading (currently fakeable with a trait implemented on tuples, but it's not exactly pretty).

I think this could be better now:

// if b is not given, use 0 instead
fn myfn(a: i32, b: i32 = 0) {
    a + b
}
myfn(1); //> 1
myfn(1, 2); //> 3

// Option<T> will be used for every argument
// that has no default value
fn myfn2(a: i32, b?: i32) {
    a + b.unwrap_or(0)
}
myfn2(1); //> 1
myfn2(1, 2); //> 3

// For optional arguments inbetween
fn myfn3(a: i32, b: i32 = 0, c: i32 = 0, d: i32) {
    a + b + c + d
}
myfn3(1,,, 2); //> 3
myfn3(1, 2,, 3); //> 6
myfn3(1, 2, 3, 4); //> 10

And this would compile to something like this:

fn myfn(a: i32, b: Option<i32>) {
    let b = b.unwrap_or(0);
    a + b
}
myfn(1, None);
myfn(1, Some(2));

fn myfn2(a: i32, b: Option<i32>) {
    a + b.unwrap_or(0)
}
myfn2(1, None);
myfn2(1, Some(2));

fn myfn3(a: i32, b: Option<i32>, c: Option<i32>, d: i32) {
    let b = b.unwrap_or(0);
    let c = c.unwrap_or(0);
    a + b + c + d
}
myfn3(1, None, None, 2);
myfn3(1, Some(2), None, 3);
myfn3(1, Some(2), Some(3), 4);

I think something like this shouldn't be that hard to implement as it's just a preprocessing operation.

With this approach, we wouldn't have problems with optional positional arguments.

The procmacro infrastructure provides everything needed to experiment with anything that can be implemented as a preprocesing operation - yes, having to mark your code with #![optional_argument_preprocessing] is a pain, but it would let you demonstrate (a) that it can be done in reasonable effort, (b) that it improves code considerably, and (c) that there's demand for such a change.

3 Likes

I find myfn3(1,,, 2); not really meaningfully different from myfn3(1, None, None, 2);. Both require me to look up the function in the docs and check "Wait, what am I skipping here again?". myfn3(1,,, 2); being arguably worse, since I have to squint harder and count commas.

Furthermore, the default values would become part of the function signature, in addition to the types. Updating a default values is a breaking change.

1 Like

If you just want to get rid of the Some():

1 Like