36、C++20 核心特性:std::span,数组的视图,安全且高效。
聊到 C++20 的 std::span,我第一反应就是:这玩意儿早该有了。你想想看,我们写 C++ 多少年了,传递一个数组或者连续内存块,要么用裸指针+长度,要么用 vector<T>&。前者容易越界,后者又绑死了容器类型。说白了,我们缺一个「不拥有数据、只提供视图」的东西。
std::span 就是干这个的。它不分配内存,不拷贝元素,只是给你一个对连续内存区域的「安全窗口」。我个人习惯把它叫做「数组的眼镜」——戴上它,你看数组的视野清晰了,还不用担心戳到眼睛。
什么是 std::span?
一句话:std::span<T> 是一个轻量级的、非拥有的连续内存视图。它内部只存了两个东西:一个指向首元素的指针,和一个长度。就这么简单。
它支持哪些数据源?
- 原生数组:
int arr[10] std::arraystd::vectorstd::string(注意是连续的 char)- 任何满足
std::contiguous_iterator的范围
嗯,这里要注意:std::span 不拥有数据,所以你不能通过它来扩容或删除元素。它就是个「只读/读写」的窗口。
为什么需要它?
我在项目中遇到过这样一个场景:一个函数需要处理来自不同来源的浮点数组。有时候是 std::vector<double>,有时候是 C 风格数组,有时候是 std::array。以前我怎么办?要么写三个重载,要么用 double* + size_t。前者代码膨胀,后者不安全。
std::span 完美解决了这个问题。它统一了接口,而且自带边界检查(如果你用 at() 或 subspan() 的话)。
核心优势:
- 零开销抽象:编译后就是指针+长度,没有虚函数,没有动态分配
- 类型安全:知道元素类型,不像
void*那样裸奔 - 边界安全:提供
at()和subspan()等安全接口 - 兼容性好:可以跟 C 接口无缝对接
基本用法
先看一个最简单的例子:
#include <span>
#include <vector>
#include <iostream>
void print_span(std::span<const int> s) {
for (auto v : s) {
std::cout << v << " ";
}
std::cout << "\n";
}
int main() {
int arr[] = {1, 2, 3, 4, 5};
std::vector vec = {10, 20, 30};
std::array arr2 = {100, 200};
print_span(arr); // 原生数组
print_span(vec); // vector
print_span(arr2); // array
return 0;
}
你看,同一个函数,三种不同的数据源,完美兼容。这就是 std::span 的魅力。
动态扩展与子视图
std::span 支持两种大小:
std::span<T>:动态大小,运行时确定std::span<T, N>:固定大小,编译期确定
我个人更常用动态大小的,灵活。但如果你知道数组大小是编译期常量,固定大小的 span 可以带来更好的优化。
子视图操作也很实用:
void process_subspan(std::span<int> data) {
// 取前3个元素
auto first3 = data.first(3);
// 取最后2个元素
auto last2 = data.last(2);
// 从索引1开始取4个元素
auto middle = data.subspan(1, 4);
}
我曾经在写一个滑动窗口算法时,就用 subspan 来避免拷贝数据。性能提升很明显,因为省掉了大量的内存分配。
避坑指南
嗯,这里我要说几个我踩过的坑:
我曾经犯过的错误:
- 悬垂引用:
std::span不拥有数据,所以如果原始数据被销毁了,span 就变成了野指针。我曾经在函数里返回了一个指向局部vector的span,结果调用方拿到的是垃圾数据。 - 修改数据源:如果你通过
span修改了元素,原始容器也会变。这不是 bug,是特性。但如果你没意识到这一点,可能会出问题。 - 不要存储 span:除非你非常确定原始数据的生命周期。我建议只在函数参数中使用
span,不要把它作为类成员长期持有。
与 C 接口的互操作
这个太实用了。很多时候我们需要调用 C 库函数,它们通常接受 T* + size_t。std::span 提供了 data() 和 size() 方法,可以直接传进去:
// 假设有个 C 函数
void c_process(double* data, size_t len);
void modern_process(std::span<double> data) {
// 直接传给 C 函数
c_process(data.data(), data.size());
}
你看,桥接 C 和 C++ 代码变得非常优雅。我个人在重构一个遗留系统时,就用 span 逐步替换了裸指针参数,安全多了。
性能对比
很多人担心 std::span 有性能开销。其实完全不用担心。我们来看一个简单的对比:
| 方式 | 传参开销 | 边界安全 | 类型安全 | 灵活性 |
|---|---|---|---|---|
T* + size_t |
两个寄存器 | 无 | 无 | 高 |
const vector<T>& |
一个引用 | 有 | 有 | 低(只接受 vector) |
std::span<T> |
两个寄存器 | 有(at/subspan) | 有 | 高(接受多种容器) |
说白了,std::span 在性能上和裸指针方案一样快,但安全性和灵活性高得多。这就是我为什么说它是「零开销抽象」的典范。
知识体系图
下面这张图展示了 std::span 在整个 C++ 连续内存视图体系中的位置:
总结
std::span 是 C++20 中一个看似简单但极其实用的工具。它解决了 C++ 长期以来的一个痛点:如何安全、高效地传递连续内存的视图。我个人建议,从现在开始,凡是需要传递数组或连续内存块的函数参数,优先考虑 std::span。它会让你的代码更安全、更通用、更现代。
我的建议:
- 用
std::span<const T>表示只读视图 - 用
std::span<T>表示可读写视图 - 避免存储
span作为长期持有的成员 - 在性能敏感的代码中放心使用,它和裸指针一样快
嗯,关于 std::span 就聊到这里。记住,它不拥有数据,它只是帮你「看」数据。用好了,你的代码会变得更安全、更清晰。