程序设计 04:C++20 的约束、范围与视图

处理一批读数时,接口需要回答两个问题:元素必须提供什么能力?函数可以访问哪一段数据?C++20 的 concepts 和 span 分别让这两个问题更明确,ranges 则把“筛选、转换、消费”的过程连起来。

本文使用内存中的模拟数据,不读硬件、不执行控制动作。上一节见 C++17 的值、状态与借用,完整主线见 程序设计知识分类。

1. 类型约束与运行时条件各管一层

DistanceSample 要求一个类型能在 const 对象上调用 distance_mm() 与 healthy(),并分别返回 int 与 bool。这个要求可以在编译期检查;“返回值是否真以毫米为单位”却不能仅靠表达式的形状证明。

因此程序分成两层:concept 检查接口形状,healthy() 根据本例的数据规则判断读数是否可用。若误把米当成毫米,即使概念检查全部通过,结果依然会错。concepts 可以表达约束,也有必须由实现者遵守的语义要求,编译器并不能自动验证全部语义。

2. 完整程序:从一段数据得到一份快照

下载 cpp20_contracts.cpp,内容与下列程序一致。

#include <algorithm>
#include <concepts>
#include <iostream>
#include <ranges>
#include <span>
#include <vector>
#include <version>

#if !defined(__cpp_lib_span) || __cpp_lib_span < 202002L
#error "This example requires C++20 std::span."
#endif
#if !defined(__cpp_lib_ranges) || __cpp_lib_ranges < 201911L
#error "This example requires C++20 ranges."
#endif

struct Reading {
int millimeters;
bool present;

int distance_mm() const { return millimeters; }
bool healthy() const { return present && millimeters >= 0; }
};

template<class T>
concept DistanceSample = requires(const T& sample) {
{ sample.distance_mm() } -> std::same_as<int>;
{ sample.healthy() } -> std::same_as<bool>;
};

static_assert(DistanceSample<Reading>);
static_assert(!DistanceSample<int>);

template<DistanceSample T>
int total_distance(std::span<const T> samples) {
auto distances = samples
| std::views::filter([](const T& s) { return s.healthy(); })
| std::views::transform([](const T& s) { return s.distance_mm(); });
int total = 0;
for (int millimeters : distances) {
total += millimeters;
}
return total;
}

int main() {
const std::vector<Reading> readings{
{120, true}, {-1, false}, {230, true}, {90, true}
};
const std::span<const Reading> window{readings};
std::cout << "samples=" << window.size() << '\n';
std::cout << "total_mm=" << total_distance(window) << '\n';

auto nearby = readings
| std::views::filter([](const Reading& s) {
return s.healthy() && s.distance_mm() < 200;
})
| std::views::transform([](const Reading& s) {
return s.distance_mm();
});

std::vector<int> snapshot;
for (int millimeters : nearby) {
snapshot.push_back(millimeters);
}
std::ranges::sort(snapshot);
std::cout << "nearby_sorted_mm=";
for (int millimeters : snapshot) {
std::cout << ' ' << millimeters;
}
std::cout << '\n';
std::cout << "first_two_total_mm="
<< total_distance(window.first(2)) << '\n';
}

在源码所在目录运行:

g++ -std=c++20 -Wall -Wextra -Werror -pedantic cpp20_contracts.cpp -o cpp20_contracts
./cpp20_contracts

准确输出:

samples=4
total_mm=440
nearby_sorted_mm= 90 120
first_two_total_mm=120

已在 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 的显式错误与可移动行为 会把“失败”和“独占资源的行为”继续放进接口中。返回 程序设计知识分类。

知识共享许可协议