19. std::views::filter 与 transform:数据过滤与转换,Lambda 配合

好,咱们今天聊点实在的。

filter 和 transform,说白了就是「挑出想要的」和「改成想要的」。这两个操作,在 C++20 的 Ranges 库里,配合 Lambda 用起来特别顺手。我最早接触这类模式是在写 Python 的时候,当时觉得 map 和 filter 真香。后来 C++ 终于也有了,而且更强大。

19.1 从「手写循环」到「声明式管道」

先看个老派的写法。假设你有一个整数列表,想挑出偶数,然后每个数翻倍:

std::vector<int> nums = {1, 2, 3, 4, 5, 6};
std::vector<int> result;

for (int n : nums) {
    if (n % 2 == 0) {
        result.push_back(n * 2);
    }
}

这段代码能跑,没问题。但你看,逻辑是混在一起的——过滤和转换揉在一个循环里。如果以后要改过滤条件,或者改转换逻辑,你得小心翼翼地拆。

用 Ranges 怎么写?

auto result = nums 
    | std::views::filter([](int n) { return n % 2 == 0; })
    | std::views::transform([](int n) { return n * 2; });

嗯,清爽多了。每一行只干一件事。filter 只管「要不要」,transform 只管「怎么改」。管道操作符 | 把数据从左往右传,像流水线一样。

核心思想:filter 是「门卫」,transform 是「加工员」。门卫决定谁进来,加工员决定进来的人变成什么样。

19.2 filter 的细节与坑

filter 接收一个谓词(predicate),也就是返回 bool 的 Lambda。返回 true 的元素保留,false 的丢弃。

我在项目中遇到过一个问题:filter 是惰性求值的。什么意思?它不会立刻遍历整个容器,而是等到你真正需要结果时才计算。比如:

auto even_view = nums | std::views::filter([](int n) { 
    std::cout << "checking " << n << "\n";
    return n % 2 == 0; 
});

// 此时没有任何输出!
for (int n : even_view) {
    // 这里才开始逐个检查
}

为什么会这样?因为 Ranges 的设计哲学是「按需计算」。你构造 view 的时候,它只是一个描述,不是结果。只有当你遍历它,或者把它转成容器时,才真正执行。

注意:filter 不会修改原容器。它只是提供一个「视角」。如果你需要持久化的结果,记得转成 vector 或别的容器:

std::vector<int> result(even_view.begin(), even_view.end());

19.3 transform 的灵活用法

transform 把一个元素映射成另一个元素。类型可以不同。比如从 int 映射到 string:

auto str_view = nums 
    | std::views::transform([](int n) { 
        return "Number: " + std::to_string(n); 
    });

我个人习惯在 transform 里保持「纯函数」风格——不修改外部状态,只根据输入返回输出。这样代码容易测试,也容易推理。

但有一个细节要注意:transform 返回的是 视图,不是新容器。如果你把 transform 的结果存起来,然后原容器被销毁了,那这个视图就悬空了。嗯,这里要小心。

std::vector<int>* ptr = new std::vector<int>{1, 2, 3};
auto view = *ptr | std::views::transform([](int n) { return n * 10; });
delete ptr;  // view 现在悬空了!
// 访问 view 是未定义行为

19.4 filter + transform 组合实战

咱们来点真实的。假设你有一个学生成绩列表,想找出所有及格的学生,并把他们的分数转换成等级:

struct Student {
    std::string name;
    int score;
};

std::vector<Student> students = {
    {"Alice", 85},
    {"Bob", 59},
    {"Charlie", 72},
    {"Diana", 43}
};

auto passed_with_grade = students
    | std::views::filter([](const Student& s) { 
        return s.score >= 60; 
    })
    | std::views::transform([](const Student& s) {
        std::string grade;
        if (s.score >= 90) grade = "A";
        else if (s.score >= 80) grade = "B";
        else if (s.score >= 70) grade = "C";
        else grade = "D";
        return std::pair{s.name, grade};
    });

for (auto [name, grade] : passed_with_grade) {
    std::cout << name << ": " << grade << "\n";
}

输出:

Alice: B
Charlie: C

你看,filter 先筛掉不及格的,transform 再把 Student 转成 pair。管道清晰,逻辑分明。

19.5 性能考量

有人会担心:这么链式调用,会不会有性能开销?

其实不会。Ranges 的 view 是 编译期组合的。filter 和 transform 不会产生中间容器。数据流是「一次遍历,逐个处理」。你想想看,手写循环也是一次遍历,这里也是一次遍历,没有额外分配。

我曾经在代码审查里看到有人把 filter 的结果先存成 vector,再对 vector 做 transform。这就是多余的开销。直接链式调用就好,编译器会帮你优化。

写法 中间容器 遍历次数
手写循环 1次
filter + transform 链式 1次
先 filter 存 vector,再 transform 有(vector) 2次

小技巧:如果你需要多次遍历同一个过滤后的结果,可以先把 view 转成 vector。否则,直接用 view 更高效。

19.6 知识结构图

下面这张图展示了 filter 和 transform 在数据管道中的位置和作用:

filter + transform 数据管道 原始数据 vector<int> | filter 谓词:n % 2 == 0 只保留偶数 | transform 函数:n * 2 每个元素翻倍 结果 view 惰性求值:遍历时才逐个执行 filter 和 transform 无中间容器:一次遍历完成所有操作 输入:[1, 2, 3, 4, 5, 6] → 输出:[4, 8, 12]

19.7 避坑指南

我曾经在项目里犯过一个错:在 filter 的 Lambda 里修改了外部变量。当时想统计过滤掉了多少元素,就在 Lambda 里递增一个计数器。结果因为 filter 是惰性的,计数器只在遍历时才更新,而且如果遍历多次,计数器会重复累加。嗯,教训深刻。

所以我的建议是:filter 和 transform 的 Lambda 尽量保持无副作用。如果非要统计,用 std::ranges::distance 或者遍历后单独算。

另一个常见坑:filter 的谓词如果抛出异常,行为是未定义的。确保你的 Lambda 不会抛异常。

19.8 总结

filter 和 transform 是 Ranges 库里最常用的两个 view。它们让数据处理变得声明式、可组合。配合 Lambda,你可以写出既简洁又高效的代码。

记住三点:

  • filter 是门卫,transform 是加工员
  • 管道是惰性的,按需计算
  • Lambda 保持纯函数,避免副作用

下次你写循环处理数据时,试试用 filter 和 transform 重构一下。你会爱上这种写法的。


公众号:蓝海资料掘金营,微信deep3321