Why is it Discouraged to Accept `&String`, `&Vec`, or `&Box` as Function Arguments in Rust?

sharmaprash

Prashant Sharma

Posted on November 29, 2024

Why is it Discouraged to Accept `&String`, `&Vec`, or `&Box` as Function Arguments in Rust?

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());
}
Enter fullscreen mode Exit fullscreen mode

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());
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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!
}
Enter fullscreen mode Exit fullscreen mode

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]`
}
Enter fullscreen mode Exit fullscreen mode

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()
}
Enter fullscreen mode Exit fullscreen mode

Write:

fn sum(slice: &[i32]) -> i32 {
    slice.iter().sum()
}
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

Write:

fn greet(name: &str) {
    println!("Hello, {}!", name);
}
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

Write:

fn process(data: &i32) {
    println!("{}", data);
}
Enter fullscreen mode Exit fullscreen mode

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:

  1. You're working with APIs that explicitly require these types.
  2. 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!

💖 💪 🙅 🚩
sharmaprash
Prashant Sharma

Posted on November 29, 2024

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related