I love named args (or keyword arguments in some places). If you don’t know them, this is how they work in Ruby:
def foo(a:, b:, c:)
puts a,b,c
end
foo(b: 2, c: 3, a: 1)
# => 1, 2, 3
It’s nice that we can pass arguments in any order, but I think they’re
particularly helpful to understand what the argument even is. See this example
from Ruby’s Object#respond_to?
method:
object.respond_to?(:each, true)
What does true
mean here? Well, there’s no way to know without looking at the
docs. If it used a named argument, instead:
object.respond_to?(:each, include_private: true)
Named arguments make our code easier to read and understand. Yay! π
Wasn’t this about Rust?
So yeah, named args are cool and all, but Rust doesn’t have them. How do rustaceans deal with this?
Let’s consider this example: a function that filters a list of users based on
the specified criteria. Think of something like Active Record’s where
method.
Some of our goals with that function are:
- Being able to make different combinations of arguments.
- Pass the arguments in any order.
- Provide default values for the arguments.
- Be type-safe. In other words, delegate work to the compiler as much as possible.
- Make the code self-documenting.
So, what are our options?
Do nothing
The simplest option is just to let our IDE/Editors help us. Modern code editors can show us the function argument names with inline hints. With that, we could implement the search function only using positional arguments:
fn search_users(users: Vec<User>, include_inactive: bool, include_underage: bool) -> Vec<User> {
users
.into_iter()
.filter(|user| include_inactive || user.is_active())
.filter(|user| include_underage || user.is_adult())
.collect()
}
This is what it looks like in VS Code:
Pros
- No need to do anything π
- We are type-safe. There’s no way to pass a wrong argument type or number of arguments.
- We don’t need to remember the argument names.
Cons
- We need to remember the order of the arguments, but our editors can help with that.
- The code is not as self-documenting outside of an IDE (e.g., in a GitHub PR, or a blog post).
- There’s no way to provide default values for the arguments.
Hash Maps
If you have first-class syntax for hash maps, you can use them to pass a single positional argument, but treat it like a named argument. This is how JavaScript (and sometimes Ruby) do it:
def search_users(users, opts = {include_inactive: false, include_underage: false})
# ...
end
# Notice that Ruby has some syntax sugar for this,
# so you don't need to write the curly brackets
search_users(users, include_inactive: true)
Rust does not have a dedicated syntax for hash maps, but we could use the crate
maplit
to achieve a similar effect:
fn search_users(users: Vec<User>, opts: HashMap<&str, bool>) -> Vec<User> {
users
.into_iter()
.filter(|user| *opts.get("include_inactive").unwrap_or(&false) || user.is_active())
.filter(|user| *opts.get("include_underage").unwrap_or(&false) || user.is_adult())
.collect()
}
This is how it looks in action:
let result = search_users(users, hashmap! { "include_inactive" => true });
Pros
- Fairly easy to implement.
- We can store recurring options into variables to use them in multiple places.
- We can pass the arguments in any order.
- We can provide default values for the arguments.
- It looks like actual named arguments.
Cons
- One extra dependency.
- We need to remember the argument names (no help from the editor here).
- We can pass unexpected argument names (they won’t do anything), and the compiler won’t complain.
- It only works for one type at a time. To handle multiple argument types, the code would need to be much more complex.
Enums
Enums are one of the best features of Rust. They’re very versatile, and we can use them here to represent the different combinations of arguments we want.
enum SearchOption {
IncludeInactive(bool),
IncludeUnderage(bool),
}
fn search_users(users: Vec<User>, opts: Vec<SearchOption>) -> Vec<User> {
users
.into_iter()
.filter(|user| {
opts.iter().all(|opt| match opt {
SearchOption::IncludeInactive(include_inactive) => {
*include_inactive || user.is_active()
}
SearchOption::IncludeUnderage(include_underage) => {
*include_underage || user.is_adult()
}
})
})
.collect()
}
Using it looks like this:
let result = search_users(
users,
vec![
SearchOption::IncludeInactive(true),
SearchOption::IncludeUnderage(false)
]
);
Pros
- We can pass the arguments in any order.
- We can store recurring options into variables to use them in multiple places.
- The compiler has our back:
- Now, the actual options are types, so the compiler will catch any typos and wrong argument types.
- Once we add more options, the compiler will tell us if we forgot to handle any of them.
Because enums can hold data, we can build more complex options like
SearchOption::AgeWithin(18..30) // or SearchOption::RegisteredAfter("2021-02-02")
Cons
- No straightforward way to specify default values for the arguments.
Builders
Another favorite Rust feature of mine is its structs. We can leverage them to
create a builder pattern. We’ll also implement the Default
trait so we can have, well, default options.
#[derive(Default)]
struct SearchOptions {
pub include_inactive: bool,
pub age_within: Option<(u32, u32)>,
// ...
}
// The builder pattern π
impl SearchOptions {
fn include_inactive(mut self, include_inactive: bool) -> Self {
self.include_inactive = include_inactive;
self
}
fn age_within(mut self, min: u32, max: u32) -> Self {
self.age_within = Some((min, max));
self
}
}
fn search_users(users: Vec<User>, opts: SearchOptions) -> Vec<User> {
users
.into_iter()
.filter(|user| opts.include_inactive || user.is_active())
.filter(|user| {
if let Some((min, max)) = opts.age_within {
user.age >= min && user.age <= max
} else {
true
}
})
.collect()
}
And we use it like this:
let result = search_users(
users,
SearchOptions::default()
.include_inactive(true)
.age_within(5, 29),
);
This technique is quite common in the Rust ecosystem. For example, the
indicatif
crate uses it for styling progress bars, and the
Bevy game engine, which uses this pattern in quite a few places. Here’s one
example of Bevy’s game builder:
fn main() {
App::new()
.add_plugins(DefaultPlugins)
.add_startup_system(add_people)
.add_system(hello_world)
.add_system(greet_people)
.run();
}
Pros
- All of the pros of the enum approach.
- We can easily provide default values for the arguments.
Cons
- The compiler won’t tell us if we forget to add a builder method for a new option
- The compiler won’t tell us if we forget to implement the filter for a new option
Why Not Both? Enums + Builders
What if we combine the enum and the builder approaches? We might get the best of both worlds.
enum SearchOption {
IncludeInactive(bool),
AgeWithin(u32, u32),
}
struct SearchOptions {
options: Vec<SearchOption>,
}
impl SearchOptions {
fn default() -> Self {
Self {
options: vec![SearchOption::IncludeInactive(false)],
}
}
fn include_inactive(mut self, include_inactive: bool) -> Self {
self.options
.push(SearchOption::IncludeInactive(include_inactive));
self
}
fn age_within(mut self, min_age: u32, max_age: u32) -> Self {
self.options.push(SearchOption::AgeWithin(min_age, max_age));
self
}
}
fn search_users(users: Vec<User>, opts: &SearchOptions) -> Vec<User> {
users
.into_iter()
.filter(|user| {
opts.options.iter().all(|opt| match opt {
SearchOption::IncludeInactive(include_inactive) => {
*include_inactive || user.is_active()
}
SearchOption::AgeWithin(min_age, max_age) => {
user.age >= *min_age && user.age <= *max_age
}
})
})
.collect()
}
Pros
- All of the pros of the builder approach.
- The compiler will tell us if we forget to implement the filter for a new option
Cons
- The compiler still won’t tell us if we forget to add a builder method for a new option
We Are So Close, Please Say There’s a Way
Yes, there is. The previous example is almost perfect. I’m sure that with enough time and dark magic (i.e., macros) we could get it to work, but I’ll suggest a different path. Let’s keep it simple.
Instead of insisting on several builder methods, let’s create a single method that can add any option to the search options.
// All the other code is the same as before
impl SearchOptions {
// ...
fn add_option(&mut self, option: SearchOption) -> &mut Self {
self.options.push(option);
self
}
}
And we can use it like this:
let result = search_users(
users,
SearchOptions::default()
.add_option(SearchOption::IncludeInactive(false))
.add_option(SearchOption::AgeWithin(4, 99)),
);
That’s it.
Pros
- All of the pros of the previous approach.
- Adding new search options is easy and safe.
- Less code to write.
Cons
- Less cool, I guess?
So, Do We Need Named Arguments?
Maybe Rust will never get named arguments. The question is, do we need them? Would they make our code better? Adding a new feature to a language incentives developers to write code in a specific way. So, would named arguments make us write better code?
For simple methods, I think they’re valuable. It’s a simple enough way to improve the developer experience. When things get more complex, though, I think they can do more harm than good.
Often, in the Ruby world, I’ve seen methods taking lots of arguments to a point that they sort of create this black hole where you can just keep adding more behavior. That’s why one of Sandi Metz’s rules is that a method should have no more than four parameters. With named arguments (in particular, with default values), it’s easy to keep adding more parameters creating some kind of abstraction. Another aspect of it is that they make it easier to overuse booleans, when you might not take one at all.
In a lot of ways, Ruby lets you use sharp knives like that. It’s up to you to be
wise and know when it’s an appropriate use case. Rust, in my opinion, falls more
on the side of being safe (although you can, with enough effort, create unsafe
behaviors), so the current approach of not having named arguments seems to go
along with the overall philosophy of the language.