Skip to content

12. Return a collection

A user store holds a list of users. Write a method that returns the active ones.

  1. WorseReturns Vec<User>: a full copy of each active user, with all their strings.

    pub struct UserStore {
    pub users: Vec<User>,
    }
    impl UserStore {
    /// A copy of every active user, and of all their strings.
    pub fn active_users(&self) -> Vec<User> {
    let mut result = Vec::new();
    for user in &self.users {
    if user.active {
    result.push(user.clone());
    }
    }
    result
    }
    }
  2. OKReturns Vec<&User>: references into the store. One small Vec, no copies of users.

    pub struct UserStore {
    pub users: Vec<User>,
    }
    impl UserStore {
    /// References to the active users. One small allocation, no string copies.
    pub fn active_users(&self) -> Vec<&User> {
    self.users.iter().filter(|u| u.active).collect()
    }
    }
  3. BetterReturns impl Iterator<Item = &User>: a lazy view. And &[User] for all users, because that data already exists as a slice.

    pub struct UserStore {
    users: Vec<User>,
    }
    impl UserStore {
    pub fn new(users: Vec<User>) -> Self {
    Self { users }
    }
    /// A lazy view of the active users. The caller decides what to collect, if anything.
    pub fn active_users(&self) -> impl Iterator<Item = &User> {
    self.users.iter().filter(|u| u.active)
    }
    /// All users, as a slice: the data already exists in this form.
    pub fn all_users(&self) -> &[User] {
    &self.users
    }
    }

The “Worse” version is the Java defensive copy: return a new list so that the caller cannot change the store. In Rust, a shared reference &User already cannot change the user, so the copy protects nothing. It costs one heap allocation per String field of each active user. The test worse_copies_the_strings shows that each name is in a new heap buffer. See S01: clone() to satisfy the borrow checker.

The “OK” version returns references. The borrow checker makes sure that the store lives as long as the Vec<&User>, and that nobody changes the store while the references exist. It still allocates one Vec, which the caller may not need.

The “Better” version returns an iterator. The caller decides what to do with it:

pub fn callers(store: &UserStore) -> (usize, Option<&str>, Vec<String>) {
let count = store.active_users().count(); // no `Vec` at all
let first = store.active_users().next().map(|u| u.name.as_str()); // stops at the first
let emails = store.active_users().map(|u| u.email.clone()).collect(); // the caller collects
(count, first, emails)
}
  • count() needs no Vec at all.
  • next() stops at the first active user.
  • map(...).collect() builds exactly the collection the caller needs, in one pass. Collecting a Vec<&User> first and then mapping it would be S11: collect() then iterate again.

all_users returns &[User]. When the data already exists in the right shape, a slice is the simplest return type: no allocation, and the caller gets every slice method (len, iter, first, binary_search).

Each version compared with the Worse version
CriterionOKBetter
Correctness Same Same
All three return the same users.
Failure behavior Same Same
Nothing can fail.
Performance Better Better
"Worse" copies each user and its strings. "OK" allocates one Vec of pointers. "Better" allocates nothing until the caller collects.
Readability Better Better
One filter line instead of a loop with push.
API ergonomics Better Same
"OK" gives a Vec that the caller can index and iterate many times. "Better" must be called again for a second pass, and its type cannot be named in a struct field.
  • Return Vec<User> when the caller must own the data: to send it to another thread, to keep it after the store is gone, or to change it. Then the copy has a reason. Name it so (active_users_owned, or into_active_users(self)).
  • impl Iterator borrows the store. While the caller holds the iterator, the store cannot be changed. Usually that is fine. For a long-lived result, collect it.
  • impl Iterator is hard to put in a trait or a struct field, because its type has no name. Inside a trait, Rust supports impl Trait in return position since 1.75. For a struct field, collect into a Vec, or use Box<dyn Iterator>.
  • A slice is right only when the data already has that shape. &[User] cannot be built from a filter without allocating.