Rust doesn't support default function arguments. Or does it?
2021-08
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
I'll pick Python as the language for the example because I think it has a pretty flexible function argument scheme (it allows for multiple combinations of default valued arguments with ordered or named arguments).
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
In order to reproduce the example calls above, we'll try to use the Default
trait. So I'll provide a short explanation of how it works.
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
At this point, it's probably not hard to guess how we'll use the Default
trait to emulate 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
Named arguments can sometimes be great for making the code more readable. What if we wanted to also make the required arguments of our function behave like name arguments?
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
This pattern was very useful in the function that draws sprites in my game (see the DrawImageArgs struct):
- 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?
Unfortunately, it doesn't seem completely free. I've inspected and compared the generated assembly from a default arguments function implementation against the corresponding explicit arguments implementation and noticed that, when I don't allow the my_func
to be inlined, the main+my_func
assemblies have 25 additional instructions in the default implementation (131 vs 106 instructions in total). When inlining is allowed though, there was almost no difference: 285 vs 282 instructions in total (which seems very close to zero cost).
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?
To be honest, I'm currently not in a good position to tell. Although I don't see any fundamental limitations preventing the compiler to optimize the struct arguments with partial defaults to the same level of performance of explicit arguments, I have to admit I'm not familiar at all with the internals of the Rust compiler yet to tell whether that would be possible or practical.
Final thoughts
You probably noticed the default arguments pattern presented in this post is somewhat verbose. For a single function it requires defining two additional structs and implementing the Default
trait for one of them. I don't think that's a big deal though. I consider that default arguments should be used judiciously, so the price of having to add a few extra structs is probably a good incentive to make one think twice if it's really a good idea. If it is even then, it seems it's worth the price.
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.