C++ / ITERATORS, ALGORITHMS, AND RANGES
Transform, copy, and remove-erase idioms
Write results into a destination range or inserter with transform and copy_if, and shrink a container correctly with the remove-erase idiom.
What you will learn
- Size a destination first, or wrap it in std::back_inserter before writing into it
- Read std::remove_if's return value as the new logical end of the kept elements
- Pair every remove, remove_if, and unique call with erase(newEnd, container.end())
- Use std::erase_if in C++20, and list::remove_if for node-based containers
Understanding Transform, copy, and remove-erase idioms
Algorithms in <algorithm> see only iterators, and an iterator offers no way to add or drop slots in the container behind it. That single fact explains transform, copy_if, and remove_if all at once: they assign to positions that already exist and they may rearrange values, but none of them can change size(). Each writing algorithm returns the destination iterator one past the last element it wrote, which is how you find out how much work actually happened.
For transform and copy_if the destination range must already have room, either because you constructed it with a size, called resize, or are overwriting the input in place by passing first as the destination. When you do not know the count in advance, adapt assignment into insertion: std::back_inserter(dest) is an output iterator whose operator= calls dest.push_back, so *it = x grows the vector. Handing copy_if the begin() of an empty vector instead compiles cleanly and then writes past the end, which is undefined behaviour that usually corrupts memory quietly rather than crashing.
remove_if works around the no-resize rule by partitioning: it move-assigns the elements you want to keep toward the front, preserving their relative order, and returns an iterator one past the last kept element. Everything from that iterator to end() is a valid but unspecified tail of moved-from objects, and the container is still exactly as long as before. The returned iterator is the remove half of the idiom; v.erase(newEnd, v.end()) is the erase half, and omitting it is the classic bug where the data looks right up to newEnd while size() lies. C++20 folds both halves into std::erase(container, value) and std::erase_if(container, pred), which return how many elements went away.
<algorithm>
<iostream>
<iterator>
<string>
<vector>
static void print(const std::string& label, const std::vector<int>& v) {
std::cout << label;
for (int x : v) std::cout << ' ' << x;
std::cout << " (size " << v.size() << ")\n";
}
int main() {
std::vector<int> src{4, 7, 2, 9, 5, 1, 8};
// back_inserter turns each assignment into push_back, so doubled may start empty
std::vector<int> doubled;
doubled.reserve(src.size());
std::transform(src.begin(), src.end(), std::back_inserter(doubled),
[](int x) { return x * 2; });
print("doubled:", doubled);
// copy_if writes only the passing elements, so the count is not known up front
std::vector<int> big;
std::copy_if(src.begin(), src.end(), std::back_inserter(big),
[](int x) { return x > 4; });
print("kept > 4:", big);
// remove_if reorders and reports a new logical end; it cannot shrink v
std::vector<int> v = src;
auto newEnd = std::remove_if(v.begin(), v.end(),
[](int x) { return x % 2 == 0; });
std::cout << "after remove_if, size is still " << v.size()
<< ", kept " << (newEnd - v.begin()) << " elements\n";
v.erase(newEnd, v.end()); // the erase half of the idiom
print("odds:", v);
}
Sequence algorithms only assign through iterators, so they can reorder and overwrite but never resize, which is why removal splits into a reorder step and a separate erase step.
Worked examples
The same idiom for unique, strings, and erase_if
Shows that unique returns a new end just like remove, that erase-remove works on std::string, and how std::erase_if collapses both steps.
<algorithm>
<iostream>
<string>
<vector>
int main() {
std::vector<int> v{3, 1, 4, 1, 5, 9, 2, 6, 5, 3};
std::sort(v.begin(), v.end());
v.erase(std::unique(v.begin(), v.end()), v.end());
std::cout << "unique:";
for (int x : v) std::cout << ' ' << x;
std::cout << '\n';
std::string s = "b a n a n a";
s.erase(std::remove(s.begin(), s.end(), ' '), s.end());
std::cout << s << '\n';
std::vector<int> w{10, 11, 12, 13, 14, 15};
auto gone = std::erase_if(w, [](int x) { return x % 3 == 0; }); // C++20
std::cout << "erase_if removed " << gone << ", left:";
for (int x : w) std::cout << ' ' << x;
std::cout << '\n';
}
Example explained
Line 1std::unique only collapses runs of adjacent equal values, which is why the sort has to come first.
Line 2The erase(newEnd, v.end()) call is the only part that changes size; without it v would still hold ten elements.
Line 3std::remove takes a value rather than a predicate, but the surrounding erase call has exactly the same shape.
Line 4std::erase_if performs both halves and returns the number of erased elements, so nothing is left for you to forget.
Destinations for transform: sized, in place, or inserted
Demonstrates the two-range transform overload, transforming a range onto itself, and falling back to back_inserter when no slots exist.
<algorithm>
<iostream>
<iterator>
<vector>
int main() {
std::vector<int> price{100, 250, 80};
std::vector<int> qty{2, 1, 5};
std::vector<int> total(price.size()); // slots created before writing
std::transform(price.begin(), price.end(), qty.begin(), total.begin(),
[](int p, int q) { return p * q; });
std::cout << "totals:";
for (int t : total) std::cout << ' ' << t;
std::cout << '\n';
std::transform(price.begin(), price.end(), price.begin(),
[](int p) { return p + 5; });
std::cout << "marked up:";
for (int p : price) std::cout << ' ' << p;
std::cout << '\n';
std::vector<int> negated;
std::transform(qty.begin(), qty.end(), std::back_inserter(negated),
[](int q) { return -q; });
std::cout << "negated size: " << negated.size() << '\n';
}
Example explained
Line 1std::vector<int> total(price.size()) matters because transform assigns to *dest and never inserts.
Line 2The four-iterator overload walks two inputs in lockstep and only takes the end of the first, so qty must be at least as long as price.
Line 3Passing price.begin() as both source and destination is allowed: the unary form applies the callable one element at a time with no lookahead.
Line 4negated starts empty, so it needs back_inserter; negated.begin() would be writing into zero slots.
Important notes
The tail left in [newEnd, end()) holds moved-from objects; they are safe to erase or assign to, but their values must not be read.
remove_if requires assignable elements, so it does not compile for std::map or std::set keys; use std::erase_if in C++20 or a loop with it = c.erase(it).
Common mistakes
Writing v.erase(std::remove_if(v.begin(), v.end(), pred)) with one argument: the single-iterator overload erases exactly one element, so size drops by one and the rest of the leftover tail stays in the vector.
Believing remove_if deleted anything: v.size() still reports the old count, and a range-for over v prints stale tail values after the elements you meant to keep.
Calling std::copy(src.begin(), src.end(), dest.begin()) on an empty dest: there are no slots to assign to, so every write is out of bounds undefined behaviour that often survives long enough to corrupt unrelated data.
Try it yourself
Change, predict, then run
Start from std::vector<int> v{5, -3, 8, -1, 0, 7} and use std::transform with std::back_inserter to build a second vector of absolute values. Then apply the remove-erase idiom to v so the negative numbers are gone, printing v.size() both immediately after remove_if and after the erase call.
Open the C++ workspaceCheck your understanding
You call auto it = std::remove_if(v.begin(), v.end(), pred); on a vector of 10 ints where 3 of them satisfy pred. What is true right after that line?
- v.size() is still 10 and it points one past the 7 kept elements
- v.size() is 7 and it equals v.end()
- v.size() is still 10 and the 3 matching elements now sit at the back in their original order
- v.size() is 7 and the final 3 elements hold unspecified values
Show answer
remove_if only assigns through iterators, so it cannot change the container's length; it compacts the kept elements at the front and hands back the new logical end. Option 3 is tempting because the tail often does still contain leftover data, but the standard promises nothing about [it, v.end()) beyond the elements being valid moved-from objects, and options 2 and 4 assume a size change that only v.erase can perform.