处理一批读数时,接口需要回答两个问题:元素必须提供什么能力?函数可以访问哪一段数据?C++20 的 concepts 和 span 分别让这两个问题更明确,ranges 则把“筛选、转换、消费”的过程连起来。
本文使用内存中的模拟数据,不读硬件、不执行控制动作。上一节见 C++17 的值、状态与借用,完整主线见 程序设计知识分类。
1. 类型约束与运行时条件各管一层
DistanceSample 要求一个类型能在 const 对象上调用 distance_mm() 与 healthy(),并分别返回 int 与 bool。这个要求可以在编译期检查;“返回值是否真以毫米为单位”却不能仅靠表达式的形状证明。
因此程序分成两层:concept 检查接口形状,healthy() 根据本例的数据规则判断读数是否可用。若误把米当成毫米,即使概念检查全部通过,结果依然会错。concepts 可以表达约束,也有必须由实现者遵守的语义要求,编译器并不能自动验证全部语义。
2. 完整程序:从一段数据得到一份快照
下载 cpp20_contracts.cpp,内容与下列程序一致。
|
在源码所在目录运行:
g++ -std=c++20 -Wall -Wextra -Werror -pedantic cpp20_contracts.cpp -o cpp20_contracts |
准确输出:
samples=4 |
已在 GCC 14.2.0、libstdc++ 14(__GLIBCXX__=20250315)下编译运行。头文件 <version> 汇总库特性宏;<concepts>、<span>、<ranges> 提供实际接口,ranges 排序算法仍需 <algorithm>。语言标志不能替代标准库支持;应对照所用工具链的 libstdc++ 状态表 或对应厂商资料。
3. requires 描述表达式,concept 给要求起名字
requires(const T& sample) { ... } 是 requires 表达式。里面的调用用于检查表达式是否合法,并不会真的构造一个样本、执行一次读取。箭头后的 same_as<int> 对返回类型施加约束,比“只要能转换成整数就行”更严格。
template<DistanceSample T> 把命名后的约束放在模板入口。Reading 满足要求,int 不满足,两条 static_assert 把这一点固定下来。不符合接口的类型会在约束处被拒绝,而不是等到模板深处才报“找不到某个成员”。C++20 工作草案 N4861:expr.prim.req、temp.constr
也可以用 template<class T> requires DistanceSample<T> 表达约束;这是 requires 子句,与用于生成布尔条件的 requires 表达式承担不同角色。concepts 不要求继承某个基类,也不会天然引入虚函数调用。
这里没有把单位、安全范围、整数溢出都装进概念。例子中的总和很小,int 足够;面向大型数据集的接口还要明确数值范围、累加类型和溢出处理。让类型约束很长,并不能取代这些设计决定。
4. span 把指针与长度绑定起来
span<const Reading> 表示一段连续的、经此接口只读的 Reading。它按值传递时只复制视图信息,不复制整批元素,也不接管 vector 的存储。数组、array 和符合要求的连续容器都可以作为来源,不需要让算法绑定某一种容器。N4861:span.overview
span<const T> 与 const span<T> 不同:前者禁止经视图修改元素,后者只限制视图对象自身,元素仍可能可写。这里同时把源 vector 声明为 const,让本例整个处理阶段保持稳定。
window.first(2) 产生前两个元素的子视图,没有复制。调用方必须保证至少有两个元素;本例明确构造了四个。C++20 的 span 下标访问也不是自动带异常检查的安全数组:长度已知,不等于边界条件自动正确。
5. ranges 把处理描述与实际数据分开
filter 保留满足条件的元素,transform 把元素映射为毫米数。创建这条管道时,不会立即建立一份筛选后的 vector<int>;结果主要在迭代时按需产生。这是惰性求值。
total_distance 直接消费管道,适合一次遍历即可完成的计算;main 则把结果放进 snapshot,得到拥有元素的容器,然后排序。C++20 示例使用显式循环收集结果,不把 C++23 的 ranges::to 写成 C++20 功能。
这里排序的是快照,原始样本顺序保持不变。若直接把一个筛选视图交给排序,也不能假定它满足排序要求:算法仍然需要相应的迭代器能力和可交换、可写等条件。
惰性并不等于免费:重复遍历可能重复计算,某些视图还会缓存定位信息。不要让筛选和转换偷偷修改计数器、发送命令或依赖变化中的外部状态;同一个“看起来只是遍历”的操作可能调用它们多次。本文的 lambda 都只读取参数。
6. 视图的生命期必须顺着依赖往回看
本例的 nearby 来源于一个仍然存活的左值 vector,因此借用其元素;window 也借用同一批存储。没有任何视图延长 readings 的生命期。保持安全至少要满足:
- 源数据活到所有借用结束之后;不能返回指向函数局部
vector的span或借用管道。 - 使用期间不触发导致引用、指针或迭代器失效的修改;尤其不能忽略
vector扩容重分配。 - 管道中的谓词与投影所引用的外部对象也必须有效;只保证元素没销毁还不够。
- 迭代器可能依赖视图对象自身,不能随意销毁管道后继续使用它的迭代器。
不要据此总结为“所有 view 都不拥有数据”。标准库也有持有底层 range 的 owning_view;所有权取决于具体视图与构造方式。本例使用左值来源,所以应按借用分析。C++23 工作草案 N4950:range.owning.view
这里的“借用”是所有权描述,也不要与 ranges::borrowed_range 概念混为一谈。后者关注 range 对象销毁后,迭代器是否仍能独立有效;一条引用底层容器的 filter_view 并不因此自动满足它。它同样不能保证底层元素永远存活。
7. 什么时候值得引入这些设施
已有多个类型共享同一组能力时,用 concept 给泛型入口命名;函数只需要访问一段连续数据时,用 span 降低容器耦合;筛选与转换组合清晰时,用 ranges 表达管道。需要跨线程保存、延迟很久执行或脱离源数据独立使用时,优先建立拥有元素的结果。
下一节 C++23 的显式错误与可移动行为 会把“失败”和“独占资源的行为”继续放进接口中。返回 程序设计知识分类。