28、C++20新特性:ranges库、concepts约束、协程与STL结合、span与string_view
说实话,C++20 这一波新特性,是我从业十几年来见过最「解渴」的一次更新。尤其是 ranges 库,它彻底改变了我们写循环的方式。以前写个过滤、转换、排序,得嵌套三层 for 加 if,现在一行搞定。今天我们就来聊聊这些新玩具怎么跟 STL 配合,以及实战中怎么用。
28.1 ranges 库:告别手写循环
ranges 库的核心思想就四个字:延迟求值。它不会立刻创建新容器,而是构建一个「处理管道」,等你真正需要结果时才执行。我刚开始用的时候,最直观的感受就是——代码变短了,脑子变清楚了。
核心组件:
- views:惰性视图,不拷贝数据,只提供变换逻辑
- actions:急切操作,直接修改容器
- 管道操作符:
|让代码像流水线一样串联
来看个例子。假设你有一个整数列表,想取出所有偶数,再平方,最后取前 5 个。传统写法是这样的:
std::vector<int> nums = {1,2,3,4,5,6,7,8,9,10};
std::vector<int> result;
for (auto n : nums) {
if (n % 2 == 0) {
int sq = n * n;
result.push_back(sq);
if (result.size() == 5) break;
}
}
用 ranges 怎么写?
auto result = nums
| std::views::filter([](int n){ return n % 2 == 0; })
| std::views::transform([](int n){ return n * n; })
| std::views::take(5);
你看,逻辑清晰得像读英文。filter 过滤,transform 变换,take 截断。而且 result 是一个 view,不是 vector,它不会拷贝数据。只有当你遍历它或者转成容器时,才会真正计算。
我的习惯:在性能敏感的代码里,我尽量用 views 而不是 actions。views 是惰性的,不会产生临时容器,内存开销小很多。但如果你需要持久化结果,记得用 std::ranges::to<std::vector>() 转一下。
28.2 concepts 约束:让模板错误更友好
concepts 说白了就是给模板参数加「门槛」。以前模板编译报错,那错误信息能绕地球三圈。现在有了 concepts,编译器会直接告诉你:「嘿,你传进来的类型不满足 Sortable 这个约束」。
我印象最深的一次,是在项目里写了一个通用的排序函数。以前用 SFINAE 做约束,代码又臭又长。换成 concepts 后,清爽多了:
template<std::ranges::random_access_range R>
requires std::sortable<std::ranges::iterator_t<R>>
void my_sort(R& range) {
std::ranges::sort(range);
}
这里 requires 子句就是约束。如果传进来的是 std::list(不支持随机访问),编译器会直接报错,而不是给你看一屏幕模板实例化堆栈。
避坑指南:我曾经在项目里过度使用 concepts,给每个模板参数都加了十几条约束。结果代码变得非常脆弱,稍微改个类型就编译不过。我的建议是:只约束必要的语义,比如「可排序」「可比较」「是范围」,不要约束实现细节。
28.3 协程与 STL 结合:异步生成数据
协程在 C++20 里是个大块头,但说实话,直接上手有点陡。不过跟 STL 结合后,你会发现它特别适合做「懒加载生成器」。比如你想生成一个斐波那契数列,但又不想一次性算完:
std::generator<int> fibonacci() {
int a = 0, b = 1;
while (true) {
co_yield a;
auto next = a + b;
a = b;
b = next;
}
}
// 使用
for (auto n : fibonacci() | std::views::take(10)) {
std::cout << n << " ";
}
这里 co_yield 每次产出一个值,然后挂起。等到下一次迭代再继续。配合 views::take,你只取前 10 个,后面的根本不会计算。这种「按需生产」的模式,在处理无限序列或大文件流时特别有用。
我个人觉得,协程 + ranges 是 C++20 最被低估的组合。很多流式处理场景,比如日志解析、实时数据过滤,用这个模式写起来非常优雅。
28.4 span 与 string_view:零开销的视图
这两个东西,说白了就是「不拥有数据的引用」。它们不会拷贝、不会分配内存,只是给你一个安全的方式来访问连续内存。
| 类型 | 用途 | 不适用场景 |
|---|---|---|
std::string_view |
只读字符串,避免拷贝 | 需要修改字符串内容时 |
std::span |
连续内存的数组视图 | 非连续容器(如 list) |
举个例子。以前写个函数处理字符串,你可能会传 const std::string&,但调用方如果传的是 const char*,就得先构造一个 string,白白浪费一次拷贝。用 std::string_view 就完美解决:
void process_name(std::string_view name) {
// name 不会拷贝,直接引用原始数据
std::cout << name.substr(0, 5) << '\n';
}
// 调用方可以传 string、char*、甚至子串
process_name("Hello World"); // 零拷贝
std::span 类似,但它针对的是数组。比如你要对一个 std::vector<int> 做批量处理,但又不想传指针和长度:
void sum_range(std::span<const int> data) {
int total = 0;
for (auto v : data) total += v;
std::cout << total << '\n';
}
std::vector<int> vec = {1,2,3,4,5};
sum_range(vec); // 自动适配
我的经验:在写接口时,如果参数是只读的连续数据,优先用 std::string_view 或 std::span。它们不仅性能好,还让接口更通用。但要注意,它们不拥有数据,所以不能返回局部变量的 view,否则就是悬垂引用。
28.5 实战:使用 ranges 重构传统循环
最后我们来个实战。假设你有一个学生成绩列表,需要做以下操作:
- 过滤出成绩及格(≥60)的学生
- 按成绩降序排序
- 只取前 3 名
- 输出他们的名字和成绩
传统写法大概是这样:
struct Student { std::string name; int score; };
std::vector<Student> students = {...};
std::vector<Student> passed;
for (const auto& s : students) {
if (s.score >= 60) passed.push_back(s);
}
std::sort(passed.begin(), passed.end(),
[](const auto& a, const auto& b) { return a.score > b.score; });
for (size_t i = 0; i < 3 && i < passed.size(); ++i) {
std::cout << passed[i].name << ": " << passed[i].score << '\n';
}
用 ranges 重构后:
auto top3 = students
| std::views::filter([](const Student& s) { return s.score >= 60; })
| std::views::sort(std::greater{}) // C++23 才有,这里示意
| std::views::take(3);
for (const auto& s : top3) {
std::cout << s.name << ": " << s.score << '\n';
}
嗯,这里要注意,views::sort 是 C++23 才有的。在 C++20 里,你可以先转成 vector 再排序,或者用 actions:
auto vec = students
| std::views::filter([](const Student& s) { return s.score >= 60; })
| std::ranges::to<std::vector>();
std::ranges::sort(vec, std::greater{}, &Student::score);
for (auto&& s : vec | std::views::take(3)) {
std::cout << s.name << ": " << s.score << '\n';
}
你看,即使没有 views::sort,代码依然比传统写法简洁。而且可读性提升了一个档次——每个步骤都一目了然。
避坑指南:我曾经在重构一个老项目时,把所有循环都改成了 ranges 管道。结果发现某些地方性能反而下降了。原因是 ranges 的惰性求值在某些场景下会导致重复计算。比如你在一个 view 上多次调用 size(),它可能会重新遍历。所以,如果性能敏感,记得用 std::ranges::to<vector>() 把结果固化下来。
这张图把 C++20 的几个核心新特性跟 STL 的关系梳理清楚了。ranges 负责数据处理管道,concepts 给模板加约束,协程做异步生成,span 和 string_view 做零开销视图。它们不是孤立的,而是可以互相配合。比如协程生成的数据流,可以直接喂给 ranges 的 view 做过滤和转换。
好了,这一章的内容就到这里。记住,新特性虽好,但不要为了用而用。在实际项目中,先想清楚场景,再选择合适的工具。ranges 适合流式处理,concepts 适合泛型约束,协程适合异步生成,span 和 string_view 适合接口设计。用对了,代码质量能上一个台阶。