DEV Community

Gregory Chris
Gregory Chris

Posted on

From Vec to Slice: Writing Generic Functions over Collections

From Vec to Slice: Writing Generic Functions over Collections

When writing Rust code, one of the most important lessons to learn is how to make your functions flexible and reusable. A common mistake among Rust newcomers (and even seasoned developers) is writing functions that are overly specific—for instance, functions that accept a Vec<T> when they could work just as well with a &[T]. This subtle but powerful change can make your code more versatile and easier to use.

In this blog post, we’ll explore why writing functions over slices is often preferable to writing functions over concrete collections like Vec. You’ll learn how to replace &Vec<T> with &[T], the advantages of this approach, and how to avoid common pitfalls. Whether you’re tackling algorithm implementations or designing APIs, these tips will help you write cleaner, more idiomatic Rust code.


Why Slices Are Better Than Concrete Collections

Imagine you’re designing a function to process a list of integers. A beginner might write something like this:

fn sum(vec: &Vec<i32>) -> i32 {
    vec.iter().sum()
}
Enter fullscreen mode Exit fullscreen mode

At first glance, this seems perfectly fine. But there’s a problem: this function only works with Vec<i32>. What if you want to pass in an array, a slice, or another type of collection? You’d have to write additional functions for each use case, which is both unnecessary and unidiomatic.

By switching to slices, you can make your function work with a broader range of inputs:

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

So, Why Is &[T] Better?

  1. Flexibility: A slice (&[T]) can represent any contiguous sequence of data, whether it's a Vec<T>, an array ([T; N]), or even part of a Vec<T> using slicing (&vec[start..end]).
  2. Ergonomics: Using slices simplifies your API for callers because they don’t need to convert their data into a Vec to use your function.
  3. Performance: Slices avoid unnecessary allocations. If your caller has an array or an existing slice, they don’t need to create a new Vec just to pass data to your function.

Practical Examples: Refactoring from Vec to Slice

Let’s apply this principle in a few scenarios to see how it works.

Example 1: Computing Statistics

Here’s a function that calculates the mean of a list of numbers:

fn mean(vec: &Vec<f64>) -> Option<f64> {
    if vec.is_empty() {
        None
    } else {
        Some(vec.iter().sum::<f64>() / vec.len() as f64)
    }
}
Enter fullscreen mode Exit fullscreen mode

This version works fine, but it’s limited to Vec<f64>. Refactoring to use slices makes it more flexible:

fn mean(slice: &[f64]) -> Option<f64> {
    if slice.is_empty() {
        None
    } else {
        Some(slice.iter().sum::<f64>() / slice.len() as f64)
    }
}
Enter fullscreen mode Exit fullscreen mode

Now, the function works with any slice-like input:

fn main() {
    let vec = vec![1.0, 2.0, 3.0];
    let array = [4.0, 5.0, 6.0];

    println!("{:?}", mean(&vec));    // Works with Vec
    println!("{:?}", mean(&array)); // Works with array
    println!("{:?}", mean(&vec[1..])); // Works with slice of Vec
}
Enter fullscreen mode Exit fullscreen mode

Example 2: Searching for an Element

Let’s write a function to find the first occurrence of a value in a collection:

fn find(vec: &Vec<i32>, target: i32) -> Option<usize> {
    vec.iter().position(|&x| x == target)
}
Enter fullscreen mode Exit fullscreen mode

Refactoring to use slices:

fn find(slice: &[i32], target: i32) -> Option<usize> {
    slice.iter().position(|&x| x == target)
}
Enter fullscreen mode Exit fullscreen mode

This function now works with any slice:

fn main() {
    let vec = vec![10, 20, 30, 40];
    let array = [50, 60, 70, 80];

    println!("{:?}", find(&vec, 30));    // Works with Vec
    println!("{:?}", find(&array, 60)); // Works with array
    println!("{:?}", find(&vec[2..], 40)); // Works with slice of Vec
}
Enter fullscreen mode Exit fullscreen mode

Common Pitfalls and How to Avoid Them

While slices are powerful, there are a few things to keep in mind:

1. Confusion Between Ownership and Borrowing

When switching from Vec to slices, you might accidentally take ownership of the slice. For example:

fn incorrect(slice: &[i32]) {
    let _owned = slice.to_vec(); // Creates a new Vec, unnecessary allocation
}
Enter fullscreen mode Exit fullscreen mode

Avoid creating unnecessary Vec instances unless absolutely required. If your function accepts a slice, operate directly on the slice without converting it.

2. Mutable Slices

If your function modifies the input, you’ll need a mutable slice (&mut [T]). Be cautious about how you mutate data—it can lead to unexpected bugs for callers who don’t anticipate their input being changed.

fn double(slice: &mut [i32]) {
    for x in slice.iter_mut() {
        *x *= 2;
    }
}

fn main() {
    let mut vec = vec![1, 2, 3];
    double(&mut vec);
    println!("{:?}", vec); // [2, 4, 6]
}
Enter fullscreen mode Exit fullscreen mode

3. Indexing and Bounds

When working with slices, be mindful of bounds checking. Rust prevents out-of-bounds errors at runtime, but you should still write code that avoids panics:

fn get_element(slice: &[i32], index: usize) -> Option<i32> {
    slice.get(index).copied() // Use `.get()` instead of indexing directly
}
Enter fullscreen mode Exit fullscreen mode

Key Takeaways

  1. Use slices (&[T]) instead of concrete collections (&Vec<T>) whenever possible. This makes your functions more flexible and reusable.
  2. Slices work with arrays, Vec, and other slice-like inputs. This avoids unnecessary allocations and improves ergonomics for your API users.
  3. Be mindful of mutability and bounds checking. Use &mut [T] for mutable slices and .get() for safe indexing.

Next Steps for Learning

To deepen your understanding of slices and generic programming in Rust:

  1. Explore the Rust standard library: Learn more about how slices ([T]) integrate with traits like AsRef and IntoIterator.
  2. Practice writing generic functions: Try refactoring your existing code to use slices instead of concrete collections.
  3. Read up on lifetimes: Understanding how borrowing works is crucial when working with slices.

Slices are a cornerstone of idiomatic Rust code, and mastering them will help you write efficient, expressive, and reusable functions. Now go forth and slice your way to better code! 😊


Questions or Feedback?

Have questions or want to share your own slice-related insights? Feel free to leave a comment below! Happy coding! 🚀

Top comments (0)