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 在数据管道中的位置和作用:
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