CodeGym /Courses /C++ SELF /std::views::filter / transform / take

std::views::filter / transform / take

C++ SELF
Level 62, Lesson 1
Available

1. view and ranges pipelines

When you are just learning to program, a loop is like a Swiss army knife: you can open almost anything with it. And that's true. But in the long run another truth emerges: the same “knife” starts to look like a set of slightly different copies, and the code gets noisy — where we filter, where we transform, where we limit the number of results.

Imagine we have a small console app “TaskLite” — a mini todo list. We store tasks in a std::vector, can print them, and now want to show the “first 3 important unfinished tasks”. With loops you'll end up with several ifs, a counter, a break and mundane plumbing. With views you can write code that reads like a sentence: “take tasks → filter → transform → take first N”.

What is a view

It's important to start with the right mental model. std::vector is a box of things: it owns memory, stores elements and is responsible for their lifetime. A view is more like a “smart window” or a “viewing recipe”: it usually does not own the data, it just stores rules on how to traverse and what to show.

If you like the kitchen analogy, the container is a pot of soup, and the view is a ladle and the instruction “scoop only potato pieces”. The soup doesn't disappear or get copied; you're just looking at it from the right angle.

A small table to reinforce the difference:

Entity Example Owns data? Where do elements “live”? When is work performed?
Container
std::vector<Task>
Yes Inside the container On insert/erase/mutation
View
tasks | std::views::filter(...)
Usually no In the original container On iteration of the view

Key takeaway: a view is not a “new list”, but a “new way to look at the old list”.

Pipeline operator |

Pipelines in ranges/views look unusual: they use the operator |. Historically in C++ this is the bitwise OR, and a beginner's brain honestly tries to find bits here. But in the ranges world this is an overloaded operator that people read as “pass the range through the adaptor”.

The “pipe” idea for range adaptors is not accidental magic but a deliberate part of ranges design. It was discussed in WG21 materials (notably “Pipe support for user-defined range adaptors”).

So the expression:

tasks | std::views::filter(pred)

reads as:

“Take tasks and pass them through filter(pred)”.

And the chain:

tasks | filter(...) | transform(...) | take(...)

reads as a pipeline: at each step we don't “build a new vector”, but create another view.

3. Data model: TaskLite

To keep examples coherent, let's take a small task model. Assume that in previous topics you already met struct and enum class. We'll use the simplest possible version.

#include <string>

enum class Priority { Low, Normal, High };

struct Task {
    int id = 0;
    std::string title;
    Priority priority = Priority::Normal;
    bool done = false;
};

And let's make sample data:

#include <vector>

std::vector<Task> makeSampleTasks() {
    return {
        {1, "Submit lab", Priority::High, false},
        {2, "Buy bread", Priority::Low,  true},
        {3, "Fix build", Priority::High, false},
        {4, "Iron the cat", Priority::Normal, false},
        {5, "Look at compiler errors", Priority::High, true},
    };
}

From here on we'll build views on top of this std::vector<Task>.

4. Basic adaptors: filter, transform, take

std::views::filter

When you write a plain loop, you often do an if (condition) continue;. std::views::filter is that same if, but lifted into a declarative style: you describe the selection rule instead of manually controlling every step of the traversal.

The signature at the idea level is like: views::filter(pred) accepts a predicate (usually a lambda), and then yields a view that, on iteration, skips only those elements for which pred(x) returns true.

Example: take only unfinished tasks.

#include <iostream>
#include <ranges>
#include <vector>

int main() {
    std::vector<Task> tasks = makeSampleTasks();

    auto notDone = tasks | std::views::filter([](const Task& t) { return !t.done; });

    for (const Task& t : notDone) {
        std::cout << t.id << ": " << t.title << '\n';
    }
}

Note the const Task& t in the range-for: we don't want to copy Task (it contains a string), we want to read elements by reference.

Another example: filter “important and not done”.

#include <ranges>

bool isImportantAndNotDone(const Task& t) {
    return t.priority == Priority::High && !t.done;
}

int main() {
    auto tasks = makeSampleTasks();

    auto important = tasks | std::views::filter(isImportantAndNotDone);

    for (const Task& t : important) {
        std::cout << t.title << '\n';
    }
}

Here we passed a regular function instead of a lambda. That's fine for filter: the important thing is that it's something callable.

std::views::transform

After filtering you often want to “take not the whole object, but just the needed part”: for example, print only titles, or compute a “short representation” of a string, or extract the id.

In a loop that's done like: “take element → compute something → print”. std::views::transform makes this a separate pipeline step: you describe a transform function f(x), and the view returns f(x) values on iteration.

Example: convert tasks to their titles.

#include <iostream>
#include <ranges>
#include <vector>

int main() {
    auto tasks = makeSampleTasks();

    auto titles = tasks | std::views::transform([](const Task& t) { return t.title; });

    for (const std::string& s : titles) {
        std::cout << s << '\n';
    }
}

There is an important “think about this” point: we return t.title — a std::string. Depending on details, transform may yield values as references or as copies. To avoid turning the lecture into a template detective story, keep a practical rule: if Task contains heavy fields, it may be more convenient to return something light like an int id instead of a string.

Example: transform tasks to their id.

#include <iostream>
#include <ranges>

int main() {
    auto tasks = makeSampleTasks();

    auto ids = tasks | std::views::transform([](const Task& t) { return t.id; });

    for (int id : ids) {
        std::cout << id << ' ';
    }
    std::cout << '\n'; // 1 2 3 4 5
}

This is a very “clean” transform: it yields lightweight numbers.

std::views::take

Limiting the number of results is classic: you want to show the user not the whole list but the first 3–10 items. In loops this usually looks like int shown = 0; ... if (++shown == N) break;. It works, but reading it is like microwave instructions: logical but not delightful.

std::views::take(n) solves this declaratively: “take the first n elements of the range”. Importantly, this is also a view and it's lazy: no new container is created.

Example: take the first 2 tasks from the list.

#include <iostream>
#include <ranges>

int main() {
    auto tasks = makeSampleTasks();

    auto first2 = tasks | std::views::take(2);

    for (const Task& t : first2) {
        std::cout << t.id << ": " << t.title << '\n';
    }
}

Even if there are 1000 tasks, we'll only “walk” the first 2 when printing.

Putting it all together: filter | transform | take

Now the tastiest piece: let's compose the phrase “first 3 important unfinished tasks” so that the code reads almost like English (well, as much as possible in C++).

First filter important and not done, then transform to titles, then take the first 3.

#include <iostream>
#include <ranges>
#include <vector>

int main() {
    auto tasks = makeSampleTasks();

    auto pipeline =
        tasks
        | std::views::filter([](const Task& t) { return t.priority == Priority::High && !t.done; })
        | std::views::transform([](const Task& t) { return t.title; })
        | std::views::take(3);

    for (const std::string& title : pipeline) {
        std::cout << title << '\n';
    }
}

If you just thought “this looks like Unix pipes” — congratulations, you caught the right intuition. In C++ it's the same pipe logic for range adaptors.

To reinforce, here is a simple pipeline diagram:

flowchart LR
    A[std::vector<Task> tasks] --> B[views::filter
important and not done] B --> C[views::transform
Task -> title] C --> D[views::take
first 3] D --> E[range-for + cout]

Note that the “computation” does not happen when pipeline is declared. It happens during iteration in the for.

5. Laziness and readability of pipelines

When lambdas are actually invoked

The word “lazy” sounds like a cat trait that ignores your std::cout. But in ranges it's an important property: a view usually does not compute anything ahead of time, it only does work when trying to obtain the next element during iteration.

The simplest way to feel it is to add a call counter inside a lambda. Yes, it's a “dirty” trick (side effects), but as a demonstration it works perfectly.

#include <iostream>
#include <ranges>
#include <vector>

int main() {
    std::vector<int> v{1, 2, 3, 4, 5};
    int calls = 0;

    auto evens = v | std::views::filter([&](int x) { ++calls; return x % 2 == 0; });

    std::cout << calls << '\n';      // 0 (nothing iterated yet)
    for (int x : evens) { (void)x; }
    std::cout << calls << '\n';      // 5 (predicate was called during iteration)
}

Remember the practical rule: a view is a “description”, not a “result”. So filter, transform, and take reveal themselves at iteration time.

When to split into steps

A one-liner pipeline looks nice… until it contains three conditions, two transforms and half a page of logic. Then the code becomes “functional spaghetti”: fashionable but unreadable.

A normal engineering habit: if the chain stops being readable left-to-right within about 5–10 seconds, give steps names. This is not a weakness but a courtesy to your future self (who in two weeks will look at the code as if it was written by someone else).

For example, like this:

#include <ranges>
#include <vector>

int main() {
    auto tasks = makeSampleTasks();

    auto important = tasks | std::views::filter([](const Task& t) {
        return t.priority == Priority::High && !t.done;
    });

    auto titles = important | std::views::transform([](const Task& t) {
        return t.title;
    });

    for (const std::string& s : titles | std::views::take(3)) {
        // print
    }
}

The meaning is the same, but the brain reads more easily: “important” → “titles” → “take(3)”.

6. Common mistakes when working with views::filter / transform / take

Mistake #1: expecting filter/transform/take to create a new std::vector.
A very common confusion: a student writes auto x = ... and thinks “x is a list”. In reality x is a view, i.e. a representation. It does not have to own data and usually performs no copies. So if you need a real container, you must create it as a separate step (that will be the topic of the next lecture).

Mistake #2: writing for (auto t : view) and accidentally copying heavy elements.
If Task contains a std::string, then for (auto t : someView) may start copying elements (or transform results) and suddenly make the code slower. Practical habit: to inspect original objects use const auto&, and if you really want a copy — do it consciously and explicitly.

Mistake #3: trying to “see the result” without iterating.
A view is lazy: until you traverse it (range-for, std::ranges::copy, an algorithm, etc.), it may compute nothing. Therefore std::cout << calls before iteration may show “0”, and that's not a bug but the expected behavior. Keep a clear mental separation between “description” and “execution”.

Mistake #4: hiding complex business logic inside a lambda and getting puzzle-like code.
views read well when predicates and transforms are short and clear. If your filter contains 8 conditions, 3 local variables and a piece of “company policy”, the reader will suffer. In such cases it's better to extract the logic into a separate function with a meaningful name and call it from the pipeline.

Mistake #5: confusing std::views::... and std::ranges::....
These are different “shelves” of the library. views build descriptions (i.e. describe how to look at data), while ranges algorithms perform work (sort, copy, find). Remember the short phrase: views describe, ranges algorithms do — and life becomes easier.

1
Task
C++ SELF, level 62, lesson 1
Locked
Input filter
Input filter
1
Task
C++ SELF, level 62, lesson 1
Locked
Nickname lengths
Nickname lengths
Comments
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION