JavaRush /Курсы /C++ SELF /std::views::filter / transform / take

std::views::filter / transform / take

C++ SELF
62 уровень , 1 лекция
Открыта

1. view и пайплайны ranges

Когда вы только учитесь программировать, цикл — это как швейцарский нож: им можно открыть почти всё. И это правда. Но на длинной дистанции получается другая правда: один и тот же “нож” начинает выглядеть как набор слегка отличающихся копий, а в коде появляется шум — где фильтруем, где преобразуем, где ограничиваем количество результатов.

Представьте, что у нас есть учебное консольное приложение “TaskLite” — мини-список задач. Мы храним задачи в std::vector, умеем печатать их, а теперь хотим вывести “первые 3 важные незавершённые задачи”. На циклах получится несколько if, счётчик, break и мелкая бытовуха. С views можно написать так, чтобы код читался как фраза: “возьми задачи → отфильтруй → преобразуй → возьми первые N”.

Что такое view

Важно начать с правильной ментальной модели. std::vector — это коробка с вещами: он владеет памятью, хранит элементы и отвечает за их жизнь. А view — это скорее “умное окно” или “сценарий просмотра”: оно обычно не владеет данными, а лишь хранит правила, как по ним пройти и что показывать.

Если вам нравится аналогия с кухней, то контейнер — это кастрюля с супом, а view — это половник и инструкция “зачерпывай только кусочки картошки”. Суп от этого не исчезает и не копируется, просто вы смотрите на него под нужным углом.

Небольшая табличка, чтобы закрепить различие:

Сущность Пример Владеет данными? Где “живут” элементы? Когда выполняется работа?
Контейнер
std::vector<Task>
Да Внутри контейнера При добавлении/удалении/изменении
View
tasks | std::views::filter(...)
Обычно нет В исходном контейнере При обходе view

Ключевой вывод: view — это не “новый список”, а “новый способ смотреть на старый список”.

Пайплайн-оператор |

Пайплайны в ranges/views выглядят необычно: там используется оператор |. Исторически в C++ это “побитовое ИЛИ”, и мозг новичка честно пытается найти тут биты. Но в мире ranges это перегруженный оператор, который принято читать как “пропусти диапазон через адаптер”.

Идея “pipe” для range adaptors — это не случайная магия, а специально продуманная часть дизайна ranges. В материалах WG21 это явно обсуждалось (в частности, как “Pipe support for user-defined range adaptors”).

То есть выражение:

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

читается как:

“Возьми tasks и пропусти через filter(pred)”.

А цепочка:

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

читается как конвейер: на каждом шаге мы не “строим новый вектор”, а создаём очередное представление.

3. Модель данных: TaskLite

Чтобы примеры были связными, возьмём маленькую модель задачи. Предположим, что в прошлых темах вы уже знакомились со struct и enum class. Мы используем максимально простую версию.

#include <string>

enum class Priority { Low, Normal, High };

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

И сделаем тестовые данные:

#include <vector>

std::vector<Task> makeSampleTasks() {
    return {
        {1, "Сдать лабу", Priority::High, false},
        {2, "Купить хлеб", Priority::Low,  true},
        {3, "Починить сборку", Priority::High, false},
        {4, "Погладить кота", Priority::Normal, false},
        {5, "Посмотреть ошибки компилятора", Priority::High, true},
    };
}

Дальше мы будем строить views поверх этого std::vector<Task>.

4. Базовые адаптеры filter, transform, take

std::views::filter

Когда вы пишете обычный цикл, вы часто делаете проверку if (условие) continue;. std::views::filter — это “тот же if”, но вынесенный в декларативный стиль: вы описываете правило отбора, а не вручную управляете каждым шагом обхода.

Сигнатура на уровне идеи такая: views::filter(pred) принимает предикат (обычно лямбду), а дальше даёт view, которое при обходе пропускает только те элементы, для которых pred(x) возвращает true.

Пример: возьмём только незавершённые задачи.

#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';
    }
}

Обратите внимание на const Task& t в range-for: мы не хотим копировать Task (там внутри строка), а хотим читать элементы как ссылки.

Ещё пример: фильтруем “важные и не сделанные”.

#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';
    }
}

Здесь мы передали не лямбду, а обычную функцию. Для filter это нормально: главное — чтобы это было “что-то вызываемое” (callable).

std::views::transform

После фильтрации часто хочется “взять не весь объект”, а только нужную часть: например, вывести только заголовки, или посчитать “короткое представление” строки, или извлечь id.

В цикле это делается так: “взяли элемент → что-то вычислили → вывели”. std::views::transform превращает это в отдельный шаг пайплайна: вы описываете функцию преобразования f(x), а view при обходе возвращает значения f(x).

Пример: превратим задачи в их заголовки.

#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';
    }
}

Тут есть важный момент “на подумать”: мы возвращаем t.title — это std::string. В зависимости от деталей, transform может отдавать значение как ссылку или как копию. Чтобы не превращать лекцию в “шаблонный детектив”, держим практическое правило: если внутри Task тяжёлые поля, не удивляйтесь, что иногда удобнее возвращать не строку, а, например, int id или что-то лёгкое.

Пример: преобразуем задачи в их 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
}

Это уже очень “чистый” transform: на выходе лёгкие числа.

std::views::take

Ограничение количества результатов — классика: вы хотите показать пользователю не весь список, а первые 310 пунктов. На цикле это обычно выглядит как int shown = 0; ... if (++shown == N) break;. Работает, но читать такое — как инструкцию к микроволновке: вроде всё логично, но радости мало.

std::views::take(n) решает задачу декларативно: “взять первые n элементов диапазона”. При этом важно, что это тоже view, и оно тоже ленивое: никакой новый контейнер не создаётся.

Пример: возьмём первые 2 задачи из списка.

#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';
    }
}

Даже если задач 1000, мы “пройдём” только первые 2 при печати.

Собираем всё вместе: filter | transform | take

Теперь самый вкусный кусок: составим ту самую фразу “первые 3 важные незавершённые задачи”, но так, чтобы код читался почти как русская речь (ну, насколько это вообще возможно в C++).

Сначала фильтруем важные и не сделанные, потом превращаем в заголовки, потом берём первые 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';
    }
}

Если вы сейчас подумали “это похоже на Unix pipes” — поздравляю, вы поймали правильное ощущение. В C++ это обсуждается ровно в той же логике “pipe” для range adaptors.

Чтобы закрепить, вот простая схема пайплайна:

flowchart LR
    A[std::vector<Task> tasks] --> B[views::filter
важные и не done] B --> C[views::transform
Task -> title] C --> D[views::take
первые 3] D --> E[range-for + cout]

Заметьте, что “вычисление” не происходит в момент объявления pipeline. Оно происходит при обходе в for.

5. Ленивость и читаемость пайплайнов

Когда реально вызываются лямбды

Слово “ленивый” звучит как характеристика кота, который игнорирует ваш std::cout. Но в ranges это важная характеристика: view обычно ничего не считает заранее, а делает работу только при попытке получить очередной элемент при обходе.

Самый простой способ это почувствовать — добавить счётчик вызовов внутри лямбды. Да, это “грязный” приём (побочный эффект), но как демонстрация он работает идеально.

#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 (ещё ничего не обходили)
    for (int x : evens) { (void)x; }
    std::cout << calls << '\n';      // 5 (предикат вызвали при обходе)
}

Здесь нужно запомнить практическое правило: view — это “описание”, а не “результат”. Поэтому и filter, и transform, и take проявляют себя в момент обхода.

Когда лучше разбить на шаги

Пайплайн в одну “лесенку” читается красиво… до тех пор, пока там не появляются три условия, два преобразования и полстраницы логики. Тогда код превращается в “функциональную лапшу”: вроде модно, но есть невозможно.

Нормальная инженерная привычка: если цепочка перестаёт читаться слева направо за 510 секунд, дайте шагам имена. Это не слабость, а забота о будущем себе (который через две недели будет смотреть на код как на чужой).

Например, так:

#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)) {
        // печать
    }
}

Смысл тот же, но голова читает проще: “important” → “titles” → “take(3)”.

6. Типичные ошибки при работе с views::filter / transform / take

Ошибка №1: ожидать, что filter/transform/take создают новый std::vector.
Очень частая путаница: студент пишет auto x = ... и думает, что “x — это список”. В реальности x — это view, то есть представление. Оно не обязано владеть данными, и чаще всего ничего не копирует. Поэтому если вам нужен настоящий контейнер, его придётся создавать отдельным шагом (но это будет темой следующей лекции, не забегаем вперёд).

Ошибка №2: писать for (auto t : view) и случайно копировать тяжёлые элементы.
Если Task содержит std::string, то for (auto t : someView) может начать копировать элементы (или результаты transform) и внезапно сделать код медленнее. Практическая привычка: для просмотра исходных объектов используйте const auto&, а если вы точно хотите копию — делайте это осознанно и явно.

Ошибка №3: пытаться “увидеть результат” без обхода.
View ленивый: пока вы по нему не прошли (range-for, std::ranges::copy, алгоритм и т.п.), он может вообще ничего не вычислить. Поэтому std::cout << calls до обхода может показывать “0”, и это не баг, а ожидаемое поведение. В голове нужно чётко разделить “описание” и “выполнение”.

Ошибка №4: прятать сложную бизнес-логику внутрь лямбды и получать код-ребус.
views хорошо читаются, когда предикат и преобразование короткие и понятные. Если в filter у вас 8 условий, 3 локальные переменные и кусок “политики компании”, читателю становится больно. В таких случаях лучше вынести логику в отдельную функцию с нормальным именем, а в пайплайне оставить только её вызов.

Ошибка №5: путать std::views::... и std::ranges::....
Это разные “полки” библиотеки. views строят представления (то есть описывают, как смотреть на данные), а ranges-алгоритмы выполняют работу (сортируют, копируют, ищут). Запомните короткую фразу: views описывают, ranges-алгоритмы делают — и жить станет спокойнее.

1
Задача
C++ SELF, 62 уровень, 1 лекция
Недоступна
Фильтр входа
Фильтр входа
1
Задача
C++ SELF, 62 уровень, 1 лекция
Недоступна
Длины никнеймов
Длины никнеймов
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ