In Rust, when designing functions, it's common to pass arguments by reference to avoid unnecessary cloning or ownership transfer. However, you may have come across advice to avoid accepting types like &String
, &Vec
, or &Box
as function arguments. Instead, you’re encouraged to use more general types like &str
, &[T]
, or directly dereference the boxed type. Why is that?
In this article, we’ll explore why this advice exists and how following it can make your Rust code more idiomatic, flexible, and ergonomic.
The Root of the Problem: Redundancy and Limiting Flexibility
1. Redundancy in Indirection
Types like String
, Vec<T>
, or Box<T>
are already heap-allocated and provide indirection. When you take &String
, &Vec
, or &Box
, you're adding another layer of indirection unnecessarily.
Example:
fn print_length(string_ref: &String) {
println!("Length: {}", string_ref.len());
}
Here, &String
means you're passing a reference to a heap-allocated String
. But String
already has a concept of a borrowed view—&str
. A more idiomatic way is to write:
fn print_length(string_slice: &str) {
println!("Length: {}", string_slice.len());
}
Why is this better? We’ll get to that shortly.
2. Loss of Generality
Accepting &String
or &Vec<T>
restricts your function to only accept references to these specific types. This limits its usability with other types that can be borrowed in a similar way.
Consider &str
versus &String
. While &String
works only for references to String
, &str
can also be used with string literals (&'static str
), substrings, or any type that implements Deref
to str
.
Example:
fn print_message(message: &String) {
println!("{}", message);
}
fn main() {
let owned_string = String::from("Hello, world!");
print_message(&owned_string); // Works
let literal = "Hello, world!";
// print_message(literal); // ERROR: mismatched types
}
Now, if we refactor the function to accept &str
:
fn print_message(message: &str) {
println!("{}", message);
}
fn main() {
let owned_string = String::from("Hello, world!");
print_message(&owned_string); // Works
let literal = "Hello, world!";
print_message(literal); // Works!
}
This makes the function more versatile and idiomatic.
3. The Power of Deref Coercion
Rust’s deref coercion automatically converts a reference to a type (like &String
or &Vec<T>
) into a reference to its "inner" type (&str
or &[T]
). This is why you don’t need to explicitly write functions that accept &String
or &Vec
—the compiler will handle the conversion for you.
Example:
fn print_items(items: &[i32]) {
for item in items {
println!("{}", item);
}
}
fn main() {
let vec = vec![1, 2, 3];
print_items(&vec); // `&Vec<i32>` automatically coerced to `&[i32]`
}
You benefit from this powerful feature by accepting the most general form (&str
, &[T]
) as function arguments.
Best Practices for Function Arguments
1. Use Slices (&[T]
) Instead of &Vec<T>
A slice (&[T]
) represents a view into a contiguous sequence of elements, which is exactly what Vec
provides. By accepting slices, your function can work with any type that can be dereferenced into a slice, not just Vec
.
Example:
Instead of:
fn sum(vec: &Vec<i32>) -> i32 {
vec.iter().sum()
}
Write:
fn sum(slice: &[i32]) -> i32 {
slice.iter().sum()
}
This works with arrays, vectors, and other slice-like types.
2. Use &str
Instead of &String
A &str
is a string slice, which can represent string data in various forms—string literals, parts of a string, or an entire String
. By using &str
, your function becomes more generic and flexible.
Example:
Instead of:
fn greet(name: &String) {
println!("Hello, {}!", name);
}
Write:
fn greet(name: &str) {
println!("Hello, {}!", name);
}
Now, your function works with both String
and string literals.
3. Avoid &Box<T>
Boxed types (Box<T>
) are heap-allocated, but taking &Box<T>
introduces redundant indirection. You can simply take &T
instead.
Example:
Instead of:
fn process(data: &Box<i32>) {
println!("{}", data);
}
Write:
fn process(data: &i32) {
println!("{}", data);
}
When Should You Use &String
, &Vec
, or &Box
?
While it's discouraged in most cases, there are rare situations where you might need to accept &String
, &Vec
, or &Box
:
- You're working with APIs that explicitly require these types.
- You're debugging or refactoring existing code and can’t change upstream designs.
Even then, consider refactoring the API if possible.
Conclusion
Avoiding &String
, &Vec<T>
, or &Box<T>
as function arguments is more than just stylistic advice—it’s about writing idiomatic, flexible, and ergonomic Rust code. By leveraging slices (&[T]
), string slices (&str
), and deref coercion, your functions can support a wider range of input types, leading to cleaner and more reusable code.
Next time you’re writing a Rust function, think about its most general form. You’ll be amazed at how much more versatile and future-proof your code becomes!
Top comments (1)
I'd gradually picked up the practice of accepting
&str
and&[T]
as well, but I wasn't able to articulate all the reasons like you have here. Thanks for sharing this advice!