用 std::span 替代 const vector&:零成本的通用性
一个接受 const std::vector<T>& 的函数虽然能正确工作,但不必要地限制了调用者。调用者必须拥有一个 std::vector——即使数据存在 C 数组、std::array、内存映射区域或现有容器的切片中。std::span(C++20)或 absl::Span 可以零开销地接受所有这些类型。
这是 Abseil C++ Tip #93。下面的基准测试证实了这一点。
问题所在
void ProcessData(const std::vector<int>& data);
持有 std::array 或 C 数组的调用者被迫构造临时的 std::vector,毫无理由地复制数据:
std::array<int, 4> a = {1, 2, 3, 4};
ProcessData({a.begin(), a.end()}); // 分配并复制
这个函数签名拒绝了合法的输入,并强制执行了不必要的工作。
解决方案
void ProcessData(std::span<const int> data);
现在所有这些调用都能无需分配地工作:
std::vector<int> v = GetIds();
std::array<int, 3> a = {1, 2, 3};
int c_arr[] = {4, 5, 6};
ProcessData(v); // OK — 直接绑定
ProcessData(a); // OK — 直接绑定
ProcessData(c_arr); // OK — 直接绑定
ProcessData(v.subspan(100)); // OK — 子范围,无需复制
基准测试
所有基准测试调用一个对输入元素求和的函数(防止编译器消除死代码)。测试了四种场景:
| 场景 | 描述 |
|---|---|
| 大 Vector | 直接传入 1000 元素的 std::vector |
| 小字面量 | 用花括号初始化 {1, 2, 3, ...} 调用 |
| C 数组 | int arr[100] 作为输入 |
| 子范围 | 1000 元素 vector 的后 500 个元素 |
结果
| 场景 | const vector& | std::span | absl::Span | 加速比 |
|---|---|---|---|---|
| 大 Vector | 189 ns | 189 ns | 189 ns | — |
| 小字面量 | 13.3 ns | 0.28 ns | 0.28 ns | ~48 倍 |
| C 数组 | 50.6 ns | 18.3 ns | 18.3 ns | ~2.8 倍 |
| 子范围 | 148 ns | 95.9 ns | 95.9 ns | ~1.5 倍 |
当调用者已经持有 std::vector 时,没有差异——std::span 是零开销视图。当调用者持有其他类型的容器时,优势就显现了。
“小字面量” 场景最为极端:const vector& 强制对一个 4 元素数组进行堆分配和复制。std::span 只是记录一个指针和一个长度。
什么时候不适合用 span
需要所有权转移。 如果函数需要存储或修改超出调用生命周期的数据,应该按值或按指针接收容器,而不是视图。
生命周期敏感。 span 不拥有数据。调用者必须确保底层数据的生命周期超过 span 的使用期。这与 string_view 的契约相同——对 C++17 代码库来说是熟悉的领域。
内部私有 API。 一个只被 std::vector 调用的私有辅助函数可以保留 const vector& 以保持清晰。主要收益在公共 API 边界。
建议
对于任何当前接受 const std::vector<T>& 的只读函数参数:
- 如果使用 C++20,替换为
std::span<const T>。 - 如果使用 C++17 并依赖 Abseil,替换为
absl::Span<const T>。 - 如果函数需要所有权,保留 vector(或按值接收)。
这是一个机械性的改动,能提升通用性,并在非平凡的调用模式中提升性能。