We can't find the internet
Attempting to reconnect
Something went wrong!
Attempting to reconnect
← Collections, Text and First Iterators step 19 of 21
In-place mutation vs rebuild, measured
Compact an event log: remove every event older than a cutoff, return the removed ones, and leave the survivors in the vector in their original order.
pub struct Event { pub id: u64, pub ts: u64 }
pub fn compact_log(log: &mut Vec<Event>, cutoff: u64) -> Vec<Event>
“Older than” is strict: ts < cutoff is removed, ts == cutoff stays. Both
halves keep their input order.
The Event struct is declared in the starter and the harness constructs and
reads it — do not rename the struct or its fields, or nothing will
compile.
The hidden case runs a 500 000-event log with half of it removed, and asserts at most 20 heap allocations for the whole operation.
The three answers, and how the budget separates them
Measured on exactly this case:
log.extract_if(.., pred).collect() 17 allocations
drain into two fresh Vecs, then reassign `*log` 34 allocations
log.remove(i) in a loop 0 allocations, times out
The middle one is what most people write, and it is correct. It rebuilds the retained half into a brand new vector and then throws away the old backing store, which is why it doubles the count — and, invisibly, why it holds twice the memory at its peak.
The third one is what people write when they hear “don’t rebuild”. It
allocates nothing at all and it is catastrophically slow: each remove(i)
shifts every later element down one, so removing 250 000 items from a 500 000
item vector moves on the order of $10^{11}$ bytes. That is the shape of an
accidental quadratic — no allocation, no warning, no lint, just a request
that never comes back.
The first one does the whole job in a single pass over the vector, moving
each survivor at most once. extract_if is the operation you want, and this
problem exists so that you meet it, because most standard libraries have no
equivalent and so most people have never looked for one.
The error in the starter
The starter reaches for retain, which is the right family of operation:
error[E0507]: cannot move out of `*e` which is behind a shared reference
retain‘s predicate has signature FnMut(&T) -> bool. It lends you each
element so you can inspect it and answer yes or no. It does not give it to
you, because at that moment the element is still in the vector — retain has
not yet decided what to do with it, and handing out ownership of something
that is still there would leave a hole.
So retain can drop elements but cannot hand them back, and removed.push(*e)
is asking for exactly that. That is not a limitation to work around — it is
the reason extract_if exists as a separate method:
log.extract_if(.., |e| e.ts < cutoff).collect()
extract_if removes the matching elements from the vector and yields them
as an iterator, so ownership transfers cleanly and the survivors close up
behind them in one pass. Its first argument is a range — pass .. for the
whole vector.
If you are attached to retain, the legitimate escape is to add
#[derive(Clone)] to Event and push a clone — and now you are allocating a
copy of every removed element, which is precisely the cost you were trying to
avoid.
::: question retain gives you &T. What if you genuinely need to modify the survivors as you filter?
Use retain_mut, whose predicate is FnMut(&mut T) -> bool. Without it you
get:
error[E0594]: cannot assign to `e.ts`, which is behind a `&` reference
which is E0507’s sibling: E0507 is “you cannot take it”, E0594 is “you cannot
change it”. Both come from the same fact — the predicate holds a shared
borrow — and both are fixed by choosing the method whose signature grants
what you need. Rust’s collection APIs tend to come in these small families
(iter/iter_mut/into_iter, get/get_mut, retain/retain_mut), and
the suffix is always telling you which kind of access you are being handed.
:::
drain has a surprise in it
let removed: Vec<Event> = log.drain(..).collect();
drain(range) returns an iterator over the removed elements. The thing that
catches people is what happens if you do not consume it:
log.drain(..); // the elements are removed anyway
When the Drain iterator is dropped, its destructor finishes the job —
removing everything in the range and closing the gap — whether or not you
ever called next. That is deliberate: it keeps the vector in a consistent
state no matter how you abandon the iteration. But it means drain is not
lazy in the way filter is, and a stray drain(..) on a line by itself is a
data-losing statement, not a no-op.
(extract_if behaves the same way for the range you gave it.)
The general number
Filtering a million-element Vec of integers, rebuild versus in place:
v.iter().copied().filter(..).collect() 0.420 ms
v.retain(..) 0.211 ms
Two times faster, and one allocation rather than a doubling sequence of them. Not a life-changing constant — but it is free, it reads better, and unlike the rebuild it does not double peak memory. Reach for the in-place method first and rebuild only when you actually want a second collection.
Remember the grade is compile + tests + clippy -D warnings.
Stuck?
Rust reference solution
Sign in to attempt this problem and reveal the reference solution.