Rust doesn’t support default function arguments. Or does it?

When I was rewriting my game to learn Rust, I came across a situation in which I wanted to create a function that had default arguments. This is usually helpful for functions that are widely used from different places and on specific occasions need to behave slightly differently (i.e. in these occasions you’d set a special option for which there’s a sensible default value that’s expected to be the right one for those which don’t care about it).

After some quick research, I found out that no, Rust doesn’t support this. Instead I discovered the Default trait, which I found pretty neat. In this post I’ll explain how I managed to use it to emulate that feature and create functions that have both required arguments, optional default valued arguments and, as a bonus, also named arguments!

A default argument example

Imagine a function that has two required arguments, and three optional arguments with default values:

def my_func(req1, req2, opt1=123, opt2="abc", opt3=false):
print("req1 is {}".format(req1))
print("req2 is {}".format(req2))
print("opt1 is {}".format(opt1))
print("opt2 is {}".format(opt2))
print("opt3 is {}".format(opt3))

This function could be called in many different ways, omitting any combination of the arguments opt1, opt2 and opt3. All of the following are valid ways to call it:

my_func("req1value", "req2value")
my_func("req1value", "req2value", 456)
my_func("req1value", "req2value", opt1=456, opt2="def", opt3=true)
my_func("req1value", "req2value", 456, "def", true)
my_func("req1value", "req2value", opt2="ghi")
my_func("req1value", "req2value", opt3=true, opt1=789)

This is what we’ll try to reproduce in Rust.

The Default trait

When you have a struct that you want to have a default instantiation value for each of its fields, you can implement the Default trait for it. That can be done by implementing the fn default() -> Self method that returns what should be the default value of the struct (alternatively, you can also use #[derive(Default)] as long as every field of your struct also implements Default). For example:

struct MyStruct {
a: i32,
b: String,
c: bool,
}
impl Default for MyStruct {
fn default() -> Self {
MyStruct {
a: 123,
b: String::from("abc"),
c: false,
}
}
}

Once you implement this trait, it allows you to call the default() method to instantiate the default value of your struct, but more interestingly, it allows for combination with the struct update syntax when constructing a partially default instance of the struct: you can specify just the fields that you want to have non-default values and then complete it with ..Default::default() indicating that the compiler should use the default value of the remaining non-specified fields (much like default arguments are expected to behave!). Examples:

let my_instance = MyStruct {
a: 456,
..Default::default()
};
let my_instance2 = MyStruct {
a: 789,
c: true,
..Default::default()
};

Using the Default trait for default arguments

The idea is to place all optional arguments inside a struct that implements Default and use this struct as a function argument. We’ll always instantiate this struct when calling the method, and that will provide almost identical behavior that one would expect from using default arguments (with just a little extra verbosity from instantiating the struct).

pub struct MyFuncOptionalArgs<'a> {
pub opt1: i32,
pub opt2: &'a str,
pub opt3: bool,
}
impl<'a> Default for MyFuncOptionalArgs<'a> {
fn default() -> Self {
MyFuncOptionalArgs {
opt1: 123,
opt2: "abc",
opt3: false,
}
}
}
pub fn my_func(
req1: &str,
req2: &str,
optional_args: MyFuncOptionalArgs
) {
println!("req1 is {}", req1);
println!("req2 is {}", req2);
println!("opt1 is {}", optional_args.opt1);
println!("opt2 is {}", optional_args.opt2);
println!("opt3 is {}", optional_args.opt3);
}

And then, here’s how we could call my_func as in those Python examples from above:

my_func("req1value", "req2value", MyFuncOptionalArgs::default());my_func(
"req1value",
"req2value",
MyFuncOptionalArgs {
opt1: 456,
opt2: "def",
opt3: true,
},
);
my_func(
"req1value",
"req2value",
MyFuncOptionalArgs {
opt2: "ghi",
..Default::default()
},
);
my_func(
"req1value",
"req2value",
MyFuncOptionalArgs {
opt3: true,
opt1: 789,
..Default::default()
},
);

As you can see, the opt1, opt2 and opt3 arguments now have default values when not specified. And they also behave like named arguments!

Bonus: Making every argument a named argument

Since these arguments are required and should always be specified, we need to create a separate struct for them that doesn’t implement Default. Then one could decide whether to have the optional arguments struct inside this one or keep it separate (we'll go with the former in the example).

pub struct MyFuncArgs<'a> {
pub req1: &'a str,
pub req2: &'a str,
pub optional_args: MyFuncOptionalArgs<'a>,
}
pub struct MyFuncOptionalArgs<'a> {
pub opt1: i32,
pub opt2: &'a str,
pub opt3: bool,
}
impl<'a> Default for MyFuncOptionalArgs<'a> {
fn default() -> Self {
MyFuncOptionalArgs {
opt1: 123,
opt2: "abc",
opt3: false,
}
}
}
pub fn my_func(args: MyFuncArgs) {
println!("req1 is {}", args.req1);
println!("req2 is {}", args.req2);
println!("opt1 is {}", args.optional_args.opt1);
println!("opt2 is {}", args.optional_args.opt2);
println!("opt3 is {}", args.optional_args.opt3);
}

And then, here’s how we would call my_func now:

my_func(MyFuncArgs {
req1: "req1value",
req2: "req2value",
optional_args: MyFuncOptionalArgs::default(),
});
my_func(MyFuncArgs {
req2: "req2value",
optional_args: MyFuncOptionalArgs {
opt1: 456,
opt2: "def",
opt3: true,
},
req1: "req1value",
});
my_func(MyFuncArgs {
req1: "req1value",
req2: "req2value",
optional_args: MyFuncOptionalArgs {
opt2: "ghi",
..Default::default()
},
});
my_func(MyFuncArgs {
req1: "req1value",
req2: "req2value",
optional_args: MyFuncOptionalArgs {
opt3: true,
opt1: 789,
..Default::default()
},
});

Now every argument is named! The required ones must be specified (otherwise the compilation will result in errors about missing struct fields), and the optional fields have default values for when they’re not specified.

An example in which this pattern was useful

  • Drawing sprites is an ubiquitous thing in my game that gets called from many different places.
  • There are arguments that I consider required, such as the source image of the sprite, the position it will be drawn in the screen, the size it will be drawn and the depth relative to other draw calls.
  • There are also arguments that I consider optional which are specified in just a few cases and that have sensible default values for all the other calls that don’t care about them. For example, the color that should multiply the sprite (in most cases the sprite’s original colors should be used), the rotation to rotate the sprite (in most cases there’s no rotation), the opacity(alpha) of the draw, whether just a partial region of the source image should be used, and so on.
  • Since many of these arguments have the same types (position and size for example both have type F2, which in my game is used for 2-dimensional floating point vectors), having named arguments in the call sites is super helpful to clarify which is which and avoid confusion.

Is this a zero cost abstraction?

I also created a simple benchmark to compare the performance of the default vs explicit arguments implementations. The benchmark initially showed quite promising results in which the performance of the two implementations seemed indistinguishable:

(default arguments) mean runtime: 59.268ms, std_dev: 3.343ms
(explicit arguments) mean runtime: 59.351ms, std_dev: 3.340ms

However, when I prevented both functions to get inlined, it seems the compiler wasn’t able to optimize the default arguments implementation as well as before, and its performance suffered heavily:

(default arguments) mean runtime: 883.652ms, std_dev: 31.416ms
(explicit arguments) mean runtime: 167.352ms, std_dev: 7.446ms

As a curiosity, another thing I noticed is that if the Default implementation instantiates a field by calling an expensive function, that call will happen even if that field is already being specified in your new struct instance with partial default fields. In the example below, expensive_vec_instantiation() still gets called even when the expensive_opt is already specified.

pub struct MyFuncOptionalArgs<'a> {
pub opt1: i32,
pub opt2: &'a str,
pub opt3: bool,
pub expensive_opt: Option<Vec<i32>>,
}
impl<'a> Default for MyFuncOptionalArgs<'a> {
fn default() -> Self {
MyFuncOptionalArgs {
opt1: 123,
opt2: "abc",
opt3: false,
expensive_opt: Some(expensive_vec_instantiation()),
}
}
}
...// This instantiation still calls expensive_vec_instantiation() even though it's
// not necessary.
let args = MyFuncOptionalArgs {
expensive_opt: None,
..Default::default()
};

I understand this is something tricky to optimize. Imagine expensive_vec_instantiation() had side effects (which may hint smelly code, but sure could happen), someone could be surprised when default() gets called for a partially default struct instantiation, but somehow expensive_vec_instantiation() doesn't get called. This would require additional special semantics (or even syntax) for the Default trait, which is not clearly a good thing. On top of that, expensive default fields are probably very rare (it's way way more likely that defaults are simpler and actually very cheap to instantiate).

Could it be zero cost?

Final thoughts

As for performance, we’ve seen that, if the modified function can be inlined, the cost of this default arguments abstraction is close to zero. Nonetheless, one could argue that, even if the function can’t be inlined, the additional performance cost might be acceptable. It’s really a question of reference: in the benchmark I performed, my_func is a function that performs very little work/processing, so the relative cost of default arguments implementation showed to be very significant (and would probably not be acceptable if that's all the application does and it's performance sensitive). On the other hand, the amount of work performed by a drawing call in my game is orders of magnitude greater, making the additional cost of the default arguments pattern practically negligible and a big win for the usability of the function.

Originally published at https://lucamoller.com.

I'm a Computer Engineer interested in all sorts of stuff. Check out my personal website at lucamoller.com